点击一个按钮,页面却突然卡死;滚动没有反应,输入框也打不进字,过了几秒甚至弹出“页面无响应”。
问题可能只是这一段同步计算:
button.onclick = () => {
let sum = 0;
for (let i = 0; i < 500_000_000; i += 1) {
sum += i;
}
console.log("算完了:", sum);
};
这不是 React 的问题,也不是 async/await 写得不够好,而是这段计算霸占了浏览器的 JavaScript 主线程。
本文会从事件循环讲到 Web Worker,再在 React 中实现一个“后台计算”示例。读完你应该能回答三个问题:
- 为什么 Promise 不能解决 CPU 密集型计算?
- Worker 和主线程之间如何传递任务和结果?
- React 中应该在什么时候创建、使用和销毁 Worker?
一、JS 为什么是单线程?
JavaScript 最初主要服务于网页交互:点击、输入、表单和 DOM 更新都发生在页面环境中。
如果多个 JavaScript 线程可以同时修改同一个 DOM 节点,就会出现竞态:一个线程刚把按钮改成红色,另一个线程又改成蓝色,浏览器还要决定最终结果。
因此,一个页面中的 JavaScript 主线程一次只执行一个 JavaScript 任务。它既要处理用户事件,也要参与脚本执行和页面更新。
主线程
├── 处理点击和输入
├── 执行 JavaScript
├── 触发 React 更新
└── 计算 5 亿次循环 ← 任务太重,其他事情只能排队
这里说的是 JavaScript 执行模型,不是说整个浏览器只有一条线程。浏览器本身是复杂的多进程、多线程软件,网络、渲染、GPU 和存储等工作并不都由同一个线程完成。
二、Event Loop 能解决什么,不能解决什么?
Event Loop 让 JavaScript 能够协调异步任务。例如发起网络请求后,主线程不用一直停在那里等待:
fetch("/api/data")
.then((response) => response.json())
.then((data) => console.log(data));
网络请求的等待阶段可以交给浏览器处理,结果回来后再把回调放回 JavaScript 任务队列。
但 CPU 密集型计算不同:
setTimeout(() => {
// 这段计算最终仍然在主线程执行
for (let i = 0; i < 500_000_000; i += 1) {
// 耗时计算
}
}, 0);
setTimeout 只能把任务推迟到未来执行,并没有创建新线程。时间到了之后,这段循环依然会占用主线程。
可以这样记:
| 问题 | Event Loop 是否擅长 |
|---|---|
| 等待网络响应 | 擅长,等待期间主线程可以处理其他任务 |
| 等待定时器 | 擅长,回调稍后再执行 |
| 大量同步计算 | 不擅长,计算开始后仍会占用执行它的线程 |
三、Web Worker:把计算放到另一个 JavaScript 执行上下文
Web Worker 是浏览器提供的后台脚本能力。它拥有独立的 JavaScript 执行上下文和事件循环,可以在主线程之外执行计算。
它和主线程之间不是直接读写变量,而是通过消息通信:
主线程(UI) Worker 线程(计算)
│ │
│──── postMessage(任务) ─────────────→│
│ │ 执行计算
│ 继续响应点击和滚动 │
│←──── onmessage(结果) ────────────────│
│ │
│ setResult(结果) │
主线程和 Worker 可以并发执行,但是否真正同时占用不同 CPU 核心,仍由浏览器和操作系统调度。对前端来说最重要的收益是:重计算不再阻塞负责交互的主线程。
四、实战:在 React 中使用 Web Worker
下面用一个简单的求和任务演示完整流程。为了避免 JavaScript 精度问题,示例在循环中使用取模结果;实际项目应根据业务选择合适的数值类型和算法。
1. Worker 文件:接收任务并返回结果
// worker.js
self.onmessage = (event) => {
const { num, iterations } = event.data;
let result = 0;
for (let i = 0; i < iterations; i += 1) {
result = (result + (num * i) % 1_000_000_007) % 1_000_000_007;
}
self.postMessage({ result });
};
Worker 中没有页面 DOM,因此不能使用 window、document 或直接操作 React 组件。self 指向当前 Worker 的全局对象,onmessage 接收主线程发来的任务,postMessage 把结果发回主线程。
2. React 组件:准备 Worker 实例
Worker 是一个外部对象,不是页面要展示的状态。用 ref 保存它,用 state 保存会影响 UI 的数据:
import { useEffect, useRef, useState } from "react";
export default function WorkerDemo() {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const worker = new Worker(
new URL("./worker.js", import.meta.url)
);
workerRef.current = worker;
worker.onmessage = (event) => {
setResult(event.data.result);
setLoading(false);
};
return () => {
worker.terminate();
workerRef.current = null;
};
}, []);
创建 Worker、绑定监听和销毁线程都属于副作用,所以放在 useEffect 中。cleanup 使用 effect 内的局部 worker,比直接读取可变的 workerRef.current 更稳妥。
3. 点击按钮:发送任务并展示结果
下面的代码接在组件函数中:
function startHeavyCalculation() {
const worker = workerRef.current;
if (!worker || loading) return;
setLoading(true);
worker.postMessage({
num: 88,
iterations: 50_000_000,
});
}
return (
<div>
<button onClick={startHeavyCalculation} disabled={loading}>
{loading ? "正在后台计算..." : "启动计算"}
</button>
{result !== null && <p>计算结果:{result}</p>}
</div>
);
}
result !== null 比 result && 更准确:如果计算结果恰好是 0,页面也应该显示它。
不同构建工具的 Worker 引入方式可能略有差异。new URL("./worker.js", import.meta.url) 是 Vite 等现代工具常见的写法;如果使用其他脚手架,应以对应文档为准。
五、关键细节:为什么这样设计?
1. 为什么用 useRef 存 Worker?
useRef 在同一个组件实例的多次渲染之间保持稳定引用,修改 current 也不会触发渲染。Worker 本身不负责显示 UI,正好符合这个特征。
但不要把它理解成“只能用 ref”。useState 也可以保存一个对象引用,正确初始化时不会因为每次渲染就自动创建新 Worker。这里选 ref,是因为 Worker 是外部可变实例,不是需要驱动 UI 的状态,语义更准确。
2. 为什么必须清理 Worker?
组件卸载后,Worker 不会因为 React 组件消失就自动停止。如果不调用 terminate(),它可能继续占用 CPU,并继续向已经离开的页面发送消息。
return () => {
worker.terminate();
workerRef.current = null;
};
这和清理定时器、移除事件监听是同一类副作用管理问题。
3. 主线程和 Worker 如何通信?
主线程发送任务:
worker.postMessage({ num: 88, iterations: 50_000_000 });
Worker 返回结果:
self.postMessage({ result });
接收端通过 event.data 读取消息。普通对象会通过结构化克隆算法传递,接收端得到的是数据副本;对于 ArrayBuffer 等大块二进制数据,还可以使用 Transferable 转移所有权,减少复制成本。
六、Web Worker 能做什么,不能做什么?
| 能做 | 不能直接做 |
|---|---|
| 纯计算、加密、数据转换 | 操作页面 DOM |
fetch 网络请求 | 访问 window、document |
console.log | 直接更新 React 组件 |
使用 postMessage 通信 | 直接读取主线程变量 |
| 使用 IndexedDB 等 Worker 可用 API | 按普通页面脚本方式访问 localStorage |
Worker 不是“后台版 React”,也不是共享内存线程。它更像一个只接收输入、产出结果的计算模块。
七、哪些场景适合使用 Worker?
常见场景包括:
- 游戏中的碰撞检测、物理模拟
- 浏览器端模型推理或向量计算
- 加密、解密、压缩和解压
- 大型 CSV、JSON 数据处理
- 图片滤镜、压缩和格式转换
可以先用一个实用标准判断:如果某段同步任务经常让主线程卡顿,或者阻塞时间明显超过一帧渲染预算,就值得考虑 Worker。50ms 可以作为排查时的经验参考,但不是绝对阈值,最终还要结合设备性能和用户体验测量。
八、Worker 的成本也要考虑
Worker 不是免费加速器:
- 创建线程有启动成本
- 消息传递需要序列化或结构化克隆
- 任务拆分和错误处理会增加代码复杂度
- 主线程和 Worker 之间不能随意共享普通变量
很小的计算放进 Worker,可能因为通信成本反而更慢。先测量任务耗时,再决定是否迁移;对于大型二进制数据,优先了解 Transferable,避免无意义的数据复制。
九、常见误区
误区一:Promise 能把计算放到后台线程
Promise 只描述异步结果,不会自动创建线程。setTimeout、Promise 和 async/await 都不能改变同步计算最终在哪个线程执行。要把计算搬离主线程,需要 Web Worker 或其他真正提供线程能力的机制。
误区二:Worker 创建后不用清理
Worker 是外部资源。组件卸载时应该调用 terminate();如果注册了消息监听,也要解除监听或让 Worker 一并结束。
误区三:Worker 可以直接修改页面
Worker 没有页面 DOM。它只能把数据发回主线程,再由主线程通过 React state 更新界面。
十、面试怎么回答?
可以这样回答:
Web Worker 是浏览器提供的独立 JavaScript 执行上下文,适合处理 CPU 密集型任务。主线程通过
postMessage发送数据,Worker 通过onmessage接收并计算,再用postMessage返回结果。Worker 不能直接访问 DOM 或 React 状态,因此主线程需要把结果接回 state。React 中通常在useEffect中创建和监听 Worker,用useRef保存实例,并在 cleanup 中调用terminate释放资源。
十一、总结
| 概念 | 作用 |
|---|---|
| JavaScript 主线程 | 执行脚本、处理交互并参与 UI 更新 |
| Event Loop | 协调异步等待和任务执行,不能搬走 CPU 计算 |
| Web Worker | 在独立执行上下文中运行计算任务 |
postMessage | 主线程与 Worker 之间发送消息 |
onmessage | 接收另一端发来的消息 |
useRef | 保存 Worker 等外部实例的稳定引用 |
useEffect | 管理创建、监听和销毁时机 |
terminate | 停止 Worker,释放线程资源 |
最后记住一句话:JS 的主线程负责和用户打交道,Worker 负责处理可以搬走的重活。
Web Worker 没有改变 JavaScript 的单线程执行模型,它只是让浏览器给了我们一个独立的计算执行上下文。真正理解消息通信、生命周期和任务成本,才能把它用在合适的地方。