第92课:useRef 与 DOM 操作——ref 对象、forwardRef、useImperativeHandle
在 React 的声明式编程模型中,绝大多数场景下你不需要直接操作 DOM——React 通过 State 和 Props 自动保持 UI 与数据同步。但总有例外:聚焦输入框、测量元素尺寸、与第三方非 React 库集成、存储不参与渲染的变量……这些场景需要一个脱离 React 渲染周期的逃生舱。useRef 就是这个逃生舱。它创建的是一个普通的 JavaScript 对象,其 current 属性可以在组件的整个生命周期中持续存在,且修改 current 不会触发重新渲染。结合 forwardRef(将 ref 传递给子组件)和 useImperativeHandle(限制子组件暴露给父组件的方法),你可以在保持声明式架构的同时,安全地跨越虚拟 DOM 直接与底层 DOM 或组件实例交互。
1. useRef 的基本概念与两大用途
useRef 返回一个可变的对象,该对象在整个组件生命周期中保持相同的引用。它的 current 属性既可以存储任意值,也可以用来关联 DOM 元素。
1 | import { useRef } from 'react'; |
useRef 与 useState 的核心区别:
| 特性 | useRef |
useState |
|---|---|---|
| 返回值 | 一个 { current: initialValue } 的普通对象。 |
一个状态值和一个更新函数。 |
| 是否触发重新渲染 | 修改 current 不触发重新渲染。 |
调用更新函数会触发重新渲染。 |
| 值的变化时机 | 直接修改 current,变化立即可见。 |
更新函数被调用后,在下一次渲染时才能获取到新值。 |
| 典型用途 | 存储 DOM 引用、保存不需要触发渲染的可变值。 | 存储驱动 UI 变化的数据。 |
1.1 用途一:绑定 DOM 元素
将 useRef 返回的对象传递给 JSX 元素的 ref 属性,React 会在组件挂载后将该元素的 DOM 节点赋值给 ref.current,在组件卸载前将 ref.current 重置为 null。
1 | function VideoPlayer() { |
访问时机:ref.current 在首次渲染时为 null(因为 DOM 尚未生成)。它仅在 useEffect 或事件处理函数中才指向有效的 DOM 节点。在 JSX 中直接读取 ref.current(如在 <p>{ref.current?.textContent}</p> 中)是无效的——渲染时 ref 还未赋值,且 React 不会因为 ref 变化而重新渲染。
1.2 用途二:存储不需要渲染的可变值
useRef 非常适合保存不会直接影响 UI 的变量,比如定时器 ID、请求标识、前一次渲染的值等。由于修改 current 不触发渲染,你可以在回调或效果中自由更新它而无需担心不必要的渲染。
1 | function Timer() { |
这里 intervalRef 存储定时器 ID。由于定时器 ID 的变化不需要导致 UI 更新,使用 useRef 而非 useState 是最佳选择。
1.3 回调 Ref(Callback Ref)
除了传递 useRef 对象给 ref 属性,你还可以传递一个函数。这个函数会在 React 将 DOM 节点附加到元素时调用(参数为 DOM 节点),在节点被移除时再次调用(参数为 null)。回调 ref 比对象 ref 更灵活,尤其适合需要在 ref 附加/移除时执行逻辑的场景(如动态测量元素尺寸)。
1 | function MeasureExample() { |
2. forwardRef:将 ref 传递给子组件
默认情况下,React 组件的 ref 属性只对原生 HTML 元素有效,不能直接传递给自定义组件。如果你尝试 <MyInput ref={inputRef} />,React 会抛出警告——因为自定义组件默认没有 ref 这个 prop。
forwardRef 允许组件将接收到的 ref 转发给其内部的某个 DOM 元素或子组件。它接收一个渲染函数作为参数,该函数的第二个参数就是 ref。
1 | import { forwardRef, useRef } from 'react'; |
forwardRef 的渲染函数签名:(props, ref) => ReactElement。第二个参数 ref 是父组件传递下来的 ref。你可以将它绑定到内部的 DOM 元素上,也可以传递给另一个使用了 forwardRef 的子组件。
2.1 在 forwardRef 中使用 useImperativeHandle
有时你不希望父组件完全控制子组件内部的 DOM 元素——你只想暴露一组有限的方法(如 focus、scrollToTop)。useImperativeHandle 允许你自定义暴露给父组件的 ref 值,通常配合 forwardRef 使用。
1 | import { forwardRef, useRef, useImperativeHandle } from 'react'; |
useImperativeHandle(ref, createHandle, [deps]) 接收三个参数:
ref:父组件传递的 ref。createHandle:一个函数,返回你想要暴露给父组件的对象(即ref.current的值)。deps:依赖数组。当依赖变化时,createHandle会重新执行并更新暴露的对象。
使用场景:当你封装了一个复杂的组件,希望对外隐藏内部 DOM 结构,只暴露必要的命令式方法。这是面向对象设计中“封装”理念在 React 组件中的体现。
3. useRef 与 useEffect 的协作模式
useRef 常与 useEffect 结合使用,解决“需要读取最新值但不想重新执行效果”的场景(闭包陷阱的解决方案之一)。
3.1 跟踪前一次的 Props 或 State
1 | function usePrevious(value) { |
3.2 在异步回调中访问最新 State
1 | function LatestValue() { |
4. 最佳实践与常见陷阱
4.1 不要在渲染期间读写 ref.current
ref.current 的修改不会触发渲染,因此如果在 JSX 中直接使用 ref.current,UI 不会自动更新。如果需要根据 ref 的值驱动 UI 变化,应该使用 State,并在效果或事件中同步 ref 到 state。
4.2 避免过度使用 forwardRef + useImperativeHandle
这种模式适用于必须封装命令式 API 的场景(如输入框的 focus、视频播放器的 play/pause)。大多数情况下,通过 Props 和 State 进行声明式交互是更好的选择。如果滥用,会导致组件间耦合增加,降低可维护性。
4.3 使用 TypeScript 为 ref 提供类型
1 | const inputRef = useRef<HTMLInputElement>(null); |
对于通过 forwardRef 暴露的 ref,需要定义 ref 的类型:
1 | type FancyInputHandle = { |
4.4 避免在多个效果中依赖 ref 的值
如果多个 useEffect 都需要读取某个 State 的最新值,考虑使用 useRef 存储一个“最新值”,但要注意保持一致性——最好将更新 ref 的 useEffect 放在单一位置,避免多处更新导致竞态。
5. 综合示例:可滚动到底部的聊天组件
1 | import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react'; |
设计要点:
MessageList通过forwardRef和useImperativeHandle暴露scrollToBottom方法,隐藏了内部listRef的 DOM 细节。- 父组件通过
listRef.current?.scrollToBottom()在消息更新后滚动到底部。 - 输入框通过
useState受控,发送后清空。
课后练习
一、概念自测(选择题 / 填空题)
(单选)
useRef返回的对象在组件整个生命周期中有什么特性?
A. 每次渲染都会生成一个新的对象。
B. 同一个对象引用,修改current会触发重新渲染。
C. 同一个对象引用,修改current不会触发重新渲染。
D. 仅用于保存 DOM 引用,不能存储其他值。(单选)
useImperativeHandle的主要作用是什么?
A. 将子组件的 DOM 节点直接暴露给父组件。
B. 自定义暴露给父组件的 ref 值,通常用于限制父组件可调用的方法。
C. 将 props 转发给子组件。
D. 在组件间共享状态。(填空) 要将 ref 从父组件传递给子组件内部的 DOM 元素,需要使用
______包裹子组件。(多选) 以下哪些场景适合使用
useRef而非useState?
A. 存储定时器 ID。
B. 存储触发 UI 更新的表单输入值。
C. 获取输入框 DOM 元素以实现焦点控制。
D. 在异步回调中读取最新的 State 值而不触发重新渲染。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要实现一个倒计时组件,具备以下功能:
- 使用 Props 接收初始秒数(
initialSeconds)和倒计时结束后触发的回调(onComplete)。 - 内部使用
useRef存储定时器 ID,使用useState存储剩余秒数。 - 提供“开始”、“暂停”、“重置”三个按钮。
- 倒计时到 0 时自动停止,并调用
onComplete。 - 在组件卸载时清除定时器,防止内存泄漏。
- 使用
useRef确保在回调中能访问最新的onComplete回调(因为onComplete可能在倒计时过程中被替换)。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 React 组件代码。提示词中需明确指定 useRef 的用途、清除定时器的方式以及如何避免闭包陷阱。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个 React 倒计时组件。需要创建文件
src/components/CountdownTimer.jsx:
- Props:
initialSeconds: number,onComplete?: () => void。- 使用
useState管理remaining(初始值为initialSeconds)和isRunning(初始false)。- 使用
useRef存储定时器 ID(timerRef)和最新的onComplete回调(onCompleteRef)。- 使用
useEffect同步更新onCompleteRef.current = onComplete。start()函数:如果remaining > 0 && !isRunning,设置isRunning为true,并启动一个setInterval(1000ms),将定时器 ID 存入timerRef。定时器回调中使用函数式更新setRemaining(prev => { if (prev <= 1) { clearInterval(timerRef.current); onCompleteRef.current?.(); return 0; } return prev - 1; })。pause()函数:clearInterval(timerRef.current),isRunning设为false。reset()函数:clearInterval(timerRef.current),remaining重置为initialSeconds,isRunning设为false。- 返回 JSX:显示剩余秒数,以及三个按钮“开始”、“暂停”、“重置”(根据
isRunning状态切换按钮禁用)。- 使用
useEffect清理函数return () => clearInterval(timerRef.current)。- 添加 JSDoc 注释,确保代码可直接运行。完成后输出完整文件内容。
四、面试真题与参考答案
题目(阿里巴巴前端面试题):
请解释 React 中
useRef的两大用途,并与useState对比。在什么情况下应该使用useRef而不是useState?forwardRef和useImperativeHandle的配合使用解决了什么实际问题?请给出一个具体的场景。
参考答案:
useRef 的两大用途:一是作为 DOM 元素的引用,通过 ref 属性绑定到 JSX 元素,获取真实 DOM 节点;二是保存任意不需要触发重新渲染的可变值,如定时器 ID、上一次的 State/Props 等。
与 useState 的对比:useState 管理的是驱动 UI 渲染的状态,更新时会触发组件重新渲染;useRef 修改 current 不会触发渲染,适合存储不影响 UI 的变量或需要跨渲染保持同一引用的值。
应使用 useRef 的场景:存储定时器 ID、存储 DOM 引用、缓存不需要触发渲染的计算结果、在回调中访问最新 State/Props 但不希望将该变量加入依赖数组。
forwardRef 和 useImperativeHandle 配合解决了组件封装与命令式 API 暴露之间的矛盾。典型场景:一个自定义的输入框组件(FancyInput),父组件需要对其执行 focus() 操作。如果直接暴露内部 <input> 的 ref,父组件将能够任意操作该 DOM 元素,破坏了封装性。通过 forwardRef 接收父组件的 ref,再用 useImperativeHandle 自定义暴露一个仅包含 focus() 和 blur() 的对象,父组件只能调用这些有限的、经过封装的方法,而不能直接访问内部 DOM。这既满足了必要的命令式控制,又保持了组件的封装性。
课后练习答案
一、概念自测答案
C
- 解析:
useRef返回同一个对象引用,修改current不触发渲染。A 错误,每次渲染返回同一对象;B 错误,修改current不触发渲染;D 错误,可存储任意值。
- 解析:
B
- 解析:
useImperativeHandle自定义暴露给父组件的 ref 值,限制父组件可访问的方法。A 是forwardRef直接转发 DOM 节点的行为;C 描述的是forwardRef本身;D 无关。
- 解析:
forwardRef- 解析:
forwardRef用于将 ref 从父组件转发给子组件内部的 DOM 元素或另一个组件。
- 解析:
A、C、D
- 解析:B 错误,表单输入值需要驱动 UI 更新,应使用
useState。A、C、D 均适合useRef。
- 解析:B 错误,表单输入值需要驱动 UI 更新,应使用
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请用 React 实现一个倒计时组件。要求:
- Props:
initialSeconds: number,onComplete?: () => void。- 使用
useRef存储定时器 ID 和最新的 onComplete 回调。- ‘开始’启动 setInterval,‘暂停’清除定时器,‘重置’恢复初始值并停止。
- 倒计时到 0 时调用 onComplete 并停止。
- 组件卸载时清除定时器。
- 输出完整组件代码,使用 TypeScript。”