React Ref、forwardRef 与 useImperativeHandle

React 中如何让父组件访问子组件内部的 DOM 或有限的命令式能力?

一、先给结论

ref 是 React 提供的“命令式入口”:它保存一个不会因为重新渲染而丢失的引用,React 会在提交阶段把 DOM 节点、类组件实例或自定义 handle 写入 ref.current,组件卸载时再清理为 null

它适合处理无法自然表达为 props 的行为,例如聚焦、滚动、选择文本、播放动画或调用第三方 DOM API。能用 props 和状态表达的 UI,优先继续使用声明式写法。

forwardRefuseImperativeHandle 解决的是两个不同问题:

  • forwardRef:让组件接收父组件传入的 ref,并把它转交给内部节点或组件。
  • useImperativeHandle:自定义父组件通过 ref 能看到的值,不直接暴露完整 DOM。

React 版本需要特别区分:React 18 及更早版本中,函数组件不能直接接收 ref,通常需要 forwardRef;React 19 开始,ref 可以像普通 prop 一样传给函数组件,新的代码通常不再需要 forwardRef。官方已将 forwardRef 标记为未来会弃用的 API,但现有 React 18 代码和兼容库仍然大量使用它。React forwardRef 文档

二、ref 到底是什么

1. ref 是可变容器,不是响应式状态

import { useRef } from 'react'

function SearchInput() {
  const inputRef = useRef(null)

  function handleFocus() {
    inputRef.current?.focus()
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>聚焦</button>
    </>
  )
}

useRef(initialValue) 返回一个对象:

{ current: initialValue }

React 会保证同一个组件实例的多次渲染之间,useRef 返回同一个 ref 对象。修改 ref.current 不会触发重新渲染,因此它适合保存:

  • DOM 节点
  • 定时器 ID、AbortController 等可变对象
  • 上一次的值
  • 不参与渲染结果的实例变量

2. ref 与 state 的区别

对比项useRefuseState
修改方式直接修改 current调用 setter
修改后是否触发渲染不会
是否保存跨渲染数据
是否参与 UI 计算通常不直接参与通常参与
典型用途DOM、定时器、上一次值UI 状态、交互状态

如果一个值变化后界面需要更新,它通常应该是 state,而不是 ref。

三、React 什么时候写入 ref

ref 的更新发生在 commit 阶段,而不是 render 阶段:

组件 render
生成新的 React 元素树
reconciliation 计算变化
commit 阶段创建 / 更新 / 删除 DOM
React 将 DOM 或 handle 写入 ref.current

因此有两个重要结论:

  1. 在组件 render 过程中读取 DOM ref,通常还是 null 或旧节点。
  2. DOM ref 一般在 layout effect 以及普通 effect 执行前已经可用。
function Example() {
  const ref = useRef(null)

  console.log(ref.current) // render 阶段不要依赖它

  useLayoutEffect(() => {
    console.log(ref.current) // DOM 已提交,可同步读取布局
  }, [])

  useEffect(() => {
    console.log(ref.current) // 也可以读取,但执行时机通常晚于 layout effect
  }, [])

  return <div ref={ref} />
}

当节点卸载或 ref 指向其他节点时,React 会先清理旧引用。对象 ref 会被设置为 null,回调 ref 会收到 null

四、对象 ref 与回调 ref

对象 ref

const inputRef = useRef(null)

return <input ref={inputRef} />

React 负责维护 inputRef.current

回调 ref

function Example() {
  const handleRef = node => {
    if (node) {
      node.focus()
    }
  }

  return <input ref={handleRef} />
}

回调 ref 会在节点建立时收到节点,在节点移除时收到 null。它适合需要在 ref 建立或清理的瞬间执行逻辑的场景。

需要注意:如果每次 render 都创建新的回调函数,React 可能会先用 null 清理旧回调,再用节点调用新回调。需要稳定身份时,可以使用 useCallback

五、为什么函数组件以前不能直接接收 ref

在 React 18 及更早版本中,refkey 类似,是 React 元素的特殊属性,不会像普通 prop 一样出现在函数组件的 props 对象中:

function MyInput(props) {
  console.log(props.ref) // React 18 及更早版本中不能这样获取
  return <input />
}

