Web Workers:浏览器的多线程
JS 是单线程,重计算会冻 UI。Web Worker 开一个后台线程跑 JS,不阻塞主线程。本文讲清 Web Worker 的原理(postMessage 通信)、适用场景(大计算/图像/加密/JSON 解析)、Shared Worker/Service Worker 的区别,以及 Comlink 让 Worker 更易用。
本文要点
- 主题聚焦:AI、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
写过前端的人多半都遇到过这一幕:页面上跑了个大计算(解析一个几十 MB 的 JSON、做图像处理、算加密),整个界面就冻住了——按钮点不动,滚动条卡死,动画掉帧。你打开 DevTools 的 Performance 一看,主线程一条长任务(Long Task)顶到底。
根因只有一个:JavaScript 在浏览器里默认是单线程的,而 UI 渲染和 JS 跑在同一个主线程上。两件事抢一个线程,谁先谁后就看时间片。
Web Worker 就是浏览器给我们的逃生通道——开一个真正的操作系统线程,在后台跑 JS,主线程继续做 UI。这篇文章把 Worker 的原理、用法、坑和工程化讲清楚。
一、JS 单线程的困境:为什么要 Worker
先说清楚问题。浏览器的渲染管线大致是这样的:主线程上一会儿跑 JS,一会儿跑 Style 计算、Layout、Paint,交替进行。一旦 JS 占着主线程不放(比如一个同步循环跑了几百毫秒),后面的渲染任务就排不上队,用户就感觉"卡"。
60 FPS 要求每一帧只有 16.6ms,其中 JS、布局、绘制要全部完成。一个跑 50ms 的任务,直接干掉 3 帧。
更狠的是,Chrome 把超过 50ms 的任务标记为 Long Task,直接影响 INP(交互响应性指标)。也就是说,重计算不仅让用户感觉卡,还会拖累你的 Core Web Vitals 分数,影响 SEO。
那为什么 JS 不干脆做成多线程?历史原因:DOM 是共享的,多线程同时操作 DOM 会引入巨大的并发复杂度(竞态、锁、死锁),浏览器厂商当年为了简单和稳定,选了单线程模型。这是个 trade-off,但代价是:所有重活必须让出主线程。
Web Worker 就是这个 trade-off 的解药。它给你一个独立线程,代价是:这个线程碰不到 DOM。
二、原理:独立线程 + postMessage 通信
Web Worker 跑在一个真正的操作系统线程里,有自己的事件循环、自己的全局对象(self 而不是 window)。它和主线程之间不共享内存(默认情况下),只能通过 postMessage 发消息。
通信模型是消息传递(Message Passing),不是共享状态。这点很重要:它从架构上杜绝了数据竞争,但代价是数据要在两个线程间"搬运"。
最小可用例子,主线程:
// main.ts
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (e) => {
console.log('结果:', e.data);
};
worker.postMessage({ input: bigArray });
Worker 文件:
// heavy.worker.ts
self.onmessage = (e) => {
const result = doExpensiveWork(e.data.input); // 在后台线程跑
self.postMessage(result);
};
function doExpensiveWork(input: number[]) {
// CPU 密集计算,这里随便跑多久,不阻塞 UI
return input.sort((a, b) => a - b);
}
几个工程上要注意的点:
new URL('./x.worker.ts', import.meta.url)这种写法是 bundler 友好的(Webpack/Vite/Next.js 都认),能正确分包 Worker 文件。别直接写字符串路径,会丢类型和打包。type: 'module'让 Worker 支持 ESM 和import,现代项目几乎必加。- Worker 抛异常不会自动冒到主线程,要监听
worker.onerror,否则错了你都不知道。 - TypeScript 里 Worker 的
self是DedicatedWorkerGlobalScope,类型推断有时要手动声明,self.onmessage的e类型才会对。
三、什么场景该用,什么场景不该用
这是最关键的判断。Worker 不是银弹,用错地方比不用更糟——通信本身有开销。
适合用 Worker 的场景(本质:CPU 密集,且耗时 >> 通信开销):
- 大数据计算:大数组排序、数值计算、复杂算法(路径规划、图算法)
- 大 JSON 解析:几十 MB 的 JSON,主线程
JSON.parse能卡几百毫秒,放 Worker 里 - 图像/视频处理:用 OffscreenCanvas 在 Worker 里跑 Canvas 绘制和像素操作(滤镜、压缩、识别)
- 加密/压缩:AES、SHA、gzip、zstd,这些库同步实现非常吃 CPU
- 实时数据流处理:股票行情、传感器数据的过滤/变换/聚合
- AI 推理:TensorFlow.js / ONNX Runtime Web 在浏览器跑模型推理,放 Worker 是标配
不适合用 Worker 的场景:
- DOM 操作:Worker 没有
document、没有window、没有localStorage,碰 DOM 直接报错。这是硬限制。 - 简单逻辑:加个和、筛个百条数据,跑 1ms 还要
postMessage来回传,通信开销比计算还大,得不偿失。经验值:任务跑不到 10ms,就别开 Worker。 - 纯 I/O 等待:
fetch网络请求本身是异步非阻塞的,主线程跑也等。Worker 不会让网络更快。 - 频繁小消息:
postMessage走的是结构化克隆,高频小消息会堆积开销。
一句话:判断标准是"计算密度",不是"看起来很复杂"。
四、Worker 的三种类型:Dedicated / Shared / Service
很多人不知道 Worker 其实有三种,各有用途,别混。
1. Dedicated Worker(专用 Worker)
就是上面那种 new Worker(...),和一个页面绑定,页面关了 Worker 就销毁。99% 的场景用这个就够了。
2. Shared Worker(共享 Worker)
new SharedWorker(...),可以被同源的多个浏览器标签页共享。适用场景:你开了 5 个标签页都是同一个应用,想共享一个 WebSocket 连接或缓存,用 Shared Worker 只开一份。
但 Shared Worker 的 API 更繁琐(port 机制),通信要手动 port.start(),而且浏览器对它的支持态度一直暧昧(Chrome 有计划调整,部分场景被 Service Worker 替代)。我的建议是:除非你真的有跨标签页共享状态的需求,否则别用。多数"多标签共享"用 BroadcastChannel + 主线程就能解决。
3. Service Worker
这是另一个物种,虽然名字里有 "Worker",但定位完全不同:它是 PWA 的离线缓存 + Push 通知 + 后台同步的代理层。它拦截网络请求、管理 Cache Storage,跑在独立生命周期里,没有 DOM,但能被浏览器在后台唤醒/休眠。
Service Worker 不是用来跑计算的,它是用来做"离线 Web App"的。如果你想做 PWA、离线访问、Web Push,这是你的工具;如果你想做并行计算,用 Service Worker 是用错了地方,会被它的生命周期和启动延迟折磨死。
记住:Dedicated Worker 干计算,Service Worker 干离线,Shared Worker 干跨标签共享。
五、通信性能:Transferable 和 SharedArrayBuffer
默认 postMessage 的数据传输用的是结构化克隆(Structured Clone)——主线程把对象复制一份给 Worker。小数据无所谓,但传一个 100MB 的 ArrayBuffer,克隆就要 100MB 内存 + 几十毫秒时间,而且两边各占一份,内存翻倍。
两个优化手段,工程上必须知道。
1. Transferable Objects(可转移对象)
ArrayBuffer、MessagePort、OffscreenCanvas 等对象支持"转移所有权"。转移是零拷贝,且原线程的对象会变成不可用(neutered):
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50MB
// 普通传:克隆,慢,内存翻倍
worker.postMessage(buffer);
// 转移:零拷贝,主线程的 buffer 之后不可用
worker.postMessage(buffer, [buffer]);
第二个参数是 transfer list。适合"一次性大块数据搬走"的场景,如图像处理——主线程把像素数据丢给 Worker,Worker 处理完再转回来。注意:转移后原变量就像被"没收"了,再读会拿到 0 长度的 ArrayBuffer,这是个常见的坑。
2. SharedArrayBuffer(共享内存)
更激进的方式:两个线程直接共享同一块内存,完全不拷贝。但要付出两个代价:
- 必须用
AtomicsAPI 做同步,否则有数据竞争(Worker 也能用Atomics.wait/notify做条件变量)。 - 页面必须设置 COOP/COEP 安全头(
Cross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corp),否则浏览器拒绝启用。这俩头会影响第三方 iframe 和资源的加载策略,上线前必须评估。
适合真正高频、大数据的实时处理,比如视频流逐帧分析。普通业务用 Transferable 已经够了,SharedArrayBuffer 是高级武器,别滥用。
六、Comlink:把 Worker 写成 RPC
原生 postMessage 用多了会烦:每次要定义消息类型、写 onmessage、维护请求/响应映射。对于"调用一个函数"这种 RPC 式的需求,样板代码又臭又长。
Google Chrome 团队出的 Comlink 解决的就是这个:把 Worker 通信包装成普通的 async function 调用。
Worker 端:
// calc.worker.ts
import * as Comlink from 'comlink';
const api = {
async sort(data: number[]) {
return data.sort((a, b) => a - b);
},
async heavyCompute(n: number) {
// 假装很重
let s = 0;
for (let i = 0; i < n; i++) s += i;
return s;
},
};
Comlink.expose(api);
主线程:
// main.ts
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./calc.worker.ts', import.meta.url), {
type: 'module',
});
const calc = Comlink.wrap<typeof import('./calc.worker.ts')>(worker);
// 像调用本地 async 函数一样调用 Worker 里的方法
const sorted = await calc.sort(bigArray);
const result = await calc.heavyCompute(1_000_000);
注意 Comlink.wrap<typeof import('./calc.worker.ts')> 这一行——类型从 Worker 文件直接推导,主线程拿到的是带类型的 async API,调用体验和本地函数一模一样,类型校验也完整。
Comlink 的几个亮点:
- API 即接口:Worker 里
expose什么,主线程就能调什么,边界清晰。 - 回调也能传:
Comlink.proxy(fn)支持把函数代理过去,双向回调也行。 - 支持 Transferable:
Comlink.transfer(value, [buffer)显式声明转移。 - 类型安全:配合 TS 的
typeof import,几乎零样板。
我的工程建议是:只要你的 Worker 通信不是高频流式数据(那种还是裸 postMessage),一律上 Comlink。可读性、可维护性、可测试性都好得多,新人看一眼就懂。
七、WASM + Worker:性能的终极组合
如果 Worker 解决了"不阻塞 UI",WASM 解决的就是"算得够快"。WASM(WebAssembly)让你在浏览器跑接近原生速度的字节码,C++/Rust/Go 编译过来的计算密集代码,常常比纯 JS 快几倍到几十倍。
但 WASM 默认还是在调它的那个线程上跑。把 WASM 实例放进 Worker,你就同时拿到两件事:原生级计算速度 + 不冻 UI。
典型架构:
// wasm.worker.ts
import * as Comlink from 'comlink';
import init, { process_image } from '../pkg/image_proc'; // rust wasm-pack 产物
let ready: Promise<void>;
const api = {
async init() {
ready = init(); // 加载 .wasm
await ready;
},
async process(buffer: ArrayBuffer) {
await ready;
return process_image(buffer); // 原生速度的图像处理
},
};
Comlink.expose(api);
主线程:
const worker = new Worker(new URL('./wasm.worker.ts', import.meta.url), {
type: 'module',
});
const img = Comlink.wrap<typeof import('./wasm.worker.ts')>(worker);
await img.init();
const result = await img.process(bigImageBuffer);
这就是浏览器里能拿到的最高性能形态:Rust/C++ 编译成 WASM 做计算,Worker 隔离线程,Comlink 做通信,Transferable 做零拷贝。图像识别、视频编解码、复杂模拟、端侧 AI 推理——这些"过去只能在服务器干"的活,现在前端也能扛下来。
小结
- JS 单线程是渲染卡顿的根因。重计算顶住主线程,UI 就冻、INP 就崩。Worker 给你一个真正的后台线程,代价是它碰不到 DOM。
- 判断标准是"计算密度":任务跑超过 10ms 的 CPU 密集活才值得开 Worker。简单逻辑、纯 I/O、DOM 操作都不适合——通信开销可能比收益还大。
- 三种 Worker 各司其职:Dedicated 干计算、Service 干离线/PWA、Shared 干跨标签共享。99% 的场景用 Dedicated 就够。
- 大数据用 Transferable 零拷贝转移,实时高频用 SharedArrayBuffer(要 COOP/COEP)。默认的结构化克隆对大 ArrayBuffer 是性能黑洞。
- Comlink 把 Worker 写成 RPC,TypeScript 类型自动推导,样板代码清零。只要不是高频流式通信,就上 Comlink。终极形态是 WASM + Worker + Comlink + Transferable。
一句话收尾:主线程只负责让它流畅,重活丢到 Worker——用户感受不到你后台有多努力,只觉得这个页面真顺手。 这就是 Web Workers 存在的意义。
Related