React 中如何让父组件访问子组件内部的 DOM 或有限的命令式能力?
ref 是 React 提供的“命令式入口”:它保存一个不会因为重新渲染而丢失的引用,React 会在提交阶段把 DOM 节点、类组件实例或自定义 handle 写入 ref.current,组件卸载时再清理为 null。
它适合处理无法自然表达为 props 的行为,例如聚焦、滚动、选择文本、播放动画或调用第三方 DOM API。能用 props 和状态表达的 UI,优先继续使用声明式写法。
forwardRef 和 useImperativeHandle 解决的是两个不同问题:
forwardRef:让组件接收父组件传入的 ref,并把它转交给内部节点或组件。useImperativeHandle:自定义父组件通过 ref 能看到的值,不直接暴露完整 DOM。React 版本需要特别区分:React 18 及更早版本中,函数组件不能直接接收 ref,通常需要 forwardRef;React 19 开始,ref 可以像普通 prop 一样传给函数组件,新的代码通常不再需要 forwardRef。官方已将 forwardRef 标记为未来会弃用的 API,但现有 React 18 代码和兼容库仍然大量使用它。React forwardRef 文档
useRef(initialValue) 返回一个对象:
React 会保证同一个组件实例的多次渲染之间,useRef 返回同一个 ref 对象。修改 ref.current 不会触发重新渲染,因此它适合保存:
| 对比项 | useRef | useState |
|---|---|---|
| 修改方式 | 直接修改 current | 调用 setter |
| 修改后是否触发渲染 | 不会 | 会 |
| 是否保存跨渲染数据 | 是 | 是 |
| 是否参与 UI 计算 | 通常不直接参与 | 通常参与 |
| 典型用途 | DOM、定时器、上一次值 | UI 状态、交互状态 |
如果一个值变化后界面需要更新,它通常应该是 state,而不是 ref。
ref 的更新发生在 commit 阶段,而不是 render 阶段:
因此有两个重要结论:
null 或旧节点。当节点卸载或 ref 指向其他节点时,React 会先清理旧引用。对象 ref 会被设置为 null,回调 ref 会收到 null。
React 负责维护 inputRef.current。
回调 ref 会在节点建立时收到节点,在节点移除时收到 null。它适合需要在 ref 建立或清理的瞬间执行逻辑的场景。
需要注意:如果每次 render 都创建新的回调函数,React 可能会先用 null 清理旧回调,再用节点调用新回调。需要稳定身份时,可以使用 useCallback。
在 React 18 及更早版本中,ref 和 key 类似,是 React 元素的特殊属性,不会像普通 prop 一样出现在函数组件的 props 对象中:
原因不是“函数组件没有 this”这么简单,而是 React 需要明确决定 ref 最终附着在哪个可被引用的目标上:
因此 React 18 的函数组件需要通过 forwardRef 显式声明:“我会接收这个 ref,并负责把它转发到某个目标”。
调用链是:
forwardRef 不是把 DOM 节点复制给父组件,也不是让函数组件凭空拥有实例。它只是创建一种 React 能识别的组件类型,使 ref 作为第二个参数传给 render 函数。
React 19 开始,函数组件可以直接从 props 中接收 ref:
父组件仍然这样使用:
React 19 的 ref 作为 prop 的能力只改变“组件如何接收 ref”,不会改变 ref 在 commit 阶段写入、卸载时清理等核心模型。React 19 官方说明
如果中间有多个组件,每一层都必须继续传递 ref:
只要某一层没有继续传递,ref 就会在这一层停止,父组件拿到的可能是 null 或错误的目标。
直接暴露 DOM 意味着父组件可以调用任意 DOM API,子组件的内部实现与父组件强耦合。useImperativeHandle 可以把父组件可用的能力限制为明确的接口:
父组件看到的不是 DOM,而是:
它看不到 style、value 等底层 DOM 属性。这种方式的本质是“把 ref 的目标从 DOM 节点替换成一个自定义 handle”。React useImperativeHandle 文档
React 会根据依赖变化重新执行 createHandle,并将新的 handle 写入 ref。依赖数组应包含创建 handle 时读取的响应式值;如果省略依赖数组,每次 render 都可能重新创建 handle。
如果 handle 中的方法只通过 ref 读取最新 DOM,而没有捕获 props/state,可以使用空依赖数组。若方法闭包捕获了 props 或 state,必须认真处理依赖,否则可能读取旧值。
React 19 可以直接接收 ref:
React 18 及更早版本则需要 forwardRef 包裹这一组件。
| API | 特点 | 常见场景 |
|---|---|---|
createRef | 每次调用都会返回新对象 | 类组件字段 |
useRef | 同一组件实例多次渲染保持同一个对象 | 函数组件 |
下面这种写法在函数组件中通常是错误的:
createRef 不是“不能存数据”,而是它每次调用都会创建新对象;函数组件需要跨 render 保持引用时,应使用 useRef。React createRef 文档
修改 ref.current 不会触发 render:
需要驱动 UI 的数据应该放在 state、props 或其他状态管理机制中。
render 应保持可重复、无副作用。DOM 操作应该放在事件处理函数、useLayoutEffect 或 useEffect 中。
以下情况都需要防御:
普通组件不会因为接收到 ref 就自动把它传给内部节点。React 18 需要 forwardRef,React 19 需要显式从 props 中取出并绑定到目标节点。
如果 props 中也存在 ref,后面的展开可能覆盖前面的值。组件应明确处理 ref,避免用普通 props 覆盖它。
开发环境 Strict Mode 可能进行额外的 render、挂载和清理检查,用于发现副作用问题。代码应正确处理 ref 被设置为节点和重新清理为 null 的过程。
服务端渲染只执行生成结果所需的逻辑,不会产生浏览器 DOM。因此服务端不能依赖 ref.current 进行 DOM 操作;这类逻辑必须放到客户端 effect 或事件中。
适合使用 ref 的场景:
不适合使用 ref 的场景:
例如,不要暴露 { open, close } 让父组件命令式控制弹窗,优先设计为:
下面是帮助理解的简化模型,不代表 React 内部源码:
真正的 React 还要处理 Fiber、并发渲染、组件替换、回调 ref 清理、Strict Mode、函数组件特殊类型以及 useImperativeHandle 的 effect。这个模型只用于说明核心因果链:
| 概念 | 本质 | 主要用途 | 是否触发渲染 |
|---|---|---|---|
ref | 保存可变引用的入口 | DOM、实例、命令式 handle | 否 |
useRef | 函数组件中的持久化 ref 容器 | 保存跨 render 的非 UI 数据 | 否 |
createRef | 创建新的 ref 对象 | 类组件 | 否 |
forwardRef | 让组件接收并转交 ref | React 18 的函数组件封装 | 否 |
useImperativeHandle | 自定义 ref 暴露的 handle | 封装命令式 API | 否 |
useState | React 状态 | 驱动 UI 更新 | 是 |
参考答案: 两者都能跨 render 保存数据,但 state 更新会触发重新渲染,ref.current 更新不会。需要驱动 UI 的数据放 state,不参与 UI 的可变引用放 ref。
追问意图: 判断是否理解 ref 的定位,而不是只知道它能拿 DOM。
参考答案: React 18 中 ref 是特殊属性,不会进入普通函数组件 props;普通函数组件也没有可直接暴露的实例目标。forwardRef 提供了一个 React 能识别的特殊组件类型,把 ref 作为 render 函数的第二个参数交给组件,由组件决定最终绑定到哪个节点。
参考答案: 新代码通常可以直接从函数组件 props 中接收 ref,因此不再需要 forwardRef。但 React 18 及更早版本仍需要它,现有组件库也可能为了兼容旧版本继续使用。React 官方已将 forwardRef 标记为未来会弃用的 API。
参考答案: 它不是复制 ref,也不是创建函数组件实例,而是返回 React 可识别的特殊组件类型。React 渲染这个类型时,会把父组件的 ref 作为第二个参数传给 render 函数;组件再把这个 ref 绑定到 DOM 或子组件上,最终 commit 阶段由 React 写入 current。
参考答案: 它把 ref 的暴露目标从完整 DOM 节点改成自定义 handle。父组件只能调用组件主动暴露的方法,从而减少内部实现泄漏和上下游耦合。
参考答案: handle 创建函数可能捕获 props、state 或其他响应式值。依赖变化时需要重新创建 handle,否则方法可能持有旧闭包。若方法只通过稳定的 DOM ref 读取最新节点且不捕获外部响应式值,可以使用空依赖数组。
参考答案: ref 在 commit 阶段绑定,render 阶段不应依赖它。DOM 更新提交后,layout effect 和 effect 中都可以读取;卸载或节点替换时 React 会清理旧 ref。
参考答案: 因为修改 ref 不会触发 render,React 不会根据它自动更新 UI。若父组件需要控制子组件的显示、选中或业务状态,应使用 props/state;ref 只适合少量无法声明式表达的命令式行为。
这只覆盖了最常见用法。ref 还可以保存跨 render 的非 UI 数据、类组件实例,以及通过 useImperativeHandle 暴露的自定义 handle。
不准确。它只是让 React 把 ref 传给组件的 render 函数,组件仍然不是类实例。
不准确。React 不会监听 ref.current 的修改;如果界面需要反映变化,应使用 state。
不准确。它是在 commit 相关流程中把 ref 指向自定义 handle,父组件看到的目标可以完全不是 DOM 节点。
不准确。React 19 函数组件可以直接接收 ref prop,但要根据项目 React 版本和组件库兼容范围决定写法。
命令式入口、跨组件边界、commit 阶段、可变容器、封装 handle
Ref 是 React 声明式渲染之外的受控命令式出口:React 在提交阶段将目标节点或自定义 handle 写入引用,组件通过它执行少量无法用 props 表达的操作。
1 分钟:
Ref 是 React 提供的命令式引用入口,常用于聚焦、滚动、读取 DOM 或调用第三方库。它保存跨 render 的引用,但修改
ref.current不会触发渲染。React 18 中函数组件不能直接接收 ref,需要用 forwardRef 转发;useImperativeHandle 可以把完整 DOM 封装成只暴露少量方法的自定义 handle。React 19 开始函数组件可以直接接收 ref prop。能用 props/state 表达的 UI,通常不应该使用 ref。
3 分钟:
补充 ref 在 commit 阶段绑定、卸载时清理,解释对象 ref 和回调 ref,并说明 useRef 与 useState 的触发渲染差异。然后结合 forwardRef 和 useImperativeHandle 示例说明父子组件之间的引用边界。
10 分钟:
进一步讲解 Fiber commit 中 ref 的挂载和卸载、React 18 与 React 19 的 API 差异、handle 的闭包和依赖数组、Strict Mode、SSR、回调 ref 身份变化,以及为什么 ref 应该被限制在少量命令式行为中。