Skip to main content

Web Worker

面试一句话总结:Web Worker 是浏览器提供的真·多线程机制——它在底层开辟独立的系统线程跑 Worker 脚本,拥有自己的 V8 实例和全局上下文(DedicatedWorkerGlobalScope)。但 JS 语言本身依然是单线程的,Worker 只解决了"CPU 密集型任务阻塞主线程"这一个特定问题。

1. 基础概念

① 三个最常被混淆的角色

面试官最爱问的"分类题":浏览器提供了三种 Worker,作用域完全不同:

类型作用范围典型场景
Dedicated Worker1 个页面 1 个实例CPU 密集型计算(解析 Excel、图像处理)
Shared Worker同源多个 Tab/iframe 共享跨 Tab 通信、统一登录态
Service Worker代理网络请求(独立于页面)离线缓存、Push 推送、PWA 核心

防混淆:Dedicated = "专属的"(专属于当前页面);Shared = "共享的"(同源页面之间共享);Service = "服务"(独立于页面,充当网络代理)。

② 为什么要用 Worker?

JS 是单线程的,CPU 密集型任务(解析 50MB Excel、加密、Sobel 边缘检测)会阻塞主线程,导致页面卡死、点击无响应、动画掉帧。

Worker 把这些任务扔到独立线程执行,主线程只负责 UI 和事件,二者通过消息通信。


2. 基础使用

① 最简 Dedicated Worker

主线程(main.js):

const worker = new Worker("./worker.js");

worker.postMessage({ cmd: "parse", file: "data.csv" });
worker.onmessage = (e) => console.log("结果:", e.data);

Worker 线程(worker.js):

// 注意:全局对象是 self,不是 window
self.onmessage = (e) => {
const { cmd, file } = e.data;
if (cmd === "parse") {
const result = heavyParse(file); // CPU 密集型
self.postMessage(result);
}
};

② Module Worker(推荐写法)

传统 Worker 默认是脚本模式(classic),全局污染严重。type: 'module' 可以让 Worker 支持 ES Module:

// 好处:可以使用 import/export、严格模式、Top-level await
const worker = new Worker("./worker.js", { type: "module" });
// worker.js
import { parseExcel } from "./libs/parser.js"; // 复用主线程代码
export default {}; // 即使什么都不导出,也建议写 module

面试加分项:传统 Worker 用 importScripts() 加载依赖(同步、阻塞 Worker 启动);Module Worker 用 import(异步、符合 ESM 标准)。生产环境优先 Module Worker

③ 生命周期

const worker = new Worker("./worker.js");

// 主线程主动终止:立即销毁,内存立刻释放
worker.terminate();

// Worker 内部主动关闭 (不能再被复用)
self.close();

常见误区:terminate() 不会等待正在执行的任务完成,也不会触发 Worker 的 onmessage 回调。如果有文件句柄、IndexedDB 事务需要清理,应在 Worker 内部监听 beforeunload 或自己实现"优雅退出"协议(发送 ack 消息后再关闭)。


3. 通信机制(核心)

Web Worker 通信是面试最常被深挖的部分,必须区分清楚三种机制。

① postMessage 默认:结构化克隆(深拷贝)

// 主线程
const data = { list: new Array(1000000).fill(0) };
worker.postMessage(data);
// → 浏览器会递归序列化整个对象,在另一个线程反序列化还原
// → 100MB 数据光拷贝就要几百 ms,主线程会卡顿

底层原理:浏览器使用 HTML5 结构化克隆算法,类似 JSON.stringify 但支持更多类型(Date、Map、Set、ArrayBuffer、File、ImageBitmap 等)。

② Transferable Objects:零拷贝(推荐大文件)

ArrayBuffer所有权直接转移给 Worker,主线程立刻失去访问权:

// 主线程
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
worker.postMessage(buffer, [buffer]); // 第二个参数:可转移对象列表

console.log(buffer.byteLength); // → 0!已被转移

适用类型:ArrayBufferMessagePortImageBitmapOffscreenCanvasReadableStreamWritableStreamTransformStream

核心优势:不复制内存,只转移所有权。100MB 数据传递从 200ms 降到 < 1ms,是处理大文件的必备技能。

③ SharedArrayBuffer:共享内存(最强大但最危险)

让主线程和 Worker 共用同一块物理内存,无需拷贝、无需转移:

// 主线程
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
Atomics.store(view, 0, 42); // 原子写入

worker.postMessage(shared);

console.log(Atomics.load(view, 0)); // → 42,主线程依然能访问

必须配套开启跨源隔离(服务端响应头):

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

为什么需要隔离头? Spectre 漏洞可让恶意脚本通过 SharedArrayBuffer 读取任意内存。COOP/COEP 阻止跨源页面共存,从根本上切断侧信道攻击路径。2018 年 Chrome 临时下线 SharedArrayBuffer,2020 年加这两个头后才重新开放。

④ 通信模式选型决策树

需要传大文件 (ArrayBuffer)?
├─ 是 → Worker 独占处理 → 用 Transferable
└─ 否 → 需要主线程和 Worker 共同读写?
├─ 是 → SharedArrayBuffer + Atomics (注意隔离头)
└─ 否 → 普通 postMessage (结构化克隆即可)

4. 内存占用与控制

关键认知:Web Worker 是真线程,每个实例独立占用一份 V8 堆内存。Chrome 中单个 Worker 默认可用内存约为主进程的 60%~80%(实测通常 1~1.4GB),但启动成本昂贵(冷启动 10~50ms),过度创建会适得其反。

① 监控内存

