从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程
本文根据个人学习笔记整理,结合两个小 demo,梳理 useRef 的核心特性,并延伸到 JS 单线程、event loop 与 Web Worker 的关系。适合正在学习 React Hooks、想搞懂 useRef 用法和浏览器多线程机制的同学。
一、先温习:useState 与 useEffect
React 函数组件引入 Hooks 后,最常用的两个:
- useState:创建响应式状态。状态变化,组件重新渲染。
- useEffect:处理副作用。在渲染完成后执行,比如操作 DOM、订阅、请求数据等。
jsx
const [count, setCount] = useState(0)
useEffect(() => {
document.title = `你点击了 ${count} 次`
}, [count])
这是 React 声明式编程的基础:你只描述"状态应该是什么",React 负责更新界面。

二、useRef:持久可变的"容器"
官方一句话:useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数,并且在整个组件生命周期中保持不变。
它有两个重要身份:
- 引用 DOM 节点:拿到真实 DOM 对象。
- 保存任意可变值:而且改变它不会触发组件渲染。
理解这个"不会触发渲染"非常关键,我们后面会反复提到。

三、为什么 React 不直接操作 DOM?
在 React/Vue 出现之前,前端大量使用原生 JS 操作 DOM:
js
document.getElementById('app').innerHTML = '<span>你好</span>'
这种命令式写法虽然直观,但非常消耗性能。原因有两点:
- JS 运行在 V8 引擎,DOM 在渲染引擎,两者跨线程通信成本高。
- DOM 变化会触发重排(reflow)和重绘(repaint),频繁操作会卡顿。
React 和 Vue 的核心改进之一就是:规避直接 DOM 编程,由框架帮你高效更新 DOM。你只需要管理数据状态,例如:
jsx
const [message, setMessage] = useState('你好')
return <span>{message}</span>
当 message 改变,React 通过虚拟 DOM diff 找到最小变更,再去操作真实 DOM。

四、如果非要操作 DOM,useRef 来了
虽然 React 不推荐直接操作 DOM,但有些场景确实需要:
- 自动聚焦输入框
- 滚动到某个位置
- 获取元素尺寸、画布上下文
- 集成第三方非 React 库
那么,在 React 中如何拿到这些真实的 DOM 节点呢?答案就是前面提到的 useRef 。它允许我们创建一个 ref 对象,并通过 JSX 的 ref 属性绑定到元素上,从而在组件中访问到真实的 DOM 节点。这正是 React 为我们保留的一个"受控出口"。
有了这个出口,我们才能实现上面那些场景。但更重要的是,这些操作并非单纯的技术需求,它们背后都有明确的用户体验目标。正如我学习笔记中的那句代码注释所说:"把用户当小白,前端的职责就是打造良好的用户体验"。
以自动聚焦为例:用户打开一个登录页,如果光标已经自动停在用户名输入框里,他就可以直接打字,省去"手动点击输入框"这一步。对于追求效率的用户来说,这一个小小的细节,就能让体验更顺畅。滚动到某个位置,可能是为了引导用户注意力,比如进入聊天页面自动滚到最新消息;获取元素尺寸,可能是为了实现更精准的响应式布局或动画。
所以,useRef 提供的这个"受控出口",不仅是技术上操作 DOM 的通道,更是前端工程师优化用户体验的入口。我们不是为了操作 DOM 而操作 DOM,而是为了服务用户。
示例:页面加载后自动聚焦输入框:
jsx
import { useRef, useEffect } from 'react'
function AutoFocusInput() {
const inputRef = useRef(null)
useEffect(() => {
console.log(inputRef.current) // <input ...>
inputRef.current.focus() // 挂载后自动聚焦
}, [])
return (
<input
type="text"
placeholder="请输入用户名"
ref={inputRef}
/>
)
}
执行流程:
useRef(null)创建一个 ref 对象,current初始为null。- JSX 中
<input ref={inputRef} />把 DOM 节点绑定到inputRef.current。 - 组件渲染完成,
useEffect执行,此时inputRef.current已经指向真实 input。 - 调用
.focus(),用户无需点击即可输入。
这里 useEffect 空依赖数组表示只在挂载后执行一次。

