开篇:你经历过"按钮点完没反应"的绝望吗?
先看一个真实场景:
你做了一个数据分析页面,用户点"生成报表"按钮,页面直接卡死8秒,鼠标转圈、点击无响应、用户以为你的代码炸了。
你以为是你写的循环太大?
不,你只是把一座山压在了主线程的背上。
今天这篇文章,我就用 1 个 Hook + 1 个 API ,把繁重计算从主线程"搬走",让你的 React 页面不管跑多重的计算都丝般顺滑。
读完你将掌握:
- 为什么主线程会卡?
- useRef 在 Worker 场景里的正确用法
- Vite 项目里怎么正确引入 Worker 文件
- 一套完整的"耗时计算 + Worker 通信 + 资源回收"实战方案
一、先从痛点开始:主线程为什么卡?
JavaScript 是单线程语言。
你写的 for 循环、map、filter 统统跑在主线程上。
而这个主线程,它还负责:
- DOM 渲染
- 用户点击事件
- 动画帧
- 网络请求回调
- React 的状态更新
当你在主线程跑一个 5 亿次循环:
let sum = 0;
for (let i = 0; i < 500_000_000; i++) {
sum += i * 88;
}
这期间页面完全冻结——按钮点不动、输入框打不了字、滚动都像便秘。
主线程说:我在算数,别烦我。
这就是为什么用户点完按钮,过了8秒页面才有反应——主线程在这8秒里谁也顾不上。
二、一句话说清 Web Worker 是什么
Web Worker = 浏览器给你开的"独立子线程"。
主线程(React 页面) Worker 线程(独立运行)
─────────────────── ────────────────────────
负责渲染、交互 只负责计算
不卡用户操作 干完活通知主线程
关键原则:主线程只管 UI,Worker 只管计算。
Worker 线程:
- ✅ 可以干任何纯计算
- ❌ 不能操作 DOM
- ❌ 不能访问 window 对象
- ✅ 只能通过
postMessage和主线程通信
三、先写 Worker 文件:干活的人
创建 src/worker.js:
// worker.js — 这是跑在独立子线程里的代码
console.log('worker 已上线,等待主线程派活...');
// self 是 Worker 线程里的全局对象(类似主线程的 window)
self.onmessage = (e) => {
console.log(`worker 收到任务,参数:${JSON.stringify(e.data)}`);
const { num } = e.data;
let sum = 0;
// 这里可以是任何重计算——数组处理、数据转换、正则匹配等等
for (let i = 0; i < 500000000; i++) {
sum += i * num;
}
// 干完了,把结果送回主线程
self.postMessage({ result: sum });
};
划重点:
- 入口是
self.onmessage——接收主线程发来的任务 postMessage——把结果送回主线程- 这个文件里的代码完全独立运行,不影响你的 React 页面
四、React 这边怎么接?用 useRef 拿住 Worker
import { useRef, useEffect, useState } from 'react';
const App = () => {
const workerRef = useRef(null); // 用来"拿住" Worker 实例
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
// 组件挂载时初始化 Worker(只创建一次)
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url) // Vite 专属写法
);
}, []);
const startHeavyCalc = () => {
setLoading(true);
// 给 Worker 线程派活,带上参数
workerRef.current.postMessage({ num: 88 });
// 监听 Worker 的回信
workerRef.current.onmessage = (e) => {
const { result } = e.data;
setResult(result);
setLoading(false);
// 干完活就关闭,释放资源
workerRef.current.terminate();
workerRef.current = null;
};
};
return (
<div style={{ padding: '30px' }}>
<h2>useRef + Web Worker 耗时运算</h2>
<button onClick={startHeavyCalc} disabled={loading}>
{loading ? '正在后台计算...' : '启动繁重计算任务'}
</button>
<p>计算结果:{result}</p>
</div>
);
};
export default App;
这段代码里 useRef 的作用特别关键:
const workerRef = useRef(null);
它不是用来存 DOM 的,而是存一个跨渲染周期不变的 Worker 实例引用。
为什么不用 useState?
| 方式 | 问题 |
|---|---|
useState(new Worker(...)) | 每次渲染都重新 new,Worker 会被反复创建销毁 |
useRef(new Worker(...)) | 初始化一次,后面无论渲染多少次,拿到的都是同一个 Worker |
useRef 的核心价值:存一个"跑在渲染之外的活物"。
五、Vite 的 Worker 引入坑:千万别直接传字符串
写过 Webpack 的同学可能习惯这样写:
// ❌ Vite 里这样写会翻车
new Worker('./worker.js');
Vite 的构建工具链不认识这种相对路径,你需要用特殊写法:
// ✅ Vite 正确写法
new Worker(new URL('./worker.js', import.meta.url));
import.meta.url 是 ESM 模块里的标准属性,表示当前文件的绝对 URL。new URL(...) 会把它和相对路径拼成 Worker 文件在打包后的真实地址。
如果你用的是 CRA(Create React App),直接用
new Worker('./worker.js')就行,它内部做了 polyfill。
六、别忘了回收资源——terminate()
Worker 线程是系统资源,用完记得关:
workerRef.current.terminate(); // 关闭 Worker 线程
workerRef.current = null; // 断开引用,让 GC 回收
不关闭会怎样?
- Worker 线程继续占用内存
- 浏览器标签页关掉也未必释放
- 多开几个页面内存就飚上去了
良好习惯:每次计算结果回来就 terminate(),下次计算再 new。
七、完整数据流:一次计算到底经历了什么
用户点击按钮
↓
主线程:setLoading(true),按钮变灰
↓
postMessage({ num: 88 }) → Worker 线程收到任务
↓
Worker: 5亿次循环计算中……(主线程在这期间完全自由)
↓
Worker 计算完成:postMessage({ result: xxx })
↓
主线程:onmessage 回调触发
↓
setResult(result) ,setLoading(false),按钮恢复
↓
terminate() → 工人都给你干完活了,下班!
在这整个过程中,你的页面按钮没卡、输入框能打字、抽屉能拉、导航能点——因为主线程只干了"发消息"和"收消息"两件轻活。
八、还有哪些场景该用 Worker?
| 场景 | 举例 |
|---|---|
| 大数据计算 | 5亿次循环、大数组处理 |
| 文件解析 | CSV 转 JSON、Excel 数据提取 |
| 加密/解密 | AES、RSA 前端加密 |
| 图片处理 | Canvas 滤镜、图像压缩 |
| 数据导出 | 百万级数据的导出逻辑 |
一句话:凡是能让主线程"喘不上气"的纯计算,全扔 Worker。
九、总结
| 要点 | 说明 |
|---|---|
| useRef | 存 Worker 实例,跨渲染不丢失 |
| new Worker(URL) | Vite 下用 new URL('./worker.js', import.meta.url) |
| postMessage | 主线程 ↔ Worker 双向通信 |
| onmessage | 接收对方的回信 |
| terminate() | 干完活就关,别占着资源不拉屎 |
| 主线程只做 UI | 计算全交给 Worker,页面永远不卡 |
核心思想就一句:
把 CPU 密集型任务从主线程"搬走",还你的 React 页面一个丝滑体验。