一个 useRef,三种人生:从 DOM 引用到 Web Worker
很多人刚学 React 时,对 useRef 的印象只有一句话:拿 DOM 节点的。
面试被问到 useRef 和 useState 的区别,也能马上背出:一个会触发重新渲染,一个不会。
但这句话背后真正重要的是什么?为什么一个 current 属性,既能指向输入框,也能保存定时器,还能挂住一个 Web Worker 实例?
本文用三个场景把 useRef 拆开:从 DOM 引用,到不参与渲染的可变值,再到 Web Worker 这类外部对象。看完你会发现,useRef 的核心并不是"拿 DOM",而是:
在组件多次渲染之间,保存一个稳定、可变、不会主动触发渲染的引用。
一、React 为什么不鼓励我们到处直接操作 DOM?
早期前端开发经常这样写:
js
document.getElementById("title").textContent = "Hello";
document.querySelector(".box").style.color = "red";
这是典型的命令式 DOM 编程:我们直接找到节点,然后修改它的内容或样式。
它并不是"不能用",但当页面变复杂后,问题会逐渐出现:
- 多处代码可能同时修改同一个节点
- 数据和 DOM 状态容易脱节
- 列表、条件分支、事件更新需要手动维护
- 组件销毁后,遗留的事件、定时器或第三方实例可能没有清理
React 采用的是声明式思路:
jsx
// 命令式:自己找到节点并修改
input.value = "hello";
// 声明式:描述 UI 应该是什么样子
setValue("hello");
更准确地说,React 并没有"消灭 DOM",而是接管了大多数由状态驱动的 DOM 更新。你负责描述 UI,React 负责把状态变化落实到 DOM。
不过,现实中的前端总会遇到 React 不应该直接接管的对象:
- 页面加载后让输入框自动聚焦
- 滚动到某个元素,或读取元素尺寸
- 集成地图、图表、播放器等非 React 库
- 保存定时器 ID、上一次的值或请求控制器
- 持有 Web Worker、WebSocket 等外部实例
这时就需要 useRef。
二、先用一句话理解 useRef
调用 useRef(initialValue) 会得到一个稳定的对象:
js
const ref = useRef(initialValue);
// ref 的形状可以理解为:
{
current: initialValue
}
这个对象有两个关键特征:
- 组件重新渲染时,
ref对象本身仍然是同一个对象 - 修改
ref.current不会自动触发重新渲染
所以可以把它看成一个"挂在组件生命周期里的小盒子"。React 帮你保管这个盒子,但不会因为盒子里的内容变了,就重新绘制页面。