五、useRef 的另一个身份:不触发渲染的可变值
除了 DOM,useRef 还能保存任意值。看一个例子:
jsx
function NonReactiveCounter() {
const numRef = useRef(0)
return (
<div onClick={() => numRef.current++}>
{numRef.current}
</div>
)
}
点击后,numRef.current 确实在增加(你可以在 React DevTools 中看到),但页面显示的始终是 0。因为 useRef 的值变化不会触发组件重新渲染。
如果我们希望界面跟着变,必须借助 useState 触发一次渲染:
jsx
function ReactiveCounter() {
const numRef = useRef(0)
const [, forceRender] = useState(0) // 只用来强制刷新
return (
<div onClick={() => {
numRef.current++
forceRender(x => x + 1) // 手动触发渲染
}}>
{numRef.current}
</div>
)
}
这个对比非常直观:
| 特性 | useState | useRef |
|---|---|---|
| 是否触发渲染 | ✅ 是 | ❌ 否 |
| 用途 | 聚焦数据状态、业务状态 | DOM 引用、持久化可变值 |
| 常见例子 | 计数、表单、列表 | 定时器 id、worker 实例、DOM 节点 |
何时用 useRef 保存值?
当你需要跨渲染保存一个变量,但它的变化又不需要立刻反映到 UI 上时,用 useRef 很合适。例如保存定时器 ID、动画帧 ID、WebSocket 连接、Web Worker 实例等。

六、JS 单线程与 event loop:为什么前端怕阻塞
要理解 Web Worker,得先理解 JS 单线程。
浏览器中,JS 主线程负责:
- 执行 JS 代码
- 处理用户交互
- 更新渲染页面
如果 JS 是多线程,多个线程同时操作 DOM,就可能产生冲突,导致界面状态不一致。所以浏览器让 JS 保持单线程,保证"所见即所得"的一致性。
但页面复杂了,任务多了怎么办?比如:
js
console.time('主线程耗时')
let sum = 0
for (let i = 0; i < 1e8; i++) {
sum += i
}
console.timeEnd('主线程耗时')
这段代码会让主线程一直计算,期间页面无法响应点击、滚动、输入,这就是"阻塞"。
为了解决"等待"类任务,JS 设计了 event loop:异步任务先挂起,主线程继续处理后续代码,等异步结果回来再通过任务队列执行回调。这样网络请求、定时器等不会卡住页面。
但 event loop 解决的是"异步无阻塞",它无法让 CPU 密集计算凭空消失。如果计算本身要占用主线程几秒钟,页面依然会卡。

七、Web Worker:浏览器里的"新线程"
HTML5 提供了 Web Worker:允许 JS 开启一个新的线程,专门处理耗时计算。
它的特点:
- 独立线程、独立内存
- 不阻塞主线程
- 通过消息机制与主线程通信
- 不能直接操作 DOM
适合场景:LLM 推理、游戏逻辑、大规模数据处理、图像处理等。
基础用法:
js
// 主线程
const worker = new Worker(new URL('./worker.js', import.meta.url))
worker.postMessage('start')
worker.onmessage = (e) => {
console.log('Worker 结果:', e.data)
}
js
// worker.js
console.log('worker 线程启动')
self.onmessage = (e) => {
let sum = 0
for (let i = 0; i < 1e8; i++) {
sum += i
}
self.postMessage(sum)
}
主线程把任务发给 worker,worker 在独立线程中计算,完成后通过 postMessage 把结果发回来。主线程全程没有被阻塞。
不过,Web Worker 不是免费的午餐。
创建 Worker 线程本身是有开销的:浏览器需要分配独立内存、初始化线程环境,这个成本并不小。而且主线程与 Worker 之间通过 postMessage 通信,数据需要被结构化克隆(序列化/反序列化)。如果频繁传递大量数据,通信成本可能超过计算本身。
因此,轻量任务不建议使用 Worker。例如一个耗时 1ms 的简单计算,直接在主线程执行即可,开启 Worker 反而得不偿失。一般来说,只有遇到明显阻塞、计算耗时较长(例如几百毫秒以上)的 CPU 密集型任务,才值得考虑 Worker。

