前端页面一卡一卡的,十有八九是主线程被长任务占满了。Web Workers 让 JavaScript 能在后台线程并行计算,把解析、压缩、图像处理这类重活搬离主线程,从而保住页面的响应与流畅。本文用一组可运行示例讲清 Web Worker 的原理、线程间通信,以及生产环境常见的坑与选型。
一、为什么主线程会被卡住
JavaScript 在浏览器里是单线程执行的,所有脚本、事件回调、渲染调度都挤在同一个”主线程”上。一旦某段同步代码跑得太久(比如解析一个 20MB 的 JSON、给十万条数据做排序),主线程就被它独占,用户点击、滚动、动画全部要排队。浏览器把执行时间超过 50ms 的任务标记为”长任务(Long Task)”,而长任务正是卡顿的直接来源。
这也正是 浏览器渲染中的重排与重绘 一文里提到的:主线程忙着算,就没空去处理渲染流水线的下一步,画面就停住了。在 Core Web Vitals 性能指标体系 中,长任务过多会直接拉高 INP(Interaction to Next Paint),伤害用户体验与搜索排名。
二、Web Worker 到底是什么
Web Worker 是浏览器提供的一种”后台脚本”,它在独立于主线程的另一个线程里运行 JS。主线程和 Worker 之间通过 postMessage 发消息、用 onmessage 收消息,彼此不直接共享变量。正因为不共享作用域,Worker 里既不能访问 document,也不能操作 DOM——它只负责”算”,算完把结果发回来。
这种”算”与”显”的分离,让主线程始终轻装上阵:用户在 Worker 算数的同时还能正常滚动、输入。下表对比了主线程与 Worker 的能力边界。
| 能力 | 主线程 | Worker 线程 |
|---|---|---|
| 访问 DOM | ✅ | ❌ |
| fetch / WebSocket | ✅ | ✅ |
| 定时器(setTimeout 等) | ✅ | ✅ |
| OffscreenCanvas 绘图 | ✅ | ✅ |
| SharedArrayBuffer 共享内存 | ✅ | ✅(需 COOP/COEP 响应头) |
三、最小可用示例:创建与通信
最基础的用法分两段:主线程用 new Worker(url) 拉起一个 Worker 文件,两边各写一对 postMessage / onmessage。下面让 Worker 帮我们算一个累加和,主线程不阻塞。
// main.js —— 主线程
const worker = new Worker('./calc.worker.js');
worker.onmessage = (e) => {
console.log('Worker 算完了:', e.data); // 500000000500000000
};
worker.postMessage({ type: 'sum', n: 1000000000 });
// calc.worker.js —— Worker 线程
self.onmessage = (e) => {
const { n } = e.data;
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
self.postMessage(total);
};
注意 Worker 文件里的全局对象不是 window 而是 self。消息是”结构化克隆”后传过去的,两边拿到的是各自独立的副本,互不干扰。
三、传递数据:拷贝还是转移
默认情况下,postMessage 会对数据进行”结构化克隆”(深拷贝)。对于小对象这没问题;但当你要传一个几十 MB 的 ArrayBuffer 或 ImageBitmap 时,深拷贝的代价会很高,甚至可能比计算本身还慢。解决办法是 Transferable 对象:把数据的”所有权”直接转移给另一端,零拷贝。
// 方式一:默认结构化克隆(深拷贝,原数组仍可用)
const buf = new ArrayBuffer(1024);
worker.postMessage({ buf });
// 方式二:Transferable 转移(零拷贝,原 buf 在发送后归零不可用)
const big = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ big }, [big]); // 第二个参数是可转移列表
| 方式 | 机制 | 适合场景 | 开销 |
|---|---|---|---|
| structured clone(默认) | 深拷贝 | 小数据、需保留原值 | 随数据量线性增长 |
| Transferable | 所有权转移 | 大 TypedArray / ImageBitmap | 极低(零拷贝) |
四、实战:用 Worker 给图片做灰度处理
图像处理是 Worker 的经典落点:逐像素计算量很大,但结果只依赖像素数据,不需要碰 DOM。下面把”读文件→生成位图→转移给 Worker→灰度计算→转移回主线程”串成一条不阻塞的链路。
// main.js
const input = document.querySelector('#file');
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
const bitmap = await createImageBitmap(file); // 不阻塞主线程
const worker = new Worker('./grayscale.worker.js');
worker.onmessage = (ev) => {
drawToCanvas(ev.data.bitmap); // 把结果画到页面上
};
// 转移 bitmap 所有权,零拷贝交给 Worker
worker.postMessage({ bitmap }, [bitmap]);
});
// grayscale.worker.js
self.onmessage = (ev) => {
const { bitmap } = ev.data;
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const img = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const gray = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = gray; // 加权灰度
}
ctx.putImageData(img, 0, 0);
const out = canvas.transferToImageBitmap();
self.postMessage({ bitmap: out }, [out]); // 转移回主线程
};
关键点有两个:其一,Worker 里用 OffscreenCanvas 完成绘图,全程不涉及真实 DOM;其二,位图通过 Transferable 双向转移,避免了把整张图在主线程和 Worker 之间反复拷贝。最终用户选完图,页面依旧能流畅滚动。
五、用 Comlink 把 RPC 藏起来
原生 postMessage 是”发一条消息、收一条消息”的事件模型,业务一复杂就容易写成一堆 if (e.data.type === ...) 的分支。Comlink 这个库把消息通信包装成”像调用本地函数一样”的异步调用,背后仍是 Worker 通信。
// heavy.worker.js
import * as Comlink from 'comlink';
const api = {
fib(n) {
let a = 0, b = 1;
for (let i = 0; i < n; i++) [a, b] = [b, a + b];
return a;
},
};
Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';
const worker = new Worker('./heavy.worker.js');
const api = Comlink.wrap(worker);
// 表面像本地调用,实际是跨线程的异步 RPC
const result = await api.fib(100000);
console.log(result);
这样主线程代码读起来和同步调用无异,重计算却被悄悄挪到了后台线程。对于体量较大的前端工程,配合 前端资源懒加载 之类的按需加载策略,能把首屏之外的重活进一步推迟到真正需要时再算。
六、单 Worker 不够?上线程池
一个 Worker 内部仍然是单线程串行的:如果连续丢给它十个任务,它们会排队依次执行,整体吞吐并没有提升。要真正并发,就得开多个 Worker,用一个池子来调度。下面是一段极简的轮询(round-robin)池。
function createPool(size, url) {
const workers = Array.from({ length: size }, () => new Worker(url));
let i = 0;
return (data) => {
const w = workers[i++ % size];
return new Promise((resolve) => {
const handler = (e) => {
w.removeEventListener('message', handler);
resolve(e.data);
};
w.addEventListener('message', handler);
w.postMessage(data);
});
};
}
const run = createPool(4, './task.worker.js');
run(bigInput1).then((r) => console.log(r));
run(bigInput2).then((r) => console.log(r));
池的大小通常参考 navigator.hardwareConcurrency(逻辑 CPU 数),避免开太多线程反而引发调度开销。下面按场景给出选型建议。
| 场景 | 推荐方案 |
|---|---|
| 单次轻量计算 | 单个 Worker |
| 大量同类任务 | Worker 线程池(按 CPU 核数) |
| 多标签页共享状态 | SharedWorker(兼容性有限,谨慎使用) |
| 极重 CPU 任务 | WebAssembly + Worker |
七、常见坑与排障清单
Worker 上手不难,但生产环境有几个高频踩坑点。和 防抖与节流优化高频事件 一样,这类”看起来能跑、跑起来却不对”的问题,往往源自对机制细节的疏忽。
| 现象 | 原因 | 修复 |
|---|---|---|
| Worker 404 / 跨域 | 路径错误或服务端 MIME 不对 | 用构建工具的 worker 语法(如 Vite 的 new Worker(new URL(...), { type: 'module' }));检查服务端 MIME |
| 数据”没传过去” | 默认深拷贝,修改原引用无效 | 明确传 Transferable 列表 |
| 报错找不到堆栈 | Worker 异常不在主线程栈 | 用 DevTools 的 Sources / Threads 面板单独调试 Worker |
| 单 Worker 仍卡 | 串行执行,任务排队 | 改用线程池或多 Worker 并发 |
还有一个现代构建链上的细节:如果你用 ES Module 写 Worker,构造函数要带 { type: 'module' },否则 import 语法会直接报错;在 Vite、webpack 等工具里,推荐用 new Worker(new URL('./x.worker.js', import.meta.url), { type: 'module' }) 这种写法,工具会自动处理打包与路径。
八、选型建议与小结
判断一段逻辑要不要进 Worker,核心就看一句话:它会不会在主线程上跑超过几十毫秒?解析大 JSON、排序/聚合海量数据、图像/音视频处理、加密解密、复杂的图表布局计算——这些都属于”重计算”,都适合搬进 Worker。
落地时的优先级建议:先确认任务确实耗时长(用 Performance 面板录一段,看长任务出现在哪);再决定单 Worker 还是线程池;数据量大就一定要用 Transferable;工程化项目用 Comlink 降低心智负担。把重活搬离主线程,页面自然就丝滑了——这正是 Web Workers 给前端性能最实在的价值。