<MyInput ref={inputRef} /> // 不能直接把 ref 交给普通函数组件

原因不是“函数组件没有 this”这么简单,而是 React 需要明确决定 ref 最终附着在哪个可被引用的目标上:

  • 原生 DOM 元素:ref 指向 DOM 节点
  • 类组件:ref 指向类组件实例
  • 普通函数组件:没有可暴露的实例目标

因此 React 18 的函数组件需要通过 forwardRef 显式声明:“我会接收这个 ref,并负责把它转发到某个目标”。

六、forwardRef:跨组件边界转交 ref

1. React 18 及更早版本的写法

import { forwardRef } from 'react'

const MyInput = forwardRef(function MyInput(props, ref) {
  return <input {...props} ref={ref} />
})

function Form() {
  const inputRef = useRef(null)

  return <MyInput ref={inputRef} />
}

调用链是:

父组件创建 <MyInput ref={inputRef} />
React 识别 forwardRef 返回的特殊组件类型
调用 render(props, ref)
子组件将 ref 绑定到 input
commit 阶段 inputRef.current = input DOM 节点

forwardRef 不是把 DOM 节点复制给父组件,也不是让函数组件凭空拥有实例。它只是创建一种 React 能识别的组件类型,使 ref 作为第二个参数传给 render 函数。

2. React 19 的写法

React 19 开始,函数组件可以直接从 props 中接收 ref

function MyInput({ ref, ...props }) {
  return <input {...props} ref={ref} />
}

父组件仍然这样使用:

<MyInput ref={inputRef} />

React 19 的 ref 作为 prop 的能力只改变“组件如何接收 ref”,不会改变 ref 在 commit 阶段写入、卸载时清理等核心模型。React 19 官方说明

3. 多层转发

如果中间有多个组件,每一层都必须继续传递 ref:

const InputWrapper = forwardRef(function InputWrapper(props, ref) {
  return <BaseInput {...props} ref={ref} />
})

const BaseInput = forwardRef(function BaseInput(props, ref) {
  return <input {...props} ref={ref} />
})

只要某一层没有继续传递,ref 就会在这一层停止,父组件拿到的可能是 null 或错误的目标。

七、useImperativeHandle:暴露受控的命令式接口

1. 为什么不直接暴露 DOM

直接暴露 DOM 意味着父组件可以调用任意 DOM API,子组件的内部实现与父组件强耦合。useImperativeHandle 可以把父组件可用的能力限制为明确的接口:

import { useImperativeHandle, useRef } from 'react'

const FancyInput = forwardRef(function FancyInput(props, ref) {
  const inputRef = useRef(null)

  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current?.focus()
    },
    clear() {
      if (inputRef.current) {
        inputRef.current.value = ''
      }
    }
  }), [])

  return <input {...props} ref={inputRef} />
})

父组件看到的不是 DOM,而是:

{
  focus: Function,
  clear: Function
}

它看不到 stylevalue 等底层 DOM 属性。这种方式的本质是“把 ref 的目标从 DOM 节点替换成一个自定义 handle”。React useImperativeHandle 文档

2. dependencies 的意义

useImperativeHandle(ref, () => ({
  focus() {
    inputRef.current?.focus()
  },
  setValue(value) {
    inputRef.current.value = value
  }
}), [someDependency])

React 会根据依赖变化重新执行 createHandle,并将新的 handle 写入 ref。依赖数组应包含创建 handle 时读取的响应式值;如果省略依赖数组,每次 render 都可能重新创建 handle。

如果 handle 中的方法只通过 ref 读取最新 DOM,而没有捕获 props/state,可以使用空依赖数组。若方法闭包捕获了 props 或 state,必须认真处理依赖,否则可能读取旧值。

3. React 19 的 useImperativeHandle

React 19 可以直接接收 ref

function FancyInput({ ref, ...props }) {
  const inputRef = useRef(null)

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus()
  }), [])

  return <input {...props} ref={inputRef} />
}

React 18 及更早版本则需要 forwardRef 包裹这一组件。

八、createRef 与 useRef

class Form extends React.Component {
  inputRef = createRef()

  render() {
    return <input ref={this.inputRef} />
  }
}
function Form() {
  const inputRef = useRef(null)
  return <input ref={inputRef} />
}
API特点常见场景
createRef每次调用都会返回新对象类组件字段
useRef同一组件实例多次渲染保持同一个对象函数组件

