useRef + Web Worker 实战:React 如何优雅地拥抱多线程
从 JS 单线程的瓶颈出发,逐层深入 Event Loop 的力不从心、Web Worker 的多线程之道、再到 useRef 在其中的桥接角色------用一份完整 Demo 打通 React 副作用管理、消息通信与资源清理的全链路。
一、JS 是单线程,为什么当初这么设计?
1.1 前端的本职工作
JavaScript 诞生之初就只做一件事:给网页加点交互------表单校验、点击弹窗、小动画。这些任务的特点是:
- 任务轻、频率高
- 必须和 DOM 紧密配合
- 用户期望即时响应
如果 JS 是多线程的,两个线程同时改同一个 <div> 的内容怎么办?一个线程要删节点,另一个要改它的文字------这会产生竞态条件 和数据不一致 。为了"显示和操作的一致性",单线程是最安全的默认选择。
1.2 单线程的代价
css
// 这段代码会彻底卡死页面
for (let i = 0; i < 100000000; i++) {
console.log(i);
}
执行 1 亿次循环期间,浏览器完全无法响应任何用户操作------点击无效、滚动卡死、动画掉帧。因为主线程被这段同步代码霸占了。
erlang
主线程时间线:
┌──────────────────────────────────────────────────────┐
│ [1亿次循环...CPU 100%] │
│ ↓ 用户点击按钮(排队等待) │
│ ↓ 用户滚动页面(排队等待) │
│ ↓ ... │
│ ↓ 循环结束 │
│ → 处理排队的事件 │
│ → 用户感觉"卡了" │
└──────────────────────────────────────────────────────┘
二、Event Loop 能拯救吗?
2.1 异步 ≠ 多线程
javascript
console.log('1');
setTimeout(() => {
console.log('2'); // 异步,放到宏任务队列
}, 0);
console.log('3');
// 输出顺序:1 → 3 → 2
Event Loop 的机制是不阻塞------把耗时操作挂起来,先去干别的事。但关键认知是:
Event Loop 只是同一个线程上的"任务调度策略",它没有开辟新线程。
javascript
主线程唯一的执行栈:
┌──────────────────────────────────────────────┐
│ 同步代码 → 微任务队列(Promise) → 宏任务队列(setTimeout) │
│ 所有队列里的任务最终都回到同一条主线程上执行 │
│ 该卡还是卡------只是"晚点卡" │
└──────────────────────────────────────────────┘
2.2 什么场景 Event Loop 搞不定?
当一个任务是计算密集型的------LLM 推理、游戏物理引擎、大量加密解密------即使把它拆成多个异步块,每个块的执行仍然占用主线程的那条单行道。页面就会间歇性卡顿。
vbnet
需求变了:不只是"点击按钮弹个窗"
而是"跑一个本地 LLM 模型做推理"
"实时渲染一个 3D 游戏场景"
"对 1GB 数据做加密处理"
→ Event Loop 异步调度不够用了
→ 需要真正的并行计算
→ Web Worker 来了
三、Web Worker:浏览器的多线程方案
3.1 JS 单线程并没有改变------但浏览器是多线程的
这是一个关键认知:
scss
┌──────────────────────────────────┐
│ 浏览器 (C++ 程序) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ JS 主线程 │ │ Worker线程 │ │
│ │ (V8 引擎) │ │ (独立 V8) │ │
│ │ │ │ │ │
│ │ DOM 操作 ✓ │ │ DOM 操作 ✗ │ │
│ │ UI 渲染 ✓ │ │ 纯计算 ✓ │ │
│ └──────────┘ └──────────┘ │
│ │ │ │
│ └── postMessage ─┘ │
│ 消息通信 │
└──────────────────────────────────┘
浏览器本身是 C++ 写的多进程多线程软件。Web Worker 是浏览器提供的一个新的 JS 运行时环境 ------独立的 V8 引擎实例、独立的内存堆、独立的事件循环。和主线程物理隔离,互不干扰。
所以:
JS 单线程机制没有变------你写 JS 代码时仍然是单线程思维。只是在需要的时候,浏览器另开一个"单线程 JS 环境"来帮你并行干活。
3.2 Worker 的硬限制
- ❌ 不能访问 DOM ------Worker 里没有
document、没有window - ❌ 不能直接操作页面------UI 更新只能通过消息通知主线程来做
- ✅ 有自己的 API 子集 ------
self、postMessage、onmessage、fetch、Math等
这恰好符合 React 的哲学------React 本来就帮我们操作 DOM,Worker 专注纯计算就好。
四、完整 Demo 拆解
4.1 主线程:App.jsx
javascript
import { useRef, useState, useEffect } from 'react';
function App() {
console.log('main thread');
// 为组件的渲染挂载让路 ------ 优先渲染 UI,Worker 等等再开
const workerRef = useRef(null); // 持久持有 Worker 实例
const [result, setResult] = useState(null); // 响应式:计算结果
const [loading, setLoading] = useState(false); // 响应式:加载状态
useEffect(() => {
// ① 组件挂载后:创建 Worker(开销大,放 effect 里)
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
// ② 监听 Worker 的消息
workerRef.current.onmessage = (e) => {
console.log(e);
const { result } = e.data;
setResult(result); // 计算结果写入状态 → 触发 UI 更新
setLoading(false); // 关掉 loading 态
};
// ③ 组件卸载时:清理 Worker
return () => {
workerRef.current.terminate(); // 立即终止线程
workerRef.current = null; // 手动回收引用
};
}, []);
const startHeavyCalc = () => {
setLoading(true);
// ④ 消息机制:给 Worker 发送任务指令
workerRef.current.postMessage({
num: 88
});
};
return (
<div style={{ padding: "30px" }}>
<h2>useRef + WebWorker 耗时运算</h2>
<p>开启 web worker 线程执行 50 亿次循环,结束后通知主线程</p>
<button
onClick={startHeavyCalc}
disabled={loading}
>
{loading ? "正在后台计算..." : "启动繁重计算任务"}
</button>
{result && <h3>计算结果:{result}</h3>}
</div>
);
}
4.2 Worker 线程:worker.js
ini
// Web Worker 独立子线程 ------ 不可以做 DOM API,有自己的 API 子集
// self 关键字指向 Worker 自身的全局作用域
self.onmessage = (e) => {
const { num } = e.data;
console.log('Worker 收到主线程任务,参数为:', e.data);
let sum = 0;
for (let i = 0; i < 5000000000; i++) { // 50 亿次循环!
sum += num * i;
}
// 计算完成,把结果发回主线程
self.postMessage({
result: sum
});
};
4.3 完整数据流时间线
scss
用户点击按钮
│
▼
startHeavyCalc()
├── setLoading(true) → UI 立即显示"正在计算..."
└── workerRef.current.postMessage({ num: 88 })
│
▼ (消息跨越线程边界)
Worker 线程收到 onmessage
│
▼
执行 50 亿次循环
(CPU 100%,但不在主线程!
主线程此刻仍然可以响应用户点击、滚动!)
│
▼
self.postMessage({ result: xxx })
│
▼ (消息跨越线程边界)
主线程 workerRef.current.onmessage 触发
│
▼
setResult(result) → UI 更新,显示结果
setLoading(false) → 按钮恢复可点击
核心体验 :50 亿次循环在跑的时候,页面按钮、滚动、输入框一切正常------因为计算发生在另一个线程的另一个 V8 实例里,主线程的事件循环完全不受干扰。
五、逐行解读关键设计
5.1 为什么用 useRef 而不是 useState 存 Worker?
ini
const workerRef = useRef(null);
| 对比 | useRef | useState |
|---|---|---|
| 存值 | workerRef.current = ... |
setWorker(...) |
| 触发渲染 | ❌ 否 | ✅ 是 |
| 组件重渲染后 | 值仍在 | 值仍在 |
| 是否需要触发渲染 | Worker 实例变了≠界面要刷新 | --- |
Worker 实例只是"后台干活的家伙",它从 null 变成 Worker 对象、发消息、收消息------这些操作都不需要驱动 UI 更新 。真正需要驱动 UI 的是 result 和 loading,它们用 useState。这就是"各司其职"。
5.2 为什么在 useEffect 里 new Worker?
注释"为组件的渲染挂载让路"的含义:
sql
时间线:
① render 阶段:App() 执行 → 返回 JSX → 生成 DOM
(此时 workerRef.current 仍然是 null)
② commit 阶段:DOM 挂载到页面 → 用户看到界面
③ effect 阶段:useEffect 回调执行 → new Worker → workerRef.current 指向 Worker
(用户已经看到界面了,Worker 的创建不会阻塞首次渲染)
如果把 new Worker() 写在组件函数体中(render 阶段),首次渲染会等 Worker 创建完才显示界面------虽然 Worker 创建很快,但这是架构层面的好习惯:渲染优先,副作用靠后。
5.3 postMessage:字符串序列化的消息机制
php
// 主 → Worker
workerRef.current.postMessage({ num: 88 });
// Worker → 主
self.postMessage({ result: sum });
底层机制:postMessage 内部会做结构化克隆 (Structured Clone Algorithm)------它不传引用,而是把数据完整拷贝一份传给对方线程。
css
主线程内存 Worker 线程内存
{ num: 88 } ──拷贝──► { num: 88 }
这就是"两个线程隔离开"的体现------它们不共享任何内存,通信全靠消息拷贝。这也意味着:
- 传大对象有拷贝开销,建议传关键数据而非整份数据集
- 不能传函数、不能传 DOM 节点
5.4 清理函数:谁创建,谁销毁
ini
return () => {
workerRef.current.terminate(); // ① 立即终止 Worker 线程
workerRef.current = null; // ② 清空引用,帮助 GC
};
terminate():浏览器 API,立即杀死 Worker 线程并释放独立内存。不管 Worker 在做什么------循环、等待、计算------直接终止。= null:语义上标记"这个 Worker 已经死了",并帮助 V8 垃圾回收。
为什么必须做这一步?
如果不清理,组件卸载后 Worker 仍然活着:
markdown
组件卸载 → Fiber 回收 → JS 引用丢失
→ Worker 线程仍在运行 💀
→ 内存泄漏
→ Worker 完成后 postMessage → 无人接收 → 错误
这就是 React 副作用管理的核心范式:对称性------Setup 里创建的资源,Cleanup 里必须销毁。
六、Web Worker 适合什么场景?
根据 readme 笔记的总结:
| 适合 | 不适合 |
|---|---|
| 游戏引擎物理计算 | DOM 操作(Worker 里没有 DOM API) |
| 本地 LLM 模型推理 | 需要直接改界面的任务 |
| 加解密等密集计算 | 轻量级异步请求(fetch 就够了) |
| 大数据处理 / 排序 | 简单的状态管理 |
| 图片 / 音视频处理 | 依赖 window 对象的逻辑 |
一句话判断:这个任务是纯计算(不碰 DOM),且耗时超过 50ms(一帧的预算)吗?是 → 考虑 Worker。
七、readme 笔记完整对应表
| 笔记要点 | 文中位置 |
|---|---|
| JS 单线程、Event Loop 机制 | 第一章、第二章 |
| 复杂任务 Event Loop 搞不定 | 第二章第 2 节 |
| Web Worker 线程 --- 浏览器提供的独立线程 | 第三章 |
| Worker 无法访问 DOM,用消息机制通信 | 第三章第 2 节 + 第五章第 3 节 |
实例化 Worker:new Worker(new URL(...)) |
第四章第 1 节 |
| 消息机制:postMessage / onmessage | 第四章第 1 节 + 第五章第 3 节 |
| JS 单线程没有变,浏览器是多线程的 | 第三章第 1 节 |
| 主线程和 Worker 隔离,互不干扰 | 第三章第 1 节 + 第五章第 3 节 |
| useRef 持久存放 Worker 实例 | 第五章第 1 节 |
| useEffect 挂载后初始化,优先渲染 | 第五章第 2 节 |
| 监听 + 发送数据 | 第四章第 1 节 + 第五章第 3 节 |
| 组件卸载时销毁线程(terminate + = null) | 第五章第 4 节 |
| JS 仍是单线程语言 | 第三章第 1 节 |
八、总结
sql
┌─────────────────────────────────────────────────────┐
│ 架构全景图 │
│ │
│ 主线程 Worker 线程 │
│ ┌──────────────┐ ┌──────────┐ │
│ │ React 组件树 │ │ 纯计算逻辑 │ │
│ │ │ postMessage │ │ │
│ │ useState ────┼──── 驱动 UI ──────►│ 密集循环 │ │
│ │ ↑ │ │ LLM 推理 │ │
│ │ │ 数据绑定 │ ◄─── onmessage ───│ 加解密 │ │
│ │ │ │ 结果返回 │ 游戏引擎 │ │
│ │ useRef ──────┼── 持有引用,不发渲染 │ │ │
│ │ │ └──────────┘ │
│ │ useEffect ───┼── 创建/销毁 Worker │
│ └──────────────┘ │
│ │
│ 核心原则: │
│ · 响应式的归 useState(result、loading) │
│ · 非响应式的归 useRef(Worker 实例) │
│ · 创建和销毁归 useEffect(对称性) │
│ · 纯计算归 Worker(不碰 DOM) │
│ · 通信靠 postMessage(结构化克隆) │
└─────────────────────────────────────────────────────┘
一句话收束 :useRef 持有一个不触发渲染的 Worker 引用,useEffect 管生管死,主线程通过 postMessage 发任务、通过 onmessage 收结果------整个过程中页面始终流畅。JS 仍然是单线程语言,但浏览器通过 Web Worker 这条"辅助通道",让前端真正具备了处理复杂计算的能力。