这也是它和 useState 的根本区别:
| 对比项 | useState |
useRef |
|---|---|---|
| 更新方式 | 调用 setter | 直接修改 current |
| 是否触发重新渲染 | 会 | 不会 |
| 读取方式 | 当前渲染中的 state 快照 | 读取 ref.current 的当前值 |
| 适合保存 | 会影响 UI 的状态 | 不直接参与 UI 的可变引用 |
这里的"不会触发渲染"不是缺点,而是一个明确的设计选择:如果数据变化需要页面立刻反映,就用 state;如果只是给副作用或外部对象使用,就可以考虑 ref。
三、面孔一:DOM 引用,让输入框自动聚焦
这是 useRef 最常见的用法。场景很简单:页面打开后,输入框自动获得焦点。
jsx
import { useEffect, useRef } from "react";
export default function LoginForm() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<input
ref={inputRef}
type="text"
placeholder="请输入用户名"
/>
);
}
这段代码有三个关键点:
useRef(null)创建一个current初始为null的容器- React 挂载
<input>后,会把真实 DOM 节点写入inputRef.current useEffect在提交 DOM 后执行,此时才适合调用focus()
组件卸载时,React 会把 DOM ref 清回 null。因此不要把 DOM 节点当作永久存在的全局对象。
为什么不直接用 autoFocus?
如果只是"首次加载时聚焦",autoFocus 完全够用:
jsx
<input autoFocus />
但 useRef 的能力更通用。拿到 DOM 节点后,你还可以调用:
js
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: "smooth" });
const height = inputRef.current?.getBoundingClientRect().height;
经验上,能用 React 属性和状态解决的 UI,不要为了炫技使用 ref;确实需要调用原生 API 时,再让 ref 出场。
四、面孔二:可变值,保存不参与渲染的数据
useRef 的 current 不要求必须是 DOM 节点,数字、字符串、对象、函数都可以保存。
先看一个最直观的例子:
jsx
import { useRef, useState } from "react";
export default function RefCounter() {
const numRef = useRef(0);
const [, forceRender] = useState(0);
function handleClick() {
numRef.current += 1;
forceRender((version) => version + 1);
}
return (
<button onClick={handleClick}>
ref 计数:{numRef.current}
</button>
);
}
注意,真正修改 ref 的代码是:
js
numRef.current += 1;
这行代码本身不会触发任何渲染。示例中的 forceRender 只是为了让页面重新执行组件函数,从而把 ref 里的新数字显示出来。
实际项目里,如果这个数字就是用户要看到的 UI 状态,应该直接使用 useState:
jsx
const [count, setCount] = useState(0);
setCount((value) => value + 1);
把 ref 当成"不会自动更新页面的 state",通常会让代码变得难以理解。它更适合保存 UI 不需要直接展示的值。
常见场景:保存定时器 ID
jsx
import { useEffect, useRef } from "react";
export default function Polling() {
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
console.log("刷新数据");
}, 5000);
return () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
}
};
}, []);
return <p>轮询中</p>;
}
定时器 ID 不需要显示在页面上,但清理副作用时必须拿到它。用 ref 保存,既能跨渲染保持引用,也不会因为保存 ID 而触发一次额外渲染。
常见场景:保存上一次的值
jsx
import { useEffect, useRef } from "react";
function SearchResult({ keyword }) {
const previousKeywordRef = useRef(keyword);
useEffect(() => {
if (previousKeywordRef.current !== keyword) {
console.log("关键词从", previousKeywordRef.current, "变成", keyword);
}
previousKeywordRef.current = keyword;
}, [keyword]);
return <p>当前关键词:{keyword}</p>;
}
常见场景:保存最新回调或标记位
例如某个副作用需要读取最新配置,但不希望因为配置对象变化就重新创建外部连接,可以把最新配置同步到 ref,再由事件处理函数读取 configRef.current。
不过,这种写法要谨慎:ref 绕开了 React 的响应式更新模型。它适合解决"读取最新值"的问题,不应该代替正常的数据流。
五、面孔三:外部对象,把 Web Worker 挂在 ref 上
第三种场景更接近工程实践:把一个不属于 React 的外部实例交给 useRef 管理。
先理解为什么需要 Worker
浏览器页面的 JavaScript 通常运行在主线程上。主线程既要处理 React 更新、点击和滚动,也要执行 JavaScript 计算。
如果在主线程执行很重的同步循环,事件循环就没有机会及时处理用户操作:页面会卡顿,甚至暂时无响应。
js
// 不要在主线程执行这种大规模同步计算
for (let i = 0; i < 1000000000; i += 1) {
// 耗时计算
}
async/await 或 Promise 主要解决的是"等待期间不阻塞后续代码组织"的问题;它们不会自动把同步计算搬到另一个线程。计算最终仍然会在执行它的线程上占用 CPU。
Web Worker 提供了一个独立的脚本执行上下文。主线程通过消息发送任务,Worker 完成计算后再把结果发回来:
text
主线程(负责 UI) Worker 线程(负责计算)
│ │
│──── postMessage(任务) ─────────────────→│
│ │ 执行耗时计算
│ 继续响应点击和滚动 │
│←──── onmessage(计算结果) ────────────────│
│ │
│ setResult(结果) │
错误写法:在渲染函数里创建 Worker
下面这种写法看起来直接,但不应该使用:
jsx
function App() {
const workerRef = useRef(null);
// 错误:渲染函数每执行一次,就可能创建一个新的 Worker
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
return <div>计算中...</div>;
}
React 组件函数可能因为 state 更新、父组件更新、开发环境 Strict Mode 等原因多次执行。渲染阶段应该保持纯粹,不能在里面创建 Worker、绑定事件或发送任务,否则会出现重复线程、重复监听、重复计算和无法清理的问题。
正确写法:在 effect 中创建、在 cleanup 中销毁
jsx
import { useEffect, useRef, useState } from "react";
export default function WorkerDemo() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
const [status, setStatus] = useState("准备开始");
useEffect(() => {
const worker = new Worker(
new URL("./worker.js", import.meta.url),
{ type: "module" }
);
workerRef.current = worker;
worker.onmessage = (event) => {
setResult(event.data);
setStatus("计算完成");
};
worker.onerror = () => {
setStatus("计算失败");
};
setStatus("计算中...");
worker.postMessage({ task: "sum" });
return () => {
worker.terminate();
workerRef.current = null;
};
}, []);
return (
<div>
<p>{status}</p>
{result !== null && <p>结果:{result}</p>}
</div>
);
}
这里的职责分工很清楚:
workerRef保存 Worker 实例,保证重渲染时仍然能拿到同一个对象status和result属于 UI 数据,因此使用useStateuseEffect负责创建 Worker、绑定消息监听和发送初始任务- cleanup 负责终止 Worker,防止组件卸载后后台线程继续运行
对应的 worker.js:
js
self.onmessage = (event) => {
if (event.data?.task !== "sum") {
return;
}
let result = 0;
for (let i = 0; i < 100000000; i += 1) {
result += i;
}
self.postMessage(result);
};
示例使用 new URL("./worker.js", import.meta.url),这是 Vite 等现代构建工具常见的 Worker 引入方式。不同脚手架的 Worker 配置可能略有差异,实际项目需要以构建工具文档为准。
为什么不用 state 保存 Worker?
严格来说,useState 也能保存一个对象引用,并不是"用了 state 就一定每次渲染都重新创建"。真正的问题是:Worker 实例本身不是 UI 状态,保存它不需要触发渲染,使用 ref 语义更准确,也更方便在 effect 和事件处理函数中读取。
不要把"用 state 保存"简单说成"每次渲染都会创建新 Worker";只要初始化方式正确,state 也可以保持引用。工程上选择 ref,是因为它表达了"这是一个外部可变实例,不负责驱动 UI"。
六、三个 Hook 如何配合?
把这三个场景放在一起看:
| Hook | 负责什么 | 更新后是否重渲染 | 典型内容 |
|---|---|---|---|
useState |
保存会影响 UI 的状态 | 会 | 加载状态、结果、表单值 |
useRef |
保存跨渲染的可变引用 | 不会 | DOM、timer、Worker、上一次的值 |
useEffect |
安排副作用的执行与清理 | 本身不负责保存值 | 创建实例、监听事件、请求数据 |
可以用一句话记忆:
state 管页面要显示什么,ref 管外部对象在哪里,effect 管什么时候连接和断开。
七、常见误区
1. ref 改了,为什么页面不更新?
因为修改 current 不会触发渲染:
js
valueRef.current = nextValue;
如果页面必须马上显示 nextValue,就应该把它放进 state:
js
setValue(nextValue);
2. 能不能在渲染阶段读取和修改 ref?
读取某些稳定 ref 通常没有问题,但不要在渲染阶段执行会产生外部影响的操作,例如创建 Worker、绑定事件、启动定时器或调用第三方实例。修改 ref 也应尽量放在事件处理函数或 effect 中,避免让渲染过程依赖隐式副作用。
3. useRef 能不能跨页面永久保存数据?
不能。ref 的生命周期属于当前组件实例。组件卸载后,ref 也会随之失效;需要跨页面或跨组件共享的数据,应考虑状态提升、Context、状态管理库或持久化存储。
4. useRef 和 createRef 有什么区别?
函数组件中通常使用 useRef。它在多次渲染之间保持同一个 ref 对象;createRef 更常见于 class 组件,若在函数组件每次渲染时直接调用,可能得到新的对象。
八、面试怎么回答 useRef?
可以这样回答:
useRef返回一个在组件多次渲染之间保持稳定的对象,对象通过current保存值。修改current不会触发组件重新渲染,因此它适合保存 DOM 节点、定时器 ID、上一次的值,以及 Web Worker、WebSocket 等外部实例。如果数据变化需要驱动 UI 更新,应使用useState;如果涉及创建、订阅或销毁外部资源,则通常配合useEffect管理时机和清理。
这段回答包含了面试官真正想听的四个关键词:稳定引用、可变 current、不触发渲染、副作用配合。
九、总结:useRef 的三种面孔
useRef 的本质是一个持久的可变容器。它不是只能拿 DOM,而是可以承载三类东西:
| 面孔 | current 指向 |
典型场景 | 核心代码 |
|---|---|---|---|
| DOM 引用 | DOM 节点 | 自动聚焦、滚动、测量 | inputRef.current?.focus() |
| 可变值 | 数字、字符串、对象 | timer ID、上一次的值、标记位 | previousRef.current = value |
| 外部对象 | Worker、WebSocket、图表实例 | 后台计算、第三方库集成 | workerRef.current = worker |
最后记住三句话:
useState管数据,改了以后页面需要重新绘制useRef管引用,改了以后不会主动触发绘制useEffect管时机,负责连接外部世界,也负责清理
理解这三者的分工,useRef 就不再是"操作 DOM 的特殊 API",而会变成 React 中管理外部资源和跨渲染引用的一件基础工具。