// ❌ Worker 内无 window,无法用 performance.memory
// ✅ 只能在主线程侧间接观察整体
console.log(performance.memory.usedJSHeapSize / 1024 / 1024 + "MB");

② 释放策略

场景推荐做法
任务完成后worker.terminate() 立即销毁整线程(最快)
长任务持有大对象任务结束将变量置 null,触发 GC
高频短任务Worker Pool 复用,避免反复创建

③ Worker Pool 模式

class WorkerPool {
constructor(script, size = navigator.hardwareConcurrency) {
this.workers = Array.from({ length: size }, () => new Worker(script));
this.idle = [...this.workers];
}

exec(data) {
return new Promise((resolve) => {
const worker = this.idle.pop() || this.workers[0]; // 忙碌则排队
const handler = (e) => {
worker.removeEventListener("message", handler);
this.idle.push(worker); // 回收
resolve(e.data);
};
worker.addEventListener("message", handler);
worker.postMessage(data);
});
}
}

// 最佳实践:size 取 CPU 核心数,避免线程切换开销
const pool = new WorkerPool("./worker.js", navigator.hardwareConcurrency);

面试加分:navigator.hardwareConcurrency 返回 CPU 物理核心数,是设置 Pool 大小的黄金标准。


5. 异常处理

关键坑点:Worker 内部抛出的错误默认不会冒泡到主线程,只在 DevTools 控制台打印。如果不显式回传,主线程永远不知道 Worker 挂了——线上表现为"页面静默卡死"。

① 主线程侧:监听两个事件

worker.onerror = (e) => {
console.error("Worker 崩溃:", e.message, e.lineno, e.colno);
// 关键:触发 onerror 后,Worker 已被浏览器标记为"错误状态",无法继续使用,必须重建
worker.terminate();
};

worker.onmessageerror = (e) => {
console.error("消息反序列化失败:", e);
};

② Worker 内部:三层防护

// ① 同步代码: try/catch + 手动回传
self.onmessage = (e) => {
try {
const result = heavyCompute(e.data);
self.postMessage({ ok: true, data: result });
} catch (err) {
self.postMessage({ ok: false, error: err.message });
}
};

// ② Promise 异步错误: 全局兜底
self.addEventListener("unhandledrejection", (e) => {
self.postMessage({ ok: false, error: e.reason?.message || String(e.reason) });
});

// ③ Worker 自身抛出的未捕获错误
self.onerror = (msg, url, line, col, err) => {
self.postMessage({ ok: false, error: msg, line, col });
return true; // 阻止默认控制台输出
};

实战约定:在协议层约定统一的 { ok, data, error } 消息体,业务层只判断 result.ok,不直接依赖 try/catch,实现跨线程的可靠通信。


6. 经典面试题

① Web Worker 是不是意味着 JS 变成了多线程?

:不是。JS 语言本身(以及 V8 等引擎)依然是单线程的。Web Worker 是浏览器宿主环境提供的并发机制——浏览器在底层开辟独立的系统线程运行 Worker 脚本,但每个 Worker 内部依然是单线程的 JS 执行环境。多个 Worker 之间也无法直接共享数据,必须通过消息通信。

② postMessage 传 100MB 数据为什么会卡?怎么优化?

:默认走"结构化克隆"(深拷贝),100MB 序列化 + 跨线程反序列化要几百 ms。 三档优化:

  • Transferable Objects(postMessage(buf, [buf])):转移所有权,零拷贝,速度提升 100x+。代价是主线程失去访问权。
  • SharedArrayBuffer(需 COOP/COEP 隔离头):共享内存,两端同时读写,配合 Atomics 实现锁与同步。适合持续读写的大数据。
  • OffscreenCanvas:把 Canvas 画图本身搬到 Worker,渲染时连主线程都不参与(适用于 WebGL 高性能渲染场景)。

③ Worker 报错后还能继续用吗?

:不能worker.onerror 触发后,浏览器已将该 Worker 标记为"错误状态",继续 postMessage 会被静默丢弃。必须 terminate() 后重新 new Worker()。预防策略:

  • Worker 内部用 try/catch 包裹业务逻辑
  • 协议层约定统一错误消息体
  • 主线程收到错误后自动重建 Worker

④ 如何实现 Worker 之间的通信?

:直接通信不行(Worker 之间没有 postMessage 接口)。只能通过"中介":

  • 主线程中转:Worker A → 主线程 → Worker B(最常用)
  • SharedWorker:多个 Dedicated Worker 都向同一个 SharedWorker 发消息,由它统一派发
  • BroadcastChannel:同源所有上下文(主线程、Worker、SharedWorker)都能订阅同一个 channel,发布的消息全员接收
  • SharedArrayBuffer + Atomics:直接读写共享内存(最低延迟,但有竞态风险)

⑤ Worker 加载第三方脚本要注意什么?

:三大坑点:

  1. 同源限制:Worker 脚本必须与主线程同源。跨源脚本会被 NetworkError 拒绝加载,即使加了 CORS 也不行(除非用 <script> 间接注入)。
  2. 跨源脚本加载:可以用 importScripts('https://cdn.xxx.com/lib.js')(传统 Worker),但该 CDN 必须返回 CORS 头(Access-Control-Allow-Origin)。
  3. Module Worker 的特殊性:type: 'module' 的 Worker 使用动态 import,受 ES Module 跨源加载规则约束,比 importScripts 更严格。

最佳实践:把第三方库本地化(pnpm 装到项目里),用 Vite/Webpack 打包成单独 chunk,再用 import 加载——既能享受 ESM 优势,又规避跨源坑。