useRef 与 DOM 操作:refs 的完整使用手册
第7章:useRef 与 DOM 操作:完整的 Ref 手册
useRef 是 React 中连接声明式世界与命令式现实的桥梁——它的不变性和持久性使它在三类场景中不可替代。
本章核心问题:useRef 的三种用途分别是什么?什么时候该用 ref 而非 state? 读完本章你将理解:
- Ref 有三种用途:持久化可变值、访问 DOM 节点、打破闭包陷阱
- React 19 允许 ref 作为普通 prop 传递,forwardRef 不再必要
- useImperativeHandle 精确控制暴露给父组件的命令式 API
Level 1 · 你需要知道的(1-3年经验)
Ref 的三种用途
useRef 返回一个可变的对象 { current: initialValue },这个对象在组件的整个生命周期中保持同一个引用。修改 .current 不会触发重渲染——这是 ref 与 state 的根本区别。
正是这两个特性(持久性 + 变更不触发渲染)让 ref 在三个场景中不可替代:
用途一:持久化跨渲染的可变值,比如计时器 ID、WebSocket 实例、上一次渲染的值。
用途二:访问 DOM 节点,对 DOM 进行命令式操作(聚焦、测量、动画)。
用途三:打破闭包陷阱,存储最新的回调函数或状态值,避免过期引用。
持久化可变值:不该放进 state 的数据
并非所有"需要在渲染间保持"的数据都适合放在 state 里。如果数据变化不需要触发 UI 更新,放进 state 就是过度设计——每次修改都会导致不必要的重渲染。
function StopWatch() {
const [elapsedMs, setElapsedMs] = useState(0);
const intervalRef = useRef<number | null>(null);
function start() {
if (intervalRef.current !== null) return; // 已经在运行
const startTime = Date.now() - elapsedMs;
intervalRef.current = setInterval(() => {
setElapsedMs(Date.now() - startTime);
}, 10);
}
function stop() {
clearInterval(intervalRef.current!);
intervalRef.current = null;
}
return (
<div>
<p>{(elapsedMs / 1000).toFixed(2)}s</p>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
);
}
intervalRef 存储定时器 ID,修改它不需要重渲染,用 ref 是正确的。elapsedMs 需要显示在 UI 上,用 state 是正确的。
存储上一次渲染的值
一个经典应用是比较当前值与上一次渲染的值:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
// 在 render 期间同步更新(不是在 effect 里)
// 注意:这在 React 严格模式中可能有微妙行为
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function PriceDisplay({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const direction = prevPrice === undefined ? 'neutral'
: price > prevPrice ? 'up' : price < prevPrice ? 'down' : 'neutral';
return <span className={direction}>{price}</span>;
}
这里有一个微妙之处:useEffect 在渲染后运行,所以在 PriceDisplay 渲染期间,ref.current 是上一次的值,Effect 运行后更新为当前值——这正是我们想要的"前一次渲染的值"语义。
DOM 访问:命令式操作的正确方式
React 的默认工作方式是声明式的:你描述 UI 应该是什么样子,React 负责操作 DOM。但有些操作本质上是命令式的:
- 聚焦(
focus()) - 测量元素尺寸
- 触发动画
- 与不受 React 管理的第三方 DOM 库集成
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
将 ref 对象传给 JSX 元素的 ref 属性后,React 会在组件挂载时将 DOM 节点赋给 ref.current,卸载时重置为 null。
不要在渲染期间读取 ref.current
这是最常见的 ref 使用错误:
function Wrong() {
const ref = useRef(null);
// 错误:在渲染期间读取 DOM ref
// 第一次渲染时 ref.current 是 null,
// 即使后续渲染有值,也不能保证一致性
const width = ref.current?.offsetWidth ?? 0;
return <div ref={ref} style={{ fontSize: width > 500 ? '20px' : '16px' }}>
内容
</div>;
}
渲染期间 ref 不一定已经被赋值。正确的方式是在 Effect(或 Layout Effect)中读取:
function Correct() {
const ref = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(16);
useLayoutEffect(() => {
if (ref.current) {
setFontSize(ref.current.offsetWidth > 500 ? 20 : 16);
}
}, []); // 必要时添加更多依赖
return <div ref={ref} style={{ fontSize }}></div>;
}
Level 2 · 它是怎么运行的(3-5年经验)
forwardRef:跨组件传递 Ref
默认情况下,ref 不能传递给自定义组件——React 会报警告而不是把 ref 赋给 DOM 节点。如果你构建的是可复用的 UI 组件库,往往需要让父组件能够访问子组件内部的 DOM 节点(例如让父组件能调用 focus())。
React 18 及之前的方案是 forwardRef:
// React 18 及之前
const CustomInput = forwardRef<HTMLInputElement, { label: string }>(
function CustomInput({ label }, ref) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
);
// 父组件
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<CustomInput label="用户名" ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
</>
);
}
forwardRef 把 ref 从 "不透明的特殊属性" 变成了普通的第二个参数,让组件可以决定把它转发到哪里。
useImperativeHandle:精确控制暴露的 API
有时候你不希望父组件能访问整个 DOM 节点(这会导致父组件绕过 React 直接操作 DOM),而只想暴露特定的方法:
type VideoPlayerHandle = {
play: () => void;
pause: () => void;
seekTo: (time: number) => void;
};
// React 18 写法
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
function VideoPlayer({ src }, ref) {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play() {
videoRef.current?.play();
},
pause() {
videoRef.current?.pause();
},
seekTo(time: number) {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
},
}), []);
return <video ref={videoRef} src={src} />;
}
);
// 父组件只能调用 play/pause/seekTo,无法访问 video DOM 节点
function MovieScreen() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<>
<VideoPlayer ref={playerRef} src="/movie.mp4" />
<button onClick={() => playerRef.current?.play()}>播放</button>
</>
);
}
useImperativeHandle 的第三个参数是依赖数组,遵循与 useEffect 相同的规则:当依赖变化时重新创建 handle 对象。如果 handle 方法只依赖 ref(ref 是稳定的),依赖数组可以是空数组。
什么时候应该用 useImperativeHandle?
- 构建可复用组件库时,想暴露稳定的命令式 API(play/pause/focus/reset)
- 封装第三方 DOM 库,只暴露 React 友好的接口
- 需要阻止父组件直接访问内部 DOM 节点
不应该用它把组件变成"充满命令式方法的控制器"。如果你发现 useImperativeHandle 里有十个方法,通常意味着这些操作应该由状态驱动,而不是命令式触发。
ref 回调:精细控制挂载时机
除了传递 ref 对象,你还可以传递一个ref 回调函数。当 DOM 节点挂载时,React 会调用这个函数并传入节点;卸载时调用并传入 null:
function MeasureDiv() {
const [height, setHeight] = useState<number | null>(null);
const measuredRef = useCallback((node: HTMLDivElement | null) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<>
<div ref={measuredRef}>内容</div>
{height !== null && <p>高度:{height}px</p>}
</>
);
}
ref 回调的一个重要细节:如果回调函数在每次渲染时都是新的(内联函数),React 会在每次渲染时先调用 null,再调用新节点——相当于卸载再挂载。用 useCallback 稳定化回调可以避免这个问题。
ref 回调的高级用法:观察多个节点
function List({ items }: { items: string[] }) {
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
function getOrCreateRef(id: string) {
return (node: HTMLLIElement | null) => {
if (node) {
rowRefs.current.set(id, node);
} else {
rowRefs.current.delete(id);
}
};
}
function scrollToItem(id: string) {
rowRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth' });
}
return (
<ul>
{items.map(item => (
<li key={item} ref={getOrCreateRef(item)}>
{item}
</li>
))}
</ul>
);
}
这个模式比用数组索引存储 ref 更健壮:列表项可以重排、添加、删除,ref 映射始终与实际 DOM 对应。
Level 3 · 规范怎么定义的(资深)
React 19:ref 作为普通 Prop
React 19 消除了 forwardRef 的必要性。现在可以直接把 ref 作为普通 prop 传递:
// React 19:不需要 forwardRef
function CustomInput({ label, ref }: { label: string; ref?: React.Ref<HTMLInputElement> }) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
// 使用方式完全相同
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<CustomInput label="用户名" ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
</>
);
}
这是 React 19 最受欢迎的 API 简化之一。forwardRef 产生了额外的包装组件层,在 DevTools 中增加了噪音。React 19 的 ref-as-prop 让组件树更清晰。
向后兼容:React 19 仍然支持 forwardRef,不需要立即迁移现有代码,但新代码建议直接用 prop 方式。
Level 4 · 边界与陷阱(所有人)
生产环境常见问题
在实际项目中,本章涵盖的概念最常见的问题包括:
-
忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。
-
过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。
-
文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。