下面这种写法在函数组件中通常是错误的:

function Form() {
  const inputRef = createRef() // 每次 render 都会创建新对象
  return <input ref={inputRef} />
}

createRef 不是“不能存数据”,而是它每次调用都会创建新对象;函数组件需要跨 render 保持引用时,应使用 useRefReact createRef 文档

九、ref 的边界与常见陷阱

1. ref 不是状态管理方案

修改 ref.current 不会触发 render:

countRef.current++ // 数据改了,但界面不会因此更新

需要驱动 UI 的数据应该放在 state、props 或其他状态管理机制中。

2. 不要在 render 阶段操作 DOM

render 应保持可重复、无副作用。DOM 操作应该放在事件处理函数、useLayoutEffectuseEffect 中。

3. ref 可能暂时为 null

以下情况都需要防御:

  • 首次 render 尚未 commit
  • 条件渲染导致节点不存在
  • 组件卸载
  • Strict Mode 开发环境下的额外挂载/清理检查

4. ref 不能跨越任意组件自动传递

普通组件不会因为接收到 ref 就自动把它传给内部节点。React 18 需要 forwardRef,React 19 需要显式从 props 中取出并绑定到目标节点。

5. props 展开顺序会影响 ref 绑定

return <input ref={ref} {...props} />

如果 props 中也存在 ref,后面的展开可能覆盖前面的值。组件应明确处理 ref,避免用普通 props 覆盖它。

6. Strict Mode 不等于生产环境 ref 会失效

开发环境 Strict Mode 可能进行额外的 render、挂载和清理检查,用于发现副作用问题。代码应正确处理 ref 被设置为节点和重新清理为 null 的过程。

7. SSR 阶段没有真实 DOM

服务端渲染只执行生成结果所需的逻辑,不会产生浏览器 DOM。因此服务端不能依赖 ref.current 进行 DOM 操作;这类逻辑必须放到客户端 effect 或事件中。

十、什么时候该用 ref

适合使用 ref 的场景:

  • 聚焦或选中文本
  • 滚动到某个元素
  • 播放、暂停动画或媒体
  • 读取 DOM 尺寸和布局
  • 调用第三方 DOM 库
  • 暴露少量稳定的命令式组件方法

不适合使用 ref 的场景:

  • 控制组件是否显示
  • 切换主题或样式状态
  • 父子组件传递业务数据
  • 触发正常的 UI 更新
  • 绕过 props/state 进行全局状态同步

例如,不要暴露 { open, close } 让父组件命令式控制弹窗,优先设计为:

<Modal isOpen={isOpen} onClose={handleClose} />

十一、核心源码模型

下面是帮助理解的简化模型,不代表 React 内部源码:

function commitAttachRef(fiber) {
  const ref = fiber.ref
  if (ref == null) return

  const value = getRefValue(fiber) // DOM、类实例或自定义 handle

  if (typeof ref === 'function') {
    ref(value)
  } else {
    ref.current = value
  }
}

function commitDetachRef(fiber) {
  const ref = fiber.ref
  if (ref == null) return

  if (typeof ref === 'function') {
    ref(null)
  } else {
    ref.current = null
  }
}

真正的 React 还要处理 Fiber、并发渲染、组件替换、回调 ref 清理、Strict Mode、函数组件特殊类型以及 useImperativeHandle 的 effect。这个模型只用于说明核心因果链:

render 阶段记录 ref
commit 阶段确定目标实例
将目标写入对象 ref 或传给回调 ref
卸载 / 替换时清理 ref

十二、常见概念对比

概念本质主要用途是否触发渲染
ref保存可变引用的入口DOM、实例、命令式 handle
useRef函数组件中的持久化 ref 容器保存跨 render 的非 UI 数据
createRef创建新的 ref 对象类组件
forwardRef让组件接收并转交 refReact 18 的函数组件封装
useImperativeHandle自定义 ref 暴露的 handle封装命令式 API
useStateReact 状态驱动 UI 更新

十三、面试官深挖

1. ref 和 state 有什么区别?

参考答案: 两者都能跨 render 保存数据,但 state 更新会触发重新渲染,ref.current 更新不会。需要驱动 UI 的数据放 state,不参与 UI 的可变引用放 ref。

