React Refs 体系与 DOM 操作:命令式接口的正确打开方式

引言

React 的声明式范式要求「通过 state 驱动 UI」,但真实世界总有例外:获取元素尺寸、聚焦输入框、播放视频、与第三方库集成……这些场景需要直接操作 DOM,而 Refs 就是 React 为「命令式逃逸门」预留的接口。本文将从 ref 的三种创建方式讲起,深入剖析 ref 与 Fiber 生命周期、命令式句柄(imperativeHandle)、ref 转发(forwardRef)与高频场景实践。

一、Ref 的本质与三种形态

Ref 是一个「可变容器」,其 current 属性在组件整个生命周期内保持不变(与渲染无关)。React 提供三种创建方式:useRefcreateRef、回调 ref。

三种创建方式对比

flowchart TB
  classDef e1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef e2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef e3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[创建 Ref] --> B[useRef<br/>函数组件标准方式]
  A --> C[createRef<br/>类组件时代遗留]
  A --> D[回调 Ref<br/>最大灵活性]

  B --> B1[每次渲染返回同一对象]
  B --> B2[初始化值可任意类型]
  C --> C1[每次渲染重建对象]
  C --> C2[需要手动挂载到实例]
  D --> D1[节点挂载时回调]
  D --> D2[节点卸载时传 null]

  click B "https://react.dev/reference/react/useRef" "useRef 文档"
  click D "https://react.dev/reference/react-dom/components/common#ref-callback" "回调 ref 文档"
  class A e1
  class B e1
  class C e2
  class D e2
  class B1 e3
  class B2 e3
  class C1 e3
  class C2 e3
  class D1 e3
  class D2 e3

useRef 的两种角色

useRef 实际上承担两种职责:DOM 引用可变状态容器

flowchart LR
  classDef u1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef u2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef u3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[useRef] --> B[DOM 引用<br/>ref 属性绑定元素]
  A --> C[可变容器<br/>ref.current 存任意值]

  B --> D[读取尺寸/聚焦/播放]
  B --> E[与第三方库集成]
  C --> F[保存定时器 id]
  C --> G[保存上次渲染值]
  C --> H[防抖节流闭包共享]

  click B "https://react.dev/reference/react-dom/components/common#ref" "ref 属性文档"
  class A u1
  class B u1
  class C u1
  class D u3
  class E u3
  class F u2
  class G u2
  class H u2

二、Ref 与渲染生命周期的关系

Ref 的赋值时机非常讲究:ref提交阶段挂载(DOM 已就绪),在卸载前清空为 null。理解这个时机是避免「拿到 null」或「读不到最新值」的关键。

Ref 生命周期

sequenceDiagram
  participant C as 组件
  participant R as React 提交
  participant D as DOM

  C->>R: 渲染函数执行
  Note over R: 渲染阶段 ref.current 保持旧值
  R->>D: 提交 DOM 变更
  R->>R: 挂载 ref.current = 节点
  C->>R: 重渲染
  Note over R: 新渲染阶段读取的仍是旧节点<br/>直到提交后才更新
  R->>R: 更新 ref.current = 新节点
  R->>R: 卸载 ref.current = null

实践要点

  1. 渲染期间不要读 ref.current(可能过期或为 null);
  2. 读取 DOM 属性的副作用放 useEffect / useLayoutEffect
  3. 事件回调中读取 ref.current 是安全的(提交后执行)。

三、命令式句柄:forwardRef 与 useImperativeHandle

forwardRef 允许父组件把 ref 传给子组件内部的 DOM 节点;useImperativeHandle 则允许子组件暴露自定义的 API,而非裸 DOM 节点。

暴露自定义句柄

flowchart TB
  classDef h1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef h2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef h3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[父组件] --> B[forwardRef 子组件]
  B --> C[useImperativeHandle 暴露]
  C --> D[focus 方法]
  C --> E[scrollToTop 方法]
  C --> F[getValue 方法]
  D --> G[内部聚焦 input]
  E --> H[内部滚动容器]
  F --> I[返回内部状态]

  click C "https://react.dev/reference/react/useImperativeHandle" "useImperativeHandle 文档"
  class A h1
  class B h1
  class C h2
  class D h3
  class E h3
  class F h3
  class G h3
  class H h3
  class I h3

示例:自定义输入组件

要点useImperativeHandle 的依赖数组决定句柄何时重建;依赖为空时句柄函数只创建一次(内部可通过 ref 读取最新状态)。

四、回调 Ref 的进阶用法

回调 ref 在「节点挂载时执行」——可以拿到 DOM 节点后立即测量、订阅,也可以在多个 ref 间组合。

回调 Ref 的两种时机