八、useRef + Web Worker:跨渲染保持 worker 实例
在 React 函数组件里,有一个容易踩的坑:函数组件每次渲染都会重新执行。
如果直接写:
jsx
function App() {
const worker = new Worker(new URL('./worker.js', import.meta.url))
// 每次渲染都会创建一个新 worker
}
组件每次重新渲染,都会创建一个新的 worker 实例。这既浪费资源,又可能造成内存泄漏,甚至逻辑混乱。
正确做法是用 useRef 保存 worker:
jsx
import { useRef, useEffect } from 'react'
function App() {
const workerRef = useRef(null)
useEffect(() => {
// 组件挂载后,只创建一次 worker
// 开启一个 worker 线程开销比较大,所以不要在每次渲染时重复创建
workerRef.current = new Worker(new URL('./worker.js', import.meta.url))
workerRef.current.onmessage = (e) => {
console.log('Worker 计算完成:', e.data)
}
return () => {
// 组件卸载时关闭 worker,释放资源
workerRef.current.terminate()
}
}, [])
const handleStart = () => {
workerRef.current?.postMessage('start')
}
return (
<div>
<button onClick={handleStart}>开始耗时计算</button>
</div>
)
}
为什么用 useRef 而不是 let?
- 函数组件每次渲染都会重新执行一遍函数,函数体内的普通变量(比如
let worker)都会重新声明、重新赋值,所以每次渲染都会创建一个新的 worker 实例。 - 而
useRef的值是 React 保存在组件对应的 fiber 节点上的,不会随函数执行而重置,因此能跨渲染保持引用不变。这样既避免了重复创建 Worker 的昂贵开销,也保证了 worker 实例的稳定性。
这也是 useRef 作为"持久可变对象"的典型应用。

九、串联起来:一条完整知识线
回顾一下:
- useState:响应式状态,驱动 UI 更新。
- useEffect:处理副作用,在渲染后与外部系统同步。
- useRef:可变对象,两个用途------引用 DOM、保存不触发渲染的值。
- React 不直接操作 DOM:声明式开发,框架帮你高效更新。
- 操作 DOM 的初心:不是为了技术,而是为了用户体验,比如自动聚焦减少用户操作步骤。
- JS 单线程 + event loop:保证一致性,异步无阻塞,但计算密集任务仍然会卡。
- Web Worker:开辟新线程,处理耗时计算,通过消息机制通信,但创建和通信都有成本,需评估使用。
- useRef + Web Worker:在 React 中跨渲染稳定持有 worker 实例,避免重复创建。
这些知识点并不是孤立的。useRef 的非响应式特性,恰好适合保存 worker 这种"不需要渲染、但要长期存在"的对象;而 Web Worker 的存在,又解决了 React 主线程被耗时任务阻塞的问题。但切记,Worker 不是万能的,轻量任务不要滥用,否则性能反而下降。

写在最后
学习 useRef 时,不要只把它当成"获取 DOM 的工具"。它本质是一个 跨渲染、非响应式的可变容器,在很多场景下都能派上用场。而在使用它操作 DOM 时,不妨多想想:这个操作能让用户少点一次、少等一秒、少困惑一点吗?
结合 Web Worker,我们可以把耗时任务安全地挪出主线程,让页面保持流畅。但在使用 Worker 前,请先评估任务的计算量和通信成本,避免为轻量任务开启昂贵的新线程。
如果你也在学习 React Hooks,建议动手跑一跑文中的两个 demo,观察:
- React DevTools 中 ref 值的变化
- 页面是否重新渲染
- worker 是否在独立线程启动
- 简单任务与复杂任务使用 Worker 的性能差异
如果这篇文章对你有帮助,欢迎点赞、收藏、评论,一键三连支持一下~ 你的每一个反馈都是我继续写作的动力!也欢迎关注我,后续会持续输出 React 前端相关的学习笔记和实战经验。我们下篇文章见!😊