
一句话:Worker 的价值是「把 CPU 密集的工作从主线程搬走」——它直接改善 INP(交互延迟) 与「动画流畅度」;真正的工程难点是「通信成本(结构化克隆)、生命周期(池化复用)、以及构建打包」这三件事。
一、为什么需要它:主线程是「单线程 + 什么都要干」
⭐⭐⭐ 主线程要干的任务(全挤在一个线程里):
· 解析 HTML / 执行 JS
· 样式计算 / 布局 / 绘制(⭐ 「渲染」也在这里触发)
· 处理用户输入(点击、键盘)→ ⭐ **这决定了 INP**
· 跑 React 的渲染(diff、effect)
· 以及「你自己写的所有计算」
⚠️ 后果:任何一次「超过 50ms 的同步计算」都会:
· ⭐ **让交互变慢**(INP 变差——用户点了没反应)
· 让动画掉帧(见 5.17)
· 让「输入框输入卡顿」(每个字符都要等计算完)
⭐ 典型「该搬去 Worker」的场景:
· 解析大 JSON/CSV(几 MB) · 图片处理(缩放、滤镜、格式转换)
· 加密/哈希(大文件) · 复杂搜索/排序/聚合(几万条数据)
· 语法高亮(大文件) · 生成 PDF/图表(计算密集)
· 数据压缩/解压 · 音频/视频处理(可用 OffscreenCanvas)
⭐ 判据:「**这段代码会不会「一次跑超过 16ms」?**」——会,就该考虑搬走。
二、四种「后台执行体」:选哪个
| 类型 | 实例粒度 | 用途 | 注意 |
|---|---|---|---|
| ⭐ Dedicated Worker | 「每个创建者一个」 | ⭐ CPU 密集计算(最常用) | 同一页面可创建多个(⭐ 用池化控制) |
| SharedWorker | ⭐ 多标签页共享一个 | 「多标签页共享一份状态/连接」 | ⚠️ Safari 支持与行为有差异;调试较麻烦 |
| Service Worker | 每个源一个 | 网络代理、离线、推送 | ⭐ 见 10.11(不是用来做计算的) |
| Worklet | 每帧/专用 | 音频(AudioWorklet)、绘制(PaintWorklet)、布局(LayoutWorklet) | ⭐ 极低延迟场景;⚠️ API 受限 |
| ⭐ OffscreenCanvas | 可转移到 Worker | ⭐ 在 Worker 里渲染 canvas | 需 transferControlToOffscreen() |
⭐⭐⭐ 三个「选型」要点:
① 【Dedicated Worker 是默认选择】
只要「把一段计算搬走」→ 用 Dedicated Worker。
⚠️ 不要为了「共享」而用 SharedWorker(它的兼容/调试成本通常不值得)。
② 【Service Worker ≠ 计算线程】⭐
⚠️ 常见误解:「用 SW 做后台计算」——
SW 会被「浏览器随时终止」(⭐ 无请求时会被杀),不适合「长时间计算」;
而且它在「自己的线程」上,与页面通信要经 `postMessage`(更绕)。
✅ SW 做「网络/缓存」;计算用 Dedicated Worker。
③ 【OffscreenCanvas 是「渲染」搬走的手段】
`const offscreen = canvas.transferControlToOffscreen();`
`worker.postMessage({ canvas: offscreen }, [offscreen]);`
⭐ 于是「绘制」也在 Worker 线程(⭐ 连「canvas 的绘制循环」都不占用主线程)
⚠️ 兼容性:Chrome/Edge/Firefox 支持,Safari 支持较晚(⭐ 需要降级到主线程绘制)
三、通信:三种方式与它们的成本
⭐⭐⭐ Worker 通信的三种方式(⭐ 成本差异巨大):
① 【结构化克隆(默认)】—— ⭐ 「复制」
postMessage(data)
· 支持:普通对象/数组、Date、Map、Set、RegExp、ArrayBuffer、Blob、File、ImageData…
· ❌ 不支持:函数、DOM 节点、Proxy、类原型(⚠️ 类实例会「变成普通对象」)
· ⚠️ **成本与「数据大小」成正比**(几 MB 的对象复制会有明显开销)
⭐ 适用:小数据、低频
② 【Transferable(转移所有权)】—— ⭐⭐ 「零拷贝」
postMessage(data, [arrayBuffer])
· 支持:**ArrayBuffer**、**MessagePort**、**OffscreenCanvas**、ImageBitmap、ReadableStream…
· ⭐ **所有权转移,零拷贝**(大数组的性能关键)
· ⚠️⚠️ 转移后「原线程里的对象会被 detach」(⚠️ 再访问会报错/长度为 0)
⭐ 适用:**大二进制数据**(图片像素、大文件内容)
③ 【SharedArrayBuffer + Atomics】—— ⭐⭐⭐ 「真共享内存」
· 多线程「读写同一块内存」→ 「零拷贝」且「可双向实时访问」
· ⚠️ **需要「跨源隔离」**:响应头 `Cross-Origin-Opener-Policy: same-origin` +
`Cross-Origin-Embedder-Policy: require-corp`(⭐ 这会影响「第三方资源加载」,要评估)
· `Atomics.wait()` ⚠️ **不能在主线程调用**(会阻塞 UI);`Atomics.waitAsync` 可用于主线程
⭐ 适用:「高频、低延迟」的共享(如「音频处理」「实时传感器数据」)
⚠️ 但注意:「无锁编程」的复杂度很高(⭐ 大多数场景用不上)
/** ⭐⭐ 一张「怎么选」的对照表 */
const GUIDANCE = `
数据大小与频率 → 选通信方式:
· 小数据(< 几十 KB)+ 低频 → 结构化克隆(最简单)
· 大数据(MB 级二进制) → Transferable(⭐ 必选,否则「复制」开销极大)
· 高频 + 需要「双方实时共享」 → SharedArrayBuffer(⚠️ 需要 COOP/COEP + 复杂)
⚠️ 三个「反直觉」的点:
① 「结构化克隆」不是「免费的」—— MB 级对象复制可能要几十毫秒(⭐ 等于「把慢搬到了另一处」)
② 「Transferable 之后原对象不可用」—— ⚠️ 常见 bug:「转移后又去读它」
③ 「消息条数」也有成本——⚠️ 「每帧发 1000 条小消息」比「每帧发 1 条大消息」更慢
`;
四、完整实现:一个 Worker RPC 层(250 行)
// worker-rpc.ts —— 把 postMessage 封装成「Promise + 超时 + 可中止 + 池化」
/**
* ⭐⭐ 目标:让「调用 Worker」像「调用异步函数」一样自然,
* 并且把「id 匹配、超时、错误传递、中止、池化」都封在内部。
*/
/** ============ 1. 协议(Worker 双方遵守) ============ */
interface RpcRequest {
__rpc: 'req';
id: number;
method: string;
payload: unknown;
/** ⭐ 需要转移的 ArrayBuffer 列表(零拷贝) */
transfer?: ArrayBuffer[];
}
interface RpcResponse {
__rpc: 'res';
id: number;
ok: boolean;
result?: unknown;
error?: { message: string; stack?: string };
transfer?: ArrayBuffer[];
}
/** ============ 2. 主线程侧:Worker 池 + RPC 客户端 ============ */
export interface WorkerPoolOptions {
/** ⭐ 创建 Worker 的工厂(便于打包器处理,见第五节) */
createWorker: () => Worker;
/** ⭐ 池大小(默认「核数 - 1」,至少 1) */
size?: number;
/** ⭐ 单个任务超时(ms) */
timeoutMs?: number;
/** Worker 崩溃时的处理 */
onWorkerError?: (err: ErrorEvent | Error, workerIndex: number) => void;
}
interface PendingCall {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout> | null;
method: string;
/** ⭐ 「可中止」:中止时「拒绝 Promise」并「把 worker 标记为脏」(因为它的计算还在跑) */
aborted: boolean;
}
export function createWorkerPool(options: WorkerPoolOptions) {
const {
createWorker,
size = Math.max(1, (navigator.hardwareConcurrency ?? 4) - 1),
timeoutMs = 30_000,
onWorkerError,
} = options;
interface Slot {
worker: Worker;
/** ⭐ 正在跑的任务(⭐ 一个 worker 同时只跑一个,所以「消息顺序」天然安全) */
current: { id: number; pending: PendingCall } | null;
/** ⭐ 是否「脏」(任务被中止/超时后,worker 里可能还在算 → 不接新任务) */
dirty: boolean;
}
const slots: Slot[] = [];
const queue: Array<{ method: string; payload: unknown; pending: PendingCall; transfer?: ArrayBuffer[] }> = [];
let nextId = 1;
/** ⭐ 惰性创建(⭐ 避免「一进页面就起 7 个 worker」) */
function ensureSlot(): Slot {
const idle = slots.find((s) => !s.current && !s.dirty);
if (idle) return idle;
if (slots.length < size) {
const worker = createWorker();
const slot: Slot = { worker, current: null, dirty: false };
worker.addEventListener('message', (e: MessageEvent<RpcResponse>) => onMessage(slot, e.data));
worker.addEventListener('error', (e) => {
onWorkerError?.(e, slots.indexOf(slot));
// ⭐ Worker 崩溃 → 拒绝当前任务并「重建 worker」
if (slot.current) {
const { pending } = slot.current;
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error(`Worker 崩溃:${e.message}`));
slot.current = null;
}
recycle(slot);
});
slots.push(slot);
return slot;
}
// ⭐ 池子满了 → 复用「最早创建的」(⭐ 调用方应通过「队列」等空闲)
return slots[0]!;
}
function onMessage(slot: Slot, data: RpcResponse) {
if (data?.__rpc !== 'res') return;
const cur = slot.current;
if (!cur || cur.id !== data.id) return; // ⚠️ 不是当前任务的响应(忽略)
if (cur.pending.timer) clearTimeout(cur.pending.timer);
slot.current = null;
if (data.ok) cur.pending.resolve(data.result);
else {
const err = new Error(data.error?.message ?? 'Worker 执行失败');
err.stack = data.error?.stack ?? err.stack;
cur.pending.reject(err);
}
// ⭐ 完成后「立刻取下一个排队任务」(⭐ 保持 worker 不空闲)
void dispatch();
}
/** ⭐⭐ 调度:把队列里的任务分配给空闲 slot */
async function dispatch() {
while (queue.length > 0) {
const slot = slots.find((s) => !s.current && !s.dirty);
if (!slot) break; // 没有空闲 → 等
const task = queue.shift()!;
runOnSlot(slot, task);
}
}
function runOnSlot(
slot: Slot,
task: { method: string; payload: unknown; pending: PendingCall; transfer?: ArrayBuffer[] }
) {
const id = nextId++;
slot.current = { id, pending: task.pending };
// ⭐ 超时(⭐ 超时后「worker 标记为脏」—— 因为它的计算还在跑,
// 如果立刻复用,下一条消息可能「与旧计算交错」产生诡异问题)
task.pending.timer = setTimeout(() => {
if (slot.current?.id !== id) return;
slot.current = null;
slot.dirty = true;
task.pending.reject(new Error(`Worker 任务超时(${task.method})`));
// ⭐ 重建 worker(⭐ 因为「脏 worker 里的计算无法取消」)
recycle(slot);
void dispatch();
}, timeoutMs);
const req: RpcRequest = { __rpc: 'req', id, method: task.method, payload: task.payload };
// ⭐⭐ Transferable:把「需要零拷贝的对象」列出来(⭐ 大数组的关键)
slot.worker.postMessage(req, task.transfer ?? []);
}
/** ⭐ 回收:终止并替换一个 worker */
function recycle(slot: Slot) {
try { slot.worker.terminate(); } catch { /* 忽略 */ }
const idx = slots.indexOf(slot);
if (idx >= 0) slots.splice(idx, 1);
// ⭐ 若「还有队列」→ 补一个新的(否则「延迟创建」)
if (queue.length > 0) {
const fresh = ensureSlot();
void fresh;
}
}
/** ⭐⭐ 对外 API:像一个「异步函数」一样调用 */
function call<T>(method: string, payload?: unknown, opts: { transfer?: ArrayBuffer[]; signal?: AbortSignal } = {}): Promise<T> {
return new Promise<T>((resolve, reject) => {
const pending: PendingCall = {
resolve: resolve as (v: unknown) => void,
reject,
timer: null,
method,
aborted: false,
};
// ⭐ 「可中止」(⭐ 与 fetch 的 AbortSignal 同一套语义)
opts.signal?.addEventListener('abort', () => {
pending.aborted = true;
pending.reject(new DOMException('已中止', 'AbortError'));
}, { once: true });
queue.push({ method, payload, pending, transfer: opts.transfer });
void dispatch();
});
}
return {
call,
/** ⭐ 池子状态(调试/监控用) */
stats: () => ({ size: slots.length, busy: slots.filter((s) => s.current).length, queued: queue.length }),
/** ⭐ 关闭(组件卸载/页面卸载时) */
dispose: () => {
slots.forEach((s) => { try { s.worker.terminate(); } catch { /* */ } });
slots.length = 0;
queue.length = 0;
},
};
}
/** ============ 3. Worker 侧:一个「方法注册表」 ============ */
export interface WorkerHandlers {
[method: string]: (payload: unknown) => unknown | Promise<unknown>;
}
export function serveWorker(handlers: WorkerHandlers) {
// ⭐ Worker 里可以用 `self.onmessage`(它自己就是「全局作用域」)
self.addEventListener('message', async (event: MessageEvent<RpcRequest>) => {
const req = event.data;
if (req?.__rpc !== 'req') return;
const handler = handlers[req.method];
if (!handler) {
const res: RpcResponse = { __rpc: 'res', id: req.id, ok: false, error: { message: `未知方法:${req.method}` } };
self.postMessage(res);
return;
}
try {
const result = await handler(req.payload);
// ⭐⭐ 「返回值里的大二进制」要「显式列出 transferables」才能零拷贝回传
const transfer = collectTransferables(result);
const res: RpcResponse = { __rpc: 'res', id: req.id, ok: true, result, transfer };
self.postMessage(res, transfer);
} catch (err) {
const e = err as Error;
const res: RpcResponse = {
__rpc: 'res', id: req.id, ok: false,
error: { message: e?.message ?? String(err), stack: e?.stack },
};
self.postMessage(res);
}
});
}
/** ⭐ 找出「可以转移」的对象(ArrayBuffer / TypedArray 的 buffer) */
function collectTransferables(value: unknown, out: ArrayBuffer[] = [], depth = 0): ArrayBuffer[] {
if (depth > 4 || value === null || typeof value !== 'object') return out;
if (value instanceof ArrayBuffer) { out.push(value); return out; }
if (ArrayBuffer.isView(value)) { out.push(value.buffer as ArrayBuffer); return out; }
if (value instanceof ImageBitmap || (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
out.push(value as unknown as ArrayBuffer);
return out;
}
if (Array.isArray(value)) { value.forEach((v) => collectTransferables(v, out, depth + 1)); return out; }
for (const v of Object.values(value as Record<string, unknown>)) collectTransferables(v, out, depth + 1);
return out;
}
/** ============ 4. 一个真实例子:Worker 里解析大 CSV(含进度) ============ */
// workers/csv.worker.ts
import { serveWorker } from '../worker-rpc';
/** ⭐ 纯计算:把 CSV 文本切成「对象数组」(⭐ 这一步在旧实现里会「卡住主线程几秒」) */
function parseCsv(text: string, onProgress?: (ratio: number) => void): Array<Record<string, string>> {
const lines = text.split('\n');
if (lines.length === 0) return [];
const header = lines[0]!.split(',').map((h) => h.trim());
const rows: Array<Record<string, string>> = [];
// ⭐ 每 5000 行报一次进度(⭐ 避免「消息太频繁」)
const progressEvery = Math.max(1, Math.floor(lines.length / 20));
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const cells = line.split(',');
const row: Record<string, string> = {};
for (let c = 0; c < header.length; c++) row[header[c]!] = cells[c]?.trim() ?? '';
rows.push(row);
if (i % progressEvery === 0) onProgress?.(i / lines.length);
}
return rows;
}
serveWorker({
parseCsv: (payload) => {
const { text } = payload as { text: string };
// ⚠️ 注意:Worker 里「不能直接 postMessage 进度」走 RPC 协议(否则主线程认不出)——
// ⭐ 所以「进度」要么「用独立的 message 类型」,要么「在返回值里只返回最终结果」
// 这里演示「只返回结果」;如果要进度,见下方「进度上报」的写法。
return { rows: parseCsv(text), total: 0 };
},
/** ⭐ 一个「图片处理」的例子(用 Transferable 零拷贝) */
resizeImage: async (payload) => {
const { buffer, width, height, targetWidth } = payload as {
buffer: ArrayBuffer; width: number; height: number; targetWidth: number;
};
// ⚠️ Worker 里可以用 `createImageBitmap`(它在 Worker 中也可用)
const blob = new Blob([buffer]);
const bitmap = await createImageBitmap(blob);
const ratio = targetWidth / width;
const targetHeight = Math.round(height * ratio);
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
bitmap.close();
// ⭐⭐ 用 `convertToBlob`(Worker 里没有 `toBlob`)
const outBlob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.85 });
const outBuffer = await outBlob.arrayBuffer();
// ⭐ 返回 ArrayBuffer(⭐ 调用方要把它「转移」回来,见 collectTransferables)
return { buffer: outBuffer, width: targetWidth, height: targetHeight };
},
});
/** ============ 5. 主线程使用(⭐ 注意「构建方式」,见下) ============ */
// ⭐ Vite 的写法(⭐ 打包器会「把 worker 单独打成一个 chunk」)
import CsvWorker from './workers/csv.worker?worker';
import { createWorkerPool } from './worker-rpc';
const csvPool = createWorkerPool({
createWorker: () => new CsvWorker(),
size: 2, // ⭐ 「解析 CSV」不需要太多并发(内存会爆)
timeoutMs: 60_000,
});
export async function parseLargeCsv(file: File) {
// ⭐ 读取文件「也在 Worker 里做吗」?
// ⚠️ `file.text()` 在主线程也是异步的(不阻塞),但「解析」才是瓶颈 →
// 所以「读文本在主线程、解析在 Worker」是合理的
const text = await file.text();
const { rows } = await csvPool.call<{ rows: Array<Record<string, string>> }>('parseCsv', { text });
return rows;
}
/** ⭐ 图片处理:⭐ 用 Transferable 把 ArrayBuffer 转过去(零拷贝) */
export async function resizeImageFile(file: File, targetWidth: number) {
const buffer = await file.arrayBuffer();
const bitmap = await createImageBitmap(file);
const { width, height } = bitmap;
bitmap.close();
const result = await csvPool.call<{ buffer: ArrayBuffer; width: number; height: number }>(
'resizeImage',
{ buffer, width, height, targetWidth },
// ⭐⭐ 注意:这里的 buffer「被转移走了」→ 之后「不能再读 buffer」
{ transfer: [buffer] }
);
return new Blob([result.buffer], { type: 'image/webp' });
}
/** ============ 6. 「进度上报」的正确做法(⭐ 用「独立的消息类型」) ============ */
// Worker 侧:
export function serveWorkerWithProgress(handlers: {
[method: string]: (payload: unknown, report: (progress: number, data?: unknown) => void) => unknown | Promise<unknown>;
}) {
self.addEventListener('message', async (event: MessageEvent<RpcRequest>) => {
const req = event.data;
if (req?.__rpc !== 'req') return;
const handler = handlers[req.method];
if (!handler) return;
// ⭐⭐ 进度用「独立的 envelope 类型」(id 相同 → 主线程能定位到「哪个任务」)
const report = (progress: number, data?: unknown) => {
self.postMessage({ __rpc: 'progress', id: req.id, progress, data });
};
try {
const result = await handler(req.payload, report);
const transfer = collectTransferables(result);
self.postMessage({ __rpc: 'res', id: req.id, ok: true, result, transfer } satisfies RpcResponse, transfer);
} catch (err) {
const e = err as Error;
self.postMessage({
__rpc: 'res', id: req.id, ok: false,
error: { message: e?.message ?? String(err), stack: e?.stack },
} satisfies RpcResponse);
}
});
}
// 主线程侧:把 progress 也接进「同一个 pending」
// (在 createWorkerPool 的 onMessage 里加一个分支)
function handleProgress(slot: Slot, data: { __rpc: 'progress'; id: number; progress: number }) {
if (slot.current?.id !== data.id) return;
// ⭐ 调用方可注册 onProgress(这里只演示结构)
void data.progress;
}
⭐⭐⭐ 这层 RPC 里的六个「关键设计」:
① 【「一个 worker 同时只跑一个任务」】⭐
⭐ 这保证了「消息顺序安全」(worker 是「单线程 + 事件队列」,
如果「同时派两个任务」,它们的「消息交错」会让状态难以推理)。
⚠️ 代价:「池子必须够大」才能并行(所以有「池 + 队列」)。
② 【超时后「不立刻复用 worker」,而是「重建」】⭐
⚠️ 因为「超时的计算还在 worker 里跑」——
如果「立刻复用」,它可能「与新任务交错执行」(共享模块级状态时尤其危险)。
✅ 做法:`terminate()` + 重建(⭐ `terminate()` 是「唯一能真正停掉 worker 计算」的手段)。
③ 【Transferable 用在「大二进制」上】⭐
· 「传过去」:`postMessage(req, [buffer])`(⭐ 之后原 buffer 不可用)
· 「传回来」:Worker 侧「自动收集 transferables」(`collectTransferables`)
⚠️ 忘了「传回来」→ MB 级结果会被「结构化克隆」(⭐ 白丢性能)。
④ 【「进度」用独立的消息类型】⭐
因为 RPC 的契约是「一个请求 → 一个响应」(Promise)——
进度是「多个中间态」,所以要「另一种 envelope」(用同一个 id 关联)。
⑤ 【惰性创建 + 池化】⭐
⚠️ 「一进页面就起 7 个 worker」会「浪费内存与启动时间」;
⚠️ 「每次任务都 new Worker」更快但「启动成本高」(几 ms~几十 ms)。
✅ 「按需创建 + 复用 + 上限」。
⑥ 【Worker 崩溃要能恢复】
`error` 事件 → 拒绝当前任务 + `terminate` + 重建(⭐ 否则「这个 worker 永久不可用」)。
五、构建与打包:最容易出问题的一步
⭐⭐⭐ 四种「创建 Worker」的写法(⭐ 只有「打包器认识」的才能用):
① 【Vite(推荐)】
import MyWorker from './workers/my.worker?worker';
const w = new MyWorker();
⭐ 打包器会「把 worker 单独打成 chunk」并「处理它的依赖」
✅ 还有:`?worker&inline`(内联成 base64,⭐ 省一个请求,适合小 worker)
② 【Webpack 5 / Rspack(原生语法)】⭐ 最通用
const w = new Worker(new URL('./workers/my.worker.ts', import.meta.url), { type: 'module' });
⭐ 这是标准语法(Vite/Rollup 也支持)→ ⭐ 「跨构建器」的首选写法
⚠️ `{ type: 'module' }` 需要「浏览器支持 ESM worker」(现代浏览器都可以)
③ 【手写一个 worker 文件 + 静态路径】
const w = new Worker('/workers/my.worker.js');
⚠️ 这个 JS 文件必须「自己处理好依赖」(⭐ 不能用 import 了,除非是 module worker)
⚠️ 且「不参与打包」(不会被 tree-shake、不会有类型检查)→ ⭐ 少用
④ 【Blob URL(动态创建)】
const w = new Worker(URL.createObjectURL(new Blob([code], { type: 'text/javascript' })));
✅ 适合「运行时可变的代码」;⚠️ CSP 可能不允许(`worker-src blob:`)
// ✅ 一个「跨构建器」的封装(⭐ 推荐)
export function createTypedWorker<T extends Record<string, (payload: any) => any>>(
factory: () => Worker
): {
call: <K extends keyof T>(method: K, payload: Parameters<T[K]>[0]) => Promise<Awaited<ReturnType<T[K]>>>;
} {
// ⭐ 用「函数工厂」拿 worker(打包器语法的差异被隔离在这里)
const pool = createWorkerPool({ createWorker: factory, size: 2 });
return {
call: (method, payload) => pool.call(String(method), payload),
};
}
// 使用(⭐ 类型从「worker 里导出的 handler 类型」推导 —— 与 7.11 tRPC 同源的思想)
// workers/csv.worker.ts 里额外导出:
// export type CsvWorkerApi = { parseCsv: (p: { text: string }) => { rows: Row[] } };
// 主线程:
// const csv = createTypedWorker<CsvWorkerApi>(() => new CsvWorker());
// const { rows } = await csv.call('parseCsv', { text }); // ⭐ 类型安全
⭐⭐ 三个「构建期」的坑(每个都会让你「本地能跑、构建后白屏」):
① 【Worker 的路径在构建后变了】
❌ `new Worker('./worker.js')`(相对路径)→ 构建后「路径不对」
✅ 用「打包器认识的方式」(`?worker` 或 `new URL(..., import.meta.url)`)
② 【Worker 里用了「主线程才有的 API」】
⚠️ Worker 里**没有**:`window`、`document`、`localStorage`、DOM API
⭐ Worker 里有:`self`、`postMessage`、`fetch`、`IndexedDB`、`caches`、
`crypto`、`TextEncoder/Decoder`、`createImageBitmap`、`OffscreenCanvas`、`WebSocket`
✅ 「共享代码」要「不依赖 DOM」(⭐ 与 10.15 的跨平台共享是同一个纪律)
③ 【CSP 拦住 Worker】
⚠️ 如果 CSP 里有 `worker-src` 限制 → ⭐ worker 创建失败
✅ 加 `worker-src 'self' blob:`(如果用了 Blob URL)
六、OffscreenCanvas:把「绘制」也搬走
/** ⭐⭐ 在 Worker 里渲染 canvas(主线程完全不参与绘制) */
// 主线程:
export function startOffscreenRenderer(canvas: HTMLCanvasElement, worker: Worker) {
// ⭐⭐ 关键:把 canvas 的「控制权」转移给 Worker
const offscreen = canvas.transferControlToOffscreen();
// ⚠️ 转移后「主线程不能再对这个 canvas 做 2D/WebGL 操作」
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen]);
}
// Worker 侧:
serveWorker({
init: (payload) => {
const { canvas } = payload as { canvas: OffscreenCanvas };
const ctx = canvas.getContext('2d')!;
// ⭐ 在 Worker 里跑「绘制循环」(⭐ 不占用主线程)
let angle = 0;
const draw = () => {
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.rotate((angle * Math.PI) / 180);
ctx.fillStyle = '#1677ff';
ctx.fillRect(-40, -4, 80, 8);
ctx.restore();
angle += 2;
// ⭐ Worker 里用 `requestAnimationFrame` 也可用(部分浏览器)
// ⚠️ 兼容性不佳时用 `setTimeout`(但「与帧不同步」)
requestAnimationFrame(draw);
};
draw();
return { ok: true };
},
});
⭐⭐⭐ OffscreenCanvas 的三个「要注意」:
① 【「转移」是不可逆的】⭐
`transferControlToOffscreen()` 之后,**主线程不能再操作这个 canvas**
(⚠️ 也不能「转移第二次」,会抛错)→ 所以「要转移的那个 canvas」应该
「专门用于绘制」(不要「还想在主线程读它的像素」)。
② 【两种用法】
· ⭐ **转移已有 canvas**(`transferControlToOffscreen`)→ 页面里显示的 canvas 在 Worker 里画
· **在 Worker 里 `new OffscreenCanvas(w, h)`** → 「离屏渲染」(⭐ 结果用
`convertToBlob()` / `transferToImageBitmap()` 传回主线程显示)
⭐ 前者适合「持续动画」,后者适合「渲染一次、传回一张图」。
③ 【兼容性与降级】⭐
⚠️ Safari 支持较晚 → ✅ 先检测:
if ('transferControlToOffscreen' in HTMLCanvasElement.prototype) { 用 Worker }
else { 主线程绘制(降级) }
⚠️ 另外「Worker 里的 `requestAnimationFrame`」在不同浏览器行为有差异
→ ⭐ 如果依赖「与帧同步」,用「主线程 rAF 驱动 + 每帧 postMessage」(⚠️ 但会「丢一点性能」)
七、本篇特有的坑
// ① ⭐ 「转移 ArrayBuffer 后又去读它」(原线程已 detach)
const buf = await file.arrayBuffer();
await pool.call('process', { buf }, { transfer: [buf] });
console.log(buf.byteLength); // ❌ 0(已 detach)
// ✅ 转移后「不要再碰它」;需要保留就「先复制一份」
// ② 「大对象用结构化克隆」(把「慢」搬到另一处)
worker.postMessage({ pixels: hugeUint8Array }); // ⚠️ 几 MB 的复制
// ✅ postMessage({ pixels: arr }, [arr.buffer])(⭐ 零拷贝)
// ③ 「忘了把结果 transfer 回来」(MB 级结果被克隆)
// ✅ Worker 侧 `collectTransferables` + `postMessage(res, transfer)`
// ④ 「每次任务 new 一个 Worker」(启动成本 + 内存)
async function run(x) { const w = new Worker(url); ... } // ⚠️
// ✅ Worker 池(复用)
// ⑤ 「同时给一个 worker 派多个任务」(消息交错、状态难推理)
// ✅ 「一个 worker 一个任务」+ 池 + 队列
// ⑥ 「超时后立刻复用 worker」(旧计算还在跑 → 与新任务交错)⭐
// ✅ terminate() + 重建
// ⑦ 「消息太频繁」(每帧 1000 条小消息)
// ✅ 「批量发送」(如「累积到 16ms 或 N 条再发」)
// ⑧ 「Worker 里用了 document/window」(直接报错)
// ✅ 共享代码保持「零 DOM 依赖」(⭐ 与 10.15 的纪律一致)
// ⑨ ⭐ 「用 Service Worker 做长时间计算」(SW 会被浏览器随时终止)
// ✅ SW 做网络/缓存;计算用 Dedicated Worker
// ⑩ ⭐ 「用 SharedWorker 做跨标签页共享」(兼容与调试成本)
// ✅ 用「Leader 选举 + BroadcastChannel」(见 3.14)更可控
// ⑪ 「Worker 的错误没有捕获」(静默失败)
// ⚠️ Worker 里的未捕获错误「不会冒泡到主线程的 window」
// ✅ 监听 `worker.onerror` + Worker 内部「try/catch 后 postMessage 回来」(本实现做了)
// ⑫ 「Worker 崩了但没重建」(该 worker 永久不可用)
// ✅ `error` → terminate + 重建
// ⑬ 「Worker 数量 = 核数」(⭐ 主线程也是「一个线程」,UI 需要它)
// ✅ 默认「核数 - 1」,并且「按任务类型分组」(如「图片池」与「解析池」分开)
// ⑭ 「Worker 里用 indexedDB 但没处理「版本冲突」」
// ⚠️ 多个 worker 同时打开 DB → 可能「一个升级、另一个阻塞」
// ✅ 统一由一个 worker 负责 DB;或「升级时 close 其它连接」
// ⑮ 「大文件「读取」也在主线程」(`file.text()` 对大文件也有成本)
// ⭐ 实践:`file.arrayBuffer()` 通常够快;真正的瓶颈是「解析」
// (如果需要「流式读取」:`file.stream()` + Worker 里 `TextDecoderStream`)
// ⑯ 「Worker 的启动被「首次任务」感知到延迟」(用户点了才建)
// ✅ 「页面空闲时预热」:`requestIdleCallback(() => pool.warmup())`
// ⑰ ⭐ 「CSP / 跨域」拦住 Worker
// ✅ CSS 里允许 `worker-src`;Worker 脚本「同源」(⚠️ 跨域 worker 有限制)
// ⑱ 「开发模式正常、生产构建后 Worker 404」(路径问题)
// ✅ 用「打包器识别的写法」(`new URL(..., import.meta.url)` 或 `?worker`)
// ⑲ 「Worker 与主线程的『版本不一致』」(Worker 是旧 chunk)
// ⚠️ 部署后「老页面的 worker 被 terminate,新 worker 是新版本」通常没问题;
// 但如果「worker 脚本独立部署」→ ⚠️ 可能「主线程协议变了但 worker 还是旧的」
// ✅ 「Worker 跟着应用一起打包」(版本天然一致)
// ⑳ 「用 Worker 做『本来就不慢的事』」(过度设计)
// ⚠️ 比如「解析一个 10KB 的 JSON」
// ✅ 判据:「**单次执行会不会超过 16ms**」——不会就别搬
// ⑤⑥ 的完整处理(「任务级隔离」:需要「取消」的场景用「一次性 worker」)
/**
* ⭐ 一个「可取消」的任务:用「一次性 worker」(⭐ cancel = terminate)
* ⚠️ 代价是「启动成本」(几 ms)
* ✅ 适合:「用户可能取消的长任务」(如「上传大图前处理」「搜索大索引」)
*/
export function runCancellableTask<T>(
factory: () => Worker,
method: string,
payload: unknown,
opts: { transfer?: ArrayBuffer[] } = {}
): { promise: Promise<T>; cancel: () => void } {
const worker = factory();
let settled = false;
const promise = new Promise<T>((resolve, reject) => {
worker.addEventListener('message', (e: MessageEvent<RpcResponse>) => {
if (e.data?.__rpc !== 'res') return;
settled = true;
worker.terminate(); // ⭐ 任务完成即终止(一次性)
if (e.data.ok) resolve(e.data.result as T);
else reject(new Error(e.data.error?.message ?? '任务失败'));
});
worker.addEventListener('error', (e) => {
if (settled) return;
settled = true;
worker.terminate();
reject(new Error(e.message));
});
worker.postMessage({ __rpc: 'req', id: 1, method, payload } satisfies RpcRequest, opts.transfer ?? []);
});
return {
promise,
cancel: () => {
if (settled) return;
settled = true;
// ⭐⭐ 「取消」= terminate(⭐ 这是唯一「真正停掉计算」的手段)
worker.terminate();
},
};
}
// ⑯ 的完整处理(空闲预热)
export function warmupPoolOnIdle(pool: { call: (...args: never[]) => Promise<unknown> }) {
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void) => number }).requestIdleCallback;
const run = () => {
// ⭐ 发一个「极轻量的任务」把 worker 起起来
void pool.call({} as never).catch(() => { /* 忽略 */ });
};
if (idle) idle(run);
else setTimeout(run, 2000);
}
八、面试延伸
- 「什么时候该用 Web Worker?」
⭐ 判据是「单次执行会不会超过 16ms」——会,就该考虑搬走。典型场景:解析大 JSON/CSV(几 MB)、图片处理(缩放/滤镜/格式转换)、大文件加密/哈希、复杂搜索/排序/聚合(几万条数据)、语法高亮、生成 PDF/图表、数据压缩、音视频处理。为什么值得:主线程要干的事太多了(解析 HTML、样式/布局/绘制、处理用户输入(⭐ 决定 INP)、跑 React 的渲染),任何一次「超过 50ms 的同步计算」都会让「交互变慢(INP 变差)、动画掉帧、输入框卡顿」。⚠️ 反过来也别「过度设计」:解析 10KB 的 JSON 就别搬(Worker 的启动成本几 ms~几十 ms,比任务本身还贵)。 -
「Worker 之间怎么通信?有什么成本?」
三种方式,成本差异巨大:① ⭐ 结构化克隆(默认)——postMessage(data)会复制数据,支持「普通对象/数组、Date、Map、Set、RegExp、ArrayBuffer、Blob、ImageData」,⚠️ 不支持函数/DOM/Proxy(类实例会变成普通对象),⭐ 成本与数据大小成正比(MB 级复制可能几十毫秒 → 「等于把慢搬到了另一处」);② ⭐⭐ Transferable(转移所有权)——postMessage(data, [arrayBuffer]),⭐ 零拷贝,支持ArrayBuffer/MessagePort/OffscreenCanvas/ImageBitmap/ReadableStream,⚠️⚠️ 转移后原线程的对象会被 detach(再访问会报错/长度为 0,⭐ 这是常见 bug);③ ⭐⭐⭐ SharedArrayBuffer + Atomics——真共享内存,⚠️ 需要跨源隔离(COOP: same-origin+COEP: require-corp,会影响第三方资源加载),且Atomics.wait()不能在主线程用。⭐ 实践:「小数据用克隆、大二进制必须 Transferable、SAB 只在真的需要时用」。 -
「Worker 池为什么必要?」
因为「启动成本」与「并发控制」:① ⚠️ 每次任务new Worker()的启动成本是「几 ms ~ 几十 ms」(解析脚本 + 初始化)——⭐ 高频任务下这是纯浪费;② ⚠️ 不能无限创建(每个 worker 有内存开销,且主线程也只有一核可用)→ 「核数」个 worker 是合理上限。池的设计要点:① ⭐ 「一个 worker 同时只跑一个任务」——这保证「消息顺序安全」(worker 是「单线程 + 事件队列」,同时派两个任务会让消息交错、状态难推理);② 等待队列(没有空闲 slot 就排队);③ ⭐ 超时后「不立刻复用 worker」而是「terminate + 重建」——因为「超时的计算还在 worker 里跑」,立刻复用会「与新任务交错」(共享模块级状态时尤其危险);④ 按任务类型分组(「图片池」与「解析池」分开,避免「长任务阻塞短任务」)。 -
「SharedWorker 和 Service Worker 什么时候用?」
⭐ 大多数情况都不该用它们做「计算」:① SharedWorker——「多个标签页共享一个实例」,适合「共享一份状态/一个连接」,⚠️ 但「兼容性与行为差异(Safari)+ 调试麻烦」→ ⭐ 实践上「Leader 选举 + BroadcastChannel」(见 3.14)更可控(⭐ 而且能用「普通 Worker」);② ⭐ Service Worker——它是「网络代理」(离线、缓存、推送),⚠️ 它会被浏览器随时终止(无事件时被杀)→ 绝对不适合「长时间计算」,而且它「在自己的线程」上,通信要经postMessage(更绕)。⭐ 一句话:「计算用 Dedicated Worker;跨标签页用 Leader 选举;网络用 Service Worker」。 -
「OffscreenCanvas 是什么?」
它是「可以在 Worker 里使用的 canvas」,让你把「绘制」也搬出主线程。两种用法:① ⭐ 转移已有 canvas——canvas.transferControlToOffscreen()→postMessage(offscreen, [offscreen])→ ⚠️ 转移是不可逆的(主线程不能再操作它,也不能转移第二次),适合「持续动画」(⭐ 连「绘制循环」都不占主线程);② 在 Worker 里new OffscreenCanvas(w, h)——离屏渲染,结果用convertToBlob()或transferToImageBitmap()传回主线程显示,适合「渲染一次、传回一张图」。注意:⚠️ 兼容性(Safari 支持较晚)→ ✅ 必须先检测'transferControlToOffscreen' in HTMLCanvasElement.prototype,不支持就降级到主线程绘制;⚠️ 另外「Worker 里的requestAnimationFrame」在不同浏览器行为有差异,如果依赖「与帧同步」,可以用「主线程 rAF 驱动 + 每帧 postMessage」。 -
「Worker 里能用什么、不能用什么?」
⚠️ 没有:window、document、localStorage/sessionStorage、DOM API(getElementById、addEventListener在 DOM 上)、parent。⭐ 有:self、postMessage、fetch/XMLHttpRequest、IndexedDB、caches、crypto、TextEncoder/TextDecoder、createImageBitmap、OffscreenCanvas、WebSocket、importScripts(经典 worker)或import(module worker)。工程纪律:⭐ 「共享代码要保持零 DOM 依赖」——这与 10.15 的「Web 与 RN 共享代码」是同一条纪律(✅ 用「适配层 + 接口注入」,而不是「在共享代码里if (typeof window !== 'undefined')」)。 -
「Worker 的构建/打包要注意什么?」
三个坑(每个都会让你「本地能跑、构建后白屏」):① ⭐ Worker 的路径在构建后变了——❌new Worker('./worker.js')(相对路径)在构建后可能不对;✅ 用「打包器认识的写法」:Vite 的import W from './w?worker',或 ⭐ 跨构建器的标准语法new Worker(new URL('./w.ts', import.meta.url), { type: 'module' });② ⭐ Worker 里用了主线程 API(document等)→ 直接报错(✅ 保持「零 DOM 依赖」);③ ⭐ CSP 拦住 Worker(worker-src限制,或用了 Blob URL 但没允许blob:)→ 创建失败。另外:⭐{ type: 'module' }让 Worker 支持 ESM(现代浏览器都可以),这样 Worker 里也能import(⭐ 能被 tree-shake,且「与主线程共享类型」)。 -
「如果让你优化一个『列表页滚动卡顿』的问题,Worker 能帮上忙吗?」
⭐ 要分清楚「卡顿的原因」——Worker 只能解决「主线程 CPU 密集计算」这一类:① ⭐ 如果卡顿来自「长任务」(如「每屏都要做一次复杂聚合」)→ ✅ Worker 有效(把聚合搬走);② ⭐ 如果卡顿来自「DOM 太多 / 样式重算 / 布局抖动」→ ❌ Worker 无效(那是「渲染」的问题,要用虚拟滚动(5.5)、content-visibility、只改transform(5.17));③ ⭐ 如果卡顿来自「图片解码」→ ⭐ Worker +createImageBitmap有效(解码在 Worker 里做);④ ⭐ 如果卡顿来自「React 重渲染太多」→ ❌ Worker 无效(要用memo/引用稳定/状态下移(5.4))。诊断方法:⭐ 用 Chrome Performance 面板看「长任务里到底在干什么」——如果是「你的函数在算」,Worker 有效;如果是「Recalculate Style / Layout / Paint」,Worker 帮不上。
一句话速记
Worker 的价值是「把 CPU 密集计算搬离主线程」→ 直接改善 INP 与动画流畅度;⭐ 判据:「单次执行会不会超过 16ms」(⚠️ 别过度设计);四类后台执行体:⭐ Dedicated Worker(默认选择,做计算) / SharedWorker(⚠️ 兼容与调试成本高,⭐ 实践用「Leader 选举 + BroadcastChannel」替代)/ ⭐ Service Worker 不是计算线程(会被随时终止,它只做网络/缓存) / OffscreenCanvas(把「绘制」也搬走:
transferControlToOffscreen(),⚠️ 不可逆 + 要降级);⭐ 通信三种方式成本差异巨大:结构化克隆(复制,成本与大小成正比,❌ 不支持函数/DOM) / ⭐⭐ Transferable(零拷贝,⚠️⚠️ 转移后原线程对象被 detach——这是最常见 bug) / SharedArrayBuffer(真共享,⚠️ 需 COOP/COEP 跨源隔离 +Atomics.wait不能在主线程)——⭐ 大二进制必须 Transferable,且「传回来」也要收集 transferables;⭐ Worker 池的四个要点:「一个 worker 同时只跑一个任务」(保证消息顺序安全)+ 等待队列 + ⭐ 超时后「terminate 重建」而不是立刻复用(否则旧计算与新任务交错)+ 按任务类型分组;⭐ Worker 里没有window/document/localStorage(⭐ 共享代码必须保持「零 DOM 依赖」——与 10.15 跨平台共享是同一条纪律);⭐ 构建三坑:路径要用「打包器认识的写法」(new URL(..., import.meta.url)或?worker)、别用主线程 API、CSP 的worker-src;⭐ 「进度」要用独立的消息 envelope(RPC 是「一请求一响应」);⭐terminate()是唯一能真正停掉计算的手段(所以「可取消任务」= 一次性 worker + terminate)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!