flowchart TD
  classDef c1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef c2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef c3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[回调 ref 执行] --> B{节点状态}
  B -->|节点挂载| C[收到 DOM 节点]
  C --> D[立即测量尺寸]
  C --> E[建立监听/订阅]
  C --> F[合并多个 ref]
  B -->|节点卸载| G[收到 null]
  G --> H[清理监听/订阅]
  G --> I[置空引用]

  click C "https://react.dev/learn/manipulating-the-dom-with-refs" "操作 DOM 文档"
  class A c1
  class B c1
  class C c2
  class D c3
  class E c3
  class F c3
  class G c2
  class H c3
  class I c3

合并多个 Ref 的模式

当一个元素需要同时被「外部 ref」与「内部逻辑」引用时:

五、Ref 高频实战场景

场景一:自动聚焦与表单验证

flowchart LR
  classDef g1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef g2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef g3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[页面加载] --> B[聚焦首个输入框]
  C[校验失败] --> D[聚焦错误字段]
  E[提交成功] --> F[聚焦成功提示]
  A --> G[useLayoutEffect 中聚焦<br/>避免闪烁]

  click B "https://react.dev/learn/manipulating-the-dom-with-refs" "ref 聚焦示例"
  class A g1
  class B g2
  class C g1
  class D g2
  class E g1
  class F g2
  class G g3

场景二:第三方库集成(图表/地图/编辑器)

场景三:媒体控制

flowchart TD
  classDef v1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef v2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
  classDef v3 fill:#e0f7fa,stroke:#00838f,color:#004d40

  A[视频组件] --> B[ref 绑定 video]
  B --> C[播放/暂停按钮]
  B --> D[进度条控制]
  B --> E[音量控制]
  C --> F[currentTime/play/pause]
  D --> G[seek 到指定时间]
  E --> H[volume 设置]

  click B "https://react.dev/reference/react-dom/components/video" "video 组件文档"
  class A v1
  class B v1
  class C v2
  class D v2
  class E v2
  class F v3
  class G v3
  class H v3

六、Ref 的常见陷阱

陷阱清单

flowchart TD
  classDef w1 fill:#ffebee,stroke:#c62828,color:#b71c1c
  classDef w2 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[常见陷阱] --> B[渲染期间读 ref<br/>拿到旧值或 null]
  A --> C[条件渲染时 ref 重置<br/>节点卸载重建]
  A --> D[ref 数组需回调形式<br/>map 中直接赋值失效]
  A --> E[忘清理监听<br/>内存泄漏]
  B --> F[改到 Effect 中读取]
  C --> G[用回调 ref 或 key 稳定]
  D --> H[回调 ref 接收 index]
  E --> I[卸载时移除监听]

  class A w1
  class B w1
  class C w1
  class D w1
  class E w1
  class F w2
  class G w2
  class H w2
  class I w2

列表 ref 的正确写法

七、Ref 与并发渲染

React 18 并发模式下,ref 的语义依然安全:ref.current 只会在提交阶段更新。但要注意——不要在渲染期间写入 ref 作为「旁路状态」,这违背了声明式原则,且并发中断时可能留下不一致状态。副作用(包括 ref 写入触发的外部操作)都应放在 Effects 中。

flowchart TB
  classDef y1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
  classDef y2 fill:#ffebee,stroke:#c62828,color:#b71c1c
  classDef y3 fill:#e8f5e9,stroke:#388e3c,color:#1b5e20

  A[并发渲染下的 ref] --> B{何时读写}
  B -->|渲染阶段| C[只读旧值<br/>写入被禁止]
  B -->|Effect 阶段| D[读写安全<br/>提交后执行]
  B -->|事件回调| E[读写安全<br/>非渲染路径]
  C --> F[避免: 渲染中 ref.current=新值]
  D --> G[推荐: Effect 中操作 DOM]
  E --> H[推荐: 回调读取最新状态]

  class A y1
  class B y1
  class C y2
  class D y3
  class E y3
  class F y2
  class G y3
  class H y3

八、总结

Refs 是 React 声明式世界的「命令式安全出口」:

  1. 本质:可变容器,current 跨渲染稳定;
  2. 时机:提交阶段挂载,渲染期禁止写入;
  3. 句柄:forwardRef + useImperativeHandle 暴露自定义 API;
  4. 回调 ref:挂载即测量,可合并多 ref;
  5. 并发安全:副作用进 Effects,ref 读写守边界。

实践建议:优先用声明式手段(state/props)解决问题;只有「聚焦、测量、播放、集成」这类真正需要 DOM 命令的场景才动用 ref,并用 useImperativeHandle 收敛对外接口。下一篇我们将探讨 React 代码分割与懒加载的最佳实践。