React Refs 体系与 DOM 操作
React Refs 体系与 DOM 操作:命令式接口的正确打开方式
引言
React 的声明式范式要求「通过 state 驱动 UI」,但真实世界总有例外:获取元素尺寸、聚焦输入框、播放视频、与第三方库集成……这些场景需要直接操作 DOM,而 Refs 就是 React 为「命令式逃逸门」预留的接口。本文将从 ref 的三种创建方式讲起,深入剖析 ref 与 Fiber 生命周期、命令式句柄(imperativeHandle)、ref 转发(forwardRef)与高频场景实践。
一、Ref 的本质与三种形态
Ref 是一个「可变容器」,其 current 属性在组件整个生命周期内保持不变(与渲染无关)。React 提供三种创建方式:useRef、createRef、回调 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
实践要点:
- 渲染期间不要读
ref.current(可能过期或为 null); - 读取 DOM 属性的副作用放
useEffect/useLayoutEffect; - 事件回调中读取
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
示例:自定义输入组件
javascript12345678910111213const FancyInput = forwardRef(function FancyInput(props, ref) { const inputRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { inputRef.current.value = ""; }, }), []); return <input ref={inputRef} {...props} />; }); // 父组件 const ref = useRef(null); <FancyInput ref={ref} /> <button onClick={() => ref.current.focus()}>聚焦</button>
要点: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」与「内部逻辑」引用时:
javascript12345678function useMergedRef(...refs) { return (node) => { refs.forEach((ref) => { if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }); }; }
五、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
场景二:第三方库集成(图表/地图/编辑器)
javascript1234567891011function Chart({ data }) { const containerRef = useRef(null); useEffect(() => { const chart = echarts.init(containerRef.current); chart.setOption({ series: [{ data }] }); return () => chart.dispose(); // 卸载时清理 }, [data]); return <div ref={containerRef} style={{ height: 400 }} />; }
场景三:媒体控制
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 的正确写法
javascript12345678// 错误:map 中直接 ref={refs[i]},重排后错位 // 正确:回调 ref 捕获当前元素 {items.map((item, i) => ( <div key={item.id} ref={(node) => { refs.current[i] = node; }} /> ))}
七、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 声明式世界的「命令式安全出口」:
- 本质:可变容器,current 跨渲染稳定;
- 时机:提交阶段挂载,渲染期禁止写入;
- 句柄:forwardRef + useImperativeHandle 暴露自定义 API;
- 回调 ref:挂载即测量,可合并多 ref;
- 并发安全:副作用进 Effects,ref 读写守边界。
实践建议:优先用声明式手段(state/props)解决问题;只有「聚焦、测量、播放、集成」这类真正需要 DOM 命令的场景才动用 ref,并用 useImperativeHandle 收敛对外接口。下一篇我们将探讨 React 代码分割与懒加载的最佳实践。
