Web Worker
面试一句话总结:Web Worker 是浏览器提供的真·多线程机制——它在底层开辟独立的系统线程跑 Worker 脚本,拥有自己的 V8 实例和全局上下文(
DedicatedWorkerGlobalScope)。但 JS 语言本身依然是单线程的,Worker 只解决了"CPU 密集型任务阻塞主线程"这一个特定问题。
1. 基础概念
① 三个最常被混淆的角色
面试官最爱问的"分类题":浏览器提供了三种 Worker,作用域完全不同:
| 类型 | 作用范围 | 典型场景 |
|---|---|---|
| Dedicated Worker | 1 个页面 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!已被转移
适用类型:ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas、ReadableStream、WritableStream、TransformStream。
核心优势:不复制内存,只转移所有权。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 加载第三方脚本要注意什么?
答:三大坑点:
- 同源限制:Worker 脚本必须与主线程同源。跨源脚本会被
NetworkError拒绝加载,即使加了 CORS 也不行(除非用<script>间接注入)。 - 跨源脚本加载:可以用
importScripts('https://cdn.xxx.com/lib.js')(传统 Worker),但该 CDN 必须返回 CORS 头(Access-Control-Allow-Origin)。 - Module Worker 的特殊性:
type: 'module'的 Worker 使用动态 import,受 ES Module 跨源加载规则约束,比importScripts更严格。
最佳实践:把第三方库本地化(
pnpm装到项目里),用 Vite/Webpack 打包成单独 chunk,再用import加载——既能享受 ESM 优势,又规避跨源坑。