#AI#TypeScript

Web Workers:浏览器的多线程

JS 是单线程,重计算会冻 UI。Web Worker 开一个后台线程跑 JS,不阻塞主线程。本文讲清 Web Worker 的原理(postMessage 通信)、适用场景(大计算/图像/加密/JSON 解析)、Shared Worker/Service Worker 的区别,以及 Comlink 让 Worker 更易用。

本文要点

  • 主题聚焦:AI、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月5日·更新于 2026年7月23日

写过前端的人多半都遇到过这一幕:页面上跑了个大计算(解析一个几十 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),不是共享状态。这点很重要:它从架构上杜绝了数据竞争,但代价是数据要在两个线程间"搬运"。

最小可用例子,主线程:

ts
// 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 文件:

ts
// 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):

ts
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50MB

// 普通传:克隆,慢,内存翻倍
worker.postMessage(buffer);

// 转移:零拷贝,主线程的 buffer 之后不可用
worker.postMessage(buffer, [buffer]);

第二个参数是 transfer list。适合"一次性大块数据搬走"的场景,如图像处理——主线程把像素数据丢给 Worker,Worker 处理完再转回来。注意:转移后原变量就像被"没收"了,再读会拿到 0 长度的 ArrayBuffer,这是个常见的坑。

2. SharedArrayBuffer(共享内存)

更激进的方式:两个线程直接共享同一块内存,完全不拷贝。但要付出两个代价:

  • 必须用 Atomics API 做同步,否则有数据竞争(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 端:

ts
// 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);

主线程:

ts
// 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。

典型架构:

ts
// 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);

主线程:

ts
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

相关推荐