摘要
从useRef持久可变对象本质出发,对比useState响应式差异,深入三大场景:DOM节点引用、非响应式值存储、持有Web Worker实例实现5亿次计算不阻塞UI,覆盖消息机制与生命周期。
React 的核心设计哲学是声明式编程 ------开发者描述"UI 应该长什么样",框架负责把 DOM 变成那样。在 React 之前,前端开发大量依赖 document.getElementById、innerHTML、appendChild 等原生 DOM API,代码冗长且容易出错。React 用 useState 驱动数据绑定和响应式更新,将 DOM 操作彻底封装起来,开发者几乎不需要触碰 DOM。
但"几乎"不等于"完全"。某些场景下,React 的声明式抽象力有不逮------比如页面加载后自动聚焦输入框、测量元素尺寸、集成第三方非 React 库、或者持有一个 Web Worker 实例。useRef 就是为这些"逃逸出口"而生的 Hook。
useRef 的底层模型
useRef 的机制可以浓缩为一句话:它返回一个普通 JavaScript 对象 { current: initialValue },这个对象在组件的整个生命周期中保持不变,修改它不会触发重新渲染。
javascript
const ref = useRef(null);
// ref 就是 { current: null }
// 你可以随时修改 ref.current = xxx
// 但组件不会因为这次修改而重新渲染
三个关键特征:
- 持久性 :同一个
ref对象在每次渲染中都是同一个引用。React 在首次渲染时创建它,后续渲染直接返回同一个对象。 - 可变性 :
ref.current可以随时读写,不受 React 渲染周期的约束。 - 非响应式 :修改
ref.current不会触发组件重新渲染。这是它与useState最本质的区别。
useRef vs useState:分工明确的两位"管家"
| 维度 | useState | useRef |
|---|---|---|
| 返回值 | [value, setter] |
{ current: value } |
| 触发渲染 | 调用 setter 会触发 | 修改 current 不触发 |
| 用途 | 驱动 UI 的数据状态 | DOM 引用、持久化可变值 |
| 典型场景 | 表单输入、开关状态、列表数据 | 焦点管理、计时器 ID、Worker 实例 |
| 渲染中读取 | 总是最新值 | 总是最新值 |
useState 聚焦"数据状态"和"业务逻辑"------当数据变化时,UI 需要跟着变化。useRef 聚焦"引用"和"副作用基础设施"------你需要一个不会触发渲染的"口袋",存放 DOM 节点、计时器、Worker 实例等。
场景一:DOM 节点引用
React 不鼓励直接操作 DOM,但确实有些场景无法完全回避------最典型的就是页面加载后自动聚焦输入框。
jsx
import { useRef, useEffect } from 'react';
const App = () => {
const inputRef = useRef(null);
useEffect(() => {
// 组件挂载后,inputRef.current 指向真实的 <input> DOM 节点
console.log(inputRef.current); // <input type="text" ...>
inputRef.current.focus(); // 自动聚焦
}, []);
return (
<input
type="text"
placeholder="请输入用户名"
ref={inputRef} // 绑定 ref
/>
);
};
ref={inputRef} 将 inputRef 与 <input> 元素绑定。React 在创建 DOM 节点后,将节点引用写入 inputRef.current;在组件卸载时,将其置为 null。useEffect 在 DOM 挂载完成后执行,此时 inputRef.current 已经指向真实 DOM 节点,可以安全地调用 focus()。
为什么输入框要放在 useEffect 中操作?因为首次渲染时,JSX 只是虚拟 DOM 的声明,真实的 DOM 节点还不存在。useRef(null) 在首次渲染时 current 为 null,只有等 React 将虚拟 DOM 提交到真实 DOM 后,current 才被赋值。useEffect 的执行时机恰好在 DOM 提交之后,是操作 DOM 的最佳时机。
场景二:持久化可变值
useRef 不仅能引用 DOM,还能存储任意值。由于修改 ref.current 不会触发渲染,它特别适合存储那些"需要记住但不需要显示"的数据。
jsx
const App = () => {
const numRef = useRef(0);
const [, forceRender] = useState(0);
return (
<div onClick={() => {
numRef.current += 1; // 修改 ref,不触发渲染
forceRender(prev => prev + 1); // 手动触发渲染以显示最新值
}}>
{numRef.current}
</div>
);
};
这个例子中,numRef.current 每次点击加 1,但页面不会自动更新。手动调用 forceRender 触发渲染后,才能看到最新值。如果换用 useState,每次 setCount 都会自动触发渲染------这就是响应式与非响应式的核心区别。
实际开发中,useRef 更常见的用法是存放定时器 ID、上一次的 props 值、或者在 useEffect 清理函数中需要的引用。
JS 单线程的困境
JavaScript 在浏览器中是单线程运行的------主线程既要执行脚本、处理用户交互,又要负责页面渲染。当一段同步代码执行时间过长(比如上亿次循环),主线程被阻塞,页面就会"卡死":滚动不响应、按钮点不动、动画帧掉到个位数。
javascript
// 这段代码会阻塞主线程数秒,页面完全无响应
console.time('主线程');
for (let i = 0; i < 100000000; i++) {
// 密集计算
}
console.timeEnd('主线程');
Event Loop 的异步机制(setTimeout、Promise)可以让代码不阻塞,但它们本质上仍然在主线程上排队执行,只是不在"当前"执行而已。对于真正的 CPU 密集型任务------游戏引擎计算、LLM 推理、加密解密、音视频处理------异步也解决不了阻塞问题。
Web Worker:浏览器的"后台线程"
HTML5 引入的 Web Worker 为 JavaScript 提供了真正的多线程能力。浏览器(C++ 编写的多进程多线程软件)可以为 JS 开辟一个独立的后台线程,专门运行纯计算任务。
Worker 的关键约束:
- 无法访问 DOM :Worker 线程中没有
document、window对象,它只负责纯计算。 - 通过消息通信 :主线程和 Worker 之间通过
postMessage发送消息,onmessage接收消息。 - 内存隔离:两个线程拥有独立的内存空间,互不干扰,数据通过结构化克隆传递。
javascript
// worker.js ------ 运行在独立线程中
self.onmessage = (e) => {
const { num } = e.data;
let sum = 0;
for (let i = 0; i < 500000000; i++) {
sum += num * i;
}
// 计算完毕,将结果发送回主线程
self.postMessage({ result: sum });
};
在主线程中,创建 Worker 并监听消息:
javascript
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.onmessage = (e) => {
console.log('计算结果:', e.data.result);
};
worker.postMessage({ num: 88 });
这里有一个容易被忽略的细节:new Worker() 需要传入 Worker 脚本的 URL。在 Vite 项目中,new URL('./worker.js', import.meta.url) 是推荐写法------它让 Vite 正确处理 Worker 文件的路径和打包。
场景三:useRef 持有 Worker 实例
Worker 实例需要在组件的整个生命周期中保持稳定------不能在每次渲染时重新创建,也不能在不需要时还留在内存中。useRef 的持久性和可变性恰好满足这个需求。
jsx
import { useState, useRef, useEffect } from 'react';
const App = () => {
const workerRef = useRef(null); // 持久化 Worker 实例
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 组件挂载后创建 Worker
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
// 监听 Worker 返回的消息
workerRef.current.onmessage = (e) => {
setResult(e.data.result);
setLoading(false);
};
// 组件卸载时销毁 Worker
return () => {
workerRef.current.terminate();
workerRef.current = null;
};
}, []);
const startHeavyCalc = () => {
setLoading(true);
workerRef.current.postMessage({ num: 88 });
};
return (
<div style={{ padding: '30px' }}>
<h2>useRef + Web Worker 耗时运算</h2>
<p>开启 Web Worker 线程执行 5 亿次循环,结束后通知主线程</p>
<button onClick={startHeavyCalc} disabled={loading}>
{loading ? '正在后台计算...' : '启动繁重计算任务'}
</button>
{result && <h3>计算结果:{result}</h3>}
</div>
);
};
这个示例完整展示了 useRef + Web Worker 的协作模式:
为什么用 useRef 而不是 useState 存储 Worker? useState 是响应式的,调用 setState 会触发渲染。但 Worker 实例只是"基础设施"------它的创建和销毁不需要驱动 UI 更新。用 useRef 存储,Worker 实例在组件多次渲染中保持稳定,UI 只关心 result 和 loading 这两个响应式状态。
为什么 Worker 在 useEffect 中创建? Worker 的创建是有开销的(浏览器需要分配独立内存和线程),不应该在每次渲染时执行。useEffect 配合空依赖数组 [],确保只在组件挂载时创建一次。
为什么要在 return 中调用 terminate()? useEffect 的清理函数在组件卸载时执行。如果不调用 terminate(),Worker 线程会一直运行,造成内存泄漏。workerRef.current = null 手动释放引用,帮助 GC 回收。
消息机制:主线程与 Worker 的双向通信
Worker 与主线程的通信完全依赖消息传递:
scss
主线程 Worker 线程
| |
|--- postMessage({num}) ---->|
| |--- 执行 5 亿次循环
| (UI 正常响应,不卡顿) |
| |--- postMessage({result})
|<--- onmessage 接收结果 ----|
| |
|--- setResult / setLoading (更新 UI)
postMessage 发送的数据会经过结构化克隆 ------类似于 JSON.parse(JSON.stringify(data)),但支持更多类型(如 ArrayBuffer、ImageBitmap)。两个线程之间传递的是数据的副本,不是引用,这保证了内存隔离的安全性。
总结:useRef 的能力矩阵
| 场景 | 核心用法 | 关键点 |
|---|---|---|
| DOM 节点引用 | ref={ref} + useEffect |
需要在 DOM 挂载后操作,useEffect 是最佳时机 |
| 持久化可变值 | ref.current = value |
修改不触发渲染,适合存放计时器、上一次的值等 |
| Worker 实例 | useRef 持有 + useEffect 初始化 |
持久化避免重复创建,清理函数中 terminate() |
| 第三方库集成 | ref 绑定容器 DOM |
将 DOM 节点传给 Chart.js、D3 等非 React 库 |
useRef 是 React Hooks 体系中一个低调但不可或缺的成员。它不像 useState 那样驱动 UI 变化,也不像 useEffect 那样管理副作用------它的职责是提供一条"旁路":在 React 声明式范式无法覆盖的角落,给你一个稳定、可变、非响应式的"把手",让你安全地触及 DOM、持有外部实例、存储跨渲染的持久数据。
理解 useRef 的非响应式本质,理解它与 useState 的分工,理解它在 Web Worker 场景中的桥梁角色------你就掌握了 React 组件中"当声明式不够用时,如何优雅地退回到命令式"的核心技巧。