陌上人如玉
公子世无双

8.10 Web Worker / SharedWorker / OffscreenCanvas

8.10 Web Worker / SharedWorker / OffscreenCanvas

一句话: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);
}

八、面试延伸

  1. 「什么时候该用 Web Worker?」
    ⭐ 判据是「单次执行会不会超过 16ms」——会,就该考虑搬走。典型场景:解析大 JSON/CSV(几 MB)、图片处理(缩放/滤镜/格式转换)、大文件加密/哈希、复杂搜索/排序/聚合(几万条数据)、语法高亮、生成 PDF/图表、数据压缩、音视频处理。为什么值得:主线程要干的事太多了(解析 HTML、样式/布局/绘制、处理用户输入(⭐ 决定 INP)、跑 React 的渲染),任何一次「超过 50ms 的同步计算」都会让「交互变慢(INP 变差)、动画掉帧、输入框卡顿」。⚠️ 反过来也别「过度设计」:解析 10KB 的 JSON 就别搬(Worker 的启动成本几 ms~几十 ms,比任务本身还贵)。

  2. 「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 只在真的需要时用」。

  3. 「Worker 池为什么必要?」
    因为「启动成本」与「并发控制」:① ⚠️ 每次任务 new Worker() 的启动成本是「几 ms ~ 几十 ms」(解析脚本 + 初始化)——⭐ 高频任务下这是纯浪费;② ⚠️ 不能无限创建(每个 worker 有内存开销,且主线程也只有一核可用)→ 「核数」个 worker 是合理上限。池的设计要点:① ⭐ 「一个 worker 同时只跑一个任务」——这保证「消息顺序安全」(worker 是「单线程 + 事件队列」,同时派两个任务会让消息交错、状态难推理);② 等待队列(没有空闲 slot 就排队);③ ⭐ 超时后「不立刻复用 worker」而是「terminate + 重建」——因为「超时的计算还在 worker 里跑」,立刻复用会「与新任务交错」(共享模块级状态时尤其危险);④ 按任务类型分组(「图片池」与「解析池」分开,避免「长任务阻塞短任务」)。

  4. 「SharedWorker 和 Service Worker 什么时候用?」
    ⭐ 大多数情况都不该用它们做「计算」:① SharedWorker——「多个标签页共享一个实例」,适合「共享一份状态/一个连接」,⚠️ 但「兼容性与行为差异(Safari)+ 调试麻烦」→ ⭐ 实践上「Leader 选举 + BroadcastChannel」(见 3.14)更可控(⭐ 而且能用「普通 Worker」);② ⭐ Service Worker——它是「网络代理」(离线、缓存、推送),⚠️ 它会被浏览器随时终止(无事件时被杀)→ 绝对不适合「长时间计算」,而且它「在自己的线程」上,通信要经 postMessage(更绕)。⭐ 一句话:「计算用 Dedicated Worker;跨标签页用 Leader 选举;网络用 Service Worker」。

  5. 「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」。

  6. 「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')」)。

  7. 「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,且「与主线程共享类型」)。

  8. 「如果让你优化一个『列表页滚动卡顿』的问题,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)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.10 Web Worker / SharedWorker / OffscreenCanvas

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