追问意图: 判断是否理解 ref 的定位,而不是只知道它能拿 DOM。

2. 为什么 React 18 的函数组件不能直接使用 ref?

参考答案: React 18 中 ref 是特殊属性,不会进入普通函数组件 props;普通函数组件也没有可直接暴露的实例目标。forwardRef 提供了一个 React 能识别的特殊组件类型,把 ref 作为 render 函数的第二个参数交给组件,由组件决定最终绑定到哪个节点。

3. React 19 还需要 forwardRef 吗?

参考答案: 新代码通常可以直接从函数组件 props 中接收 ref,因此不再需要 forwardRef。但 React 18 及更早版本仍需要它,现有组件库也可能为了兼容旧版本继续使用。React 官方已将 forwardRef 标记为未来会弃用的 API。

4. forwardRef 是如何“转发” ref 的?

参考答案: 它不是复制 ref,也不是创建函数组件实例,而是返回 React 可识别的特殊组件类型。React 渲染这个类型时,会把父组件的 ref 作为第二个参数传给 render 函数;组件再把这个 ref 绑定到 DOM 或子组件上,最终 commit 阶段由 React 写入 current

5. useImperativeHandle 的作用是什么?

参考答案: 它把 ref 的暴露目标从完整 DOM 节点改成自定义 handle。父组件只能调用组件主动暴露的方法,从而减少内部实现泄漏和上下游耦合。

6. useImperativeHandle 的依赖数组为什么重要?

参考答案: handle 创建函数可能捕获 props、state 或其他响应式值。依赖变化时需要重新创建 handle,否则方法可能持有旧闭包。若方法只通过稳定的 DOM ref 读取最新节点且不捕获外部响应式值,可以使用空依赖数组。

7. ref 在 React 的哪个阶段可用?

参考答案: ref 在 commit 阶段绑定,render 阶段不应依赖它。DOM 更新提交后,layout effect 和 effect 中都可以读取;卸载或节点替换时 React 会清理旧 ref。

8. 为什么 ref 不适合控制组件状态?

参考答案: 因为修改 ref 不会触发 render,React 不会根据它自动更新 UI。若父组件需要控制子组件的显示、选中或业务状态,应使用 props/state;ref 只适合少量无法声明式表达的命令式行为。

十四、常见错误回答

错误一:ref 就是获取 DOM 的

这只覆盖了最常见用法。ref 还可以保存跨 render 的非 UI 数据、类组件实例,以及通过 useImperativeHandle 暴露的自定义 handle。

错误二:forwardRef 给函数组件创建了实例

不准确。它只是让 React 把 ref 传给组件的 render 函数,组件仍然不是类实例。

错误三:useRef 的值变化会触发更新

不准确。React 不会监听 ref.current 的修改;如果界面需要反映变化,应使用 state。

错误四:useImperativeHandle 是把 ref 复制一份

不准确。它是在 commit 相关流程中把 ref 指向自定义 handle,父组件看到的目标可以完全不是 DOM 节点。

错误五:React 19 仍然必须用 forwardRef

不准确。React 19 函数组件可以直接接收 ref prop,但要根据项目 React 版本和组件库兼容范围决定写法。

十五、可迁移的理解

核心关键词

命令式入口跨组件边界commit 阶段可变容器封装 handle

一句话本质

Ref 是 React 声明式渲染之外的受控命令式出口:React 在提交阶段将目标节点或自定义 handle 写入引用,组件通过它执行少量无法用 props 表达的操作。

文字链路

父组件传入 ref
组件决定是否转发 / 定制 handle
render 产生目标节点
commit 阶段绑定 ref
事件或 effect 中使用 ref.current
节点卸载时清理为 null

不同时间长度的面试回答

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,并说明 useRefuseState 的触发渲染差异。然后结合 forwardRefuseImperativeHandle 示例说明父子组件之间的引用边界。

10 分钟:

进一步讲解 Fiber commit 中 ref 的挂载和卸载、React 18 与 React 19 的 API 差异、handle 的闭包和依赖数组、Strict Mode、SSR、回调 ref 身份变化,以及为什么 ref 应该被限制在少量命令式行为中。

ON THIS PAGE