React的核心思想是声明式渲染:你描述界面应该是什么样子,React负责更新真实的DOM。不过在开发中总有一些绕不开直接操作DOM的场景,比如页面加载完成后自动聚焦搜索框、监听滚动实现吸顶导航、初始化一个基于Canvas的图表库,或者控制视频元素的播放与暂停。React并没有禁止这类操作,而是提供了一整套受控的方案——useRef钩子与Refs转发机制。用得好,它们能让代码既保持React的风格,又能精确控制DOM节点。

useRef钩子的基本原理与访问DOM的方法
useRef是React内置的钩子函数,调用后会返回一个普通的JavaScript对象,这个对象有一个current属性,可以在组件的整个生命周期内持久保存。它最大的特点是:组件重新渲染时,useRef返回的对象引用保持不变,你写入current的值也不会被重置。这一点和useState完全不同,useState的值变化会触发重新渲染,而useRef只是默默存值。
访问DOM时,用法非常直接:先调用useRef(null)创建一个ref对象,然后把它作为ref属性挂到任意一个HTML标签上,React会在组件挂载后把真实的DOM节点赋值给ref.current。来看一个自动聚焦输入框的例子:
import { useRef, useEffect } from 'react';
function SearchInput() {
const inputRef = useRef(null);
useEffect(() => {
// 组件挂载后,inputRef.current 指向真实的 input 元素
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="请输入关键词" />;
}这里有个关键细节:访问DOM必须放在useEffect或者事件回调中,而不是渲染函数体内。因为渲染阶段DOM可能还没更新,此时ref.current还是初始值null,强行访问会抛出Cannot read properties of null的错误。React官方也明确建议,不要在渲染期间读写ref,这会破坏渲染的纯度。
除了聚焦,useRef操作DOM的常见场景还包括:读取元素尺寸(配合getBoundingClientRect)、手动控制滚动条位置(scrollTop)、控制audio或video的播放、在Canvas上初始化绘图上下文。这些操作在React的props体系里没有对应的表达方式,用ref是最自然的路径。
Refs转发:让父组件拿到子组件内部的DOM
useRef只能拿到自己组件内渲染的DOM节点。如果DOM在子组件内部,父组件传下去的ref并不会自动生效——因为ref在React里不是一个普通prop,子组件如果没有特殊处理,它会被忽略。这就是Refs转发(Forwarding Refs)要解决的问题。
React提供了forwardRef函数来实现转发。它接收一个渲染函数,这个函数的第二个参数就是父组件传入的ref,你可以把它继续挂到内部的DOM元素上:
import { forwardRef, useRef } from 'react';
// 子组件用 forwardRef 包裹,接收并转发 ref
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy-input" {...props} />;
});
// 父组件像操作普通 DOM 一样操作子组件内部的 input
function Parent() {
const inputRef = useRef(null);
return (
<div>
<FancyInput ref={inputRef} placeholder="转发测试" />
<button onClick={() => inputRef.current.focus()}>
聚焦子组件输入框
</button>
</;/div>
);
}这在封装表单组件、按钮组件时特别常用。比如你写了一个统一风格的MyButton,使用方往往希望能拿到底层的原生button去做聚焦或无障碍处理,forwardRef能让封装组件保持透明,不给使用者添堵。
值得一提的是,React 19之后函数组件可以直接接收ref作为普通prop,不再强制要求forwardRef包装。但如果你的项目还在使用较老版本,或者要发布兼容多版本的通用组件库,forwardRef仍是标准做法。
useImperativeHandle:暴露自定义接口而不是裸DOM
直接把内部DOM暴露给父组件虽然方便,但也有隐患:父组件可以随意修改内部元素的样式、属性,破坏了子组件的封装性。更优雅的做法是使用useImperativeHandle,只暴露你精心设计的几个方法,隐藏实现细节:
import { forwardRef, useRef, useImperativeHandle } from 'react';
const VideoPlayer = forwardRef(function VideoPlayer(props, ref) {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
seek: (time) => { videoRef.current.currentTime = time; }
}), []);
return <video ref={videoRef} src={props.src} />;
});
// 父组件只能调用 play、pause、seek,无法直接改内部 video 的属性
function App() {
const playerRef = useRef(null);
return (
<div>
<VideoPlayer ref={playerRef} src="demo.mp4" />
<button onClick={() => playerRef.current.play()}>播放</button>
</div>
);
}useImperativeHandle的第三个参数是依赖数组,不传的话每次渲染都会重新创建暴露的对象。对于固定接口的组件,传入空数组可以避免不必要的开销。这种模式本质上是把命令式操作收敛到明确的接口层,既保留了操作DOM的能力,又维持了组件边界的清晰。
常见误用场景与注意事项
第一,不要滥用ref。能用state和props表达的逻辑就不要碰DOM。比如控制元素显示隐藏,用条件渲染或CSS类切换明显比手动调用style.display更符合React哲学。判断标准很简单:只有那些React抽象不掉的浏览器原生能力,才值得用ref。
第二,注意清理副作用。如果用ref配合了事件监听、定时器或第三方库实例,必须在useEffect的返回函数中清理,否则组件卸载后残留的监听器会导致内存泄漏:
useEffect(() => {
const el = containerRef.current;
const chart = echarts.init(el);
return () => {
// 组件卸载时销毁实例,防止内存泄漏
chart.dispose();
};
}, []);第三,避免在渲染阶段读写ref.current,也不要用ref存储需要驱动渲染的数据。如果发现某个值变了界面要跟着变,那它应该放进useState。此外,ref回调也有一个小坑:React每次更新时会先调用ref传null再传节点,写回调ref时要处理好null判断,否则会在卸载或切换节点时报错。
总结一下,useRef负责组件内部直达DOM,forwardRef负责跨组件转发,useImperativeHandle负责收敛暴露的接口。三者的组合基本覆盖了React中直接操作DOM的所有需求,理解各自的边界,就能写出既灵活又不失维护性的组件代码。
useRefReact Refs转发DOM操作修改时间:2026-09-09 08:40:44