前端开发··1 阅读·预计 10 分钟

用 Web Worker 把重计算赶出主线程:从卡顿到 60fps 的三种卸载策略实测

主线程一次超过 50ms 的任务就会让用户感知到卡顿,超过 200ms 则直接触发 Core Web Vitals 的 INP 红线。JavaScript 单线程模型下,任何 CPU 密集型操作——图片矩阵处理、大 JSON 解析、哈希计算、正则回溯——都在和渲染抢同一段执行时间。Web Worker 是唯一能把计算真正搬离主线程的原生手段,但怎么搬、搬多少、数据怎么传,直接决定了它是救命稻草还是新的性能陷阱。

起点:一个会卡死的坏例子

下面的代码在主线程上同步处理 100 万条记录的聚合,输入期间页面完全冻结:

// 反例:阻塞主线程
function aggregate(records) {
  const buckets = new Map();
  for (const r of records) {
    const k = r.category;
    buckets.set(k, (buckets.get(k) ?? 0) + r.amount);
  }
  return [...buckets.entries()];
}

const start = performance.now();
const result = aggregate(oneMillionRecords); // 浏览器标签页卡死数百毫秒
console.log(performance.now() - start);

这里的核心矛盾不是逻辑错误,而是执行位置错误。渲染、用户输入、滚动都要依赖主线程的空闲,长任务把事件循环堵死,再快的算法也救不了体验。

策略一:一次性 spawn 重 Worker(最小改动)

把计算函数原样搬到 worker 文件,用 RPC 风格的 postMessage 建立一次调用关系:

// worker.js
self.onmessage = (e) => {
  const { records } = e.data;
  const buckets = new Map();
  for (const r of records) {
    const k = r.category;
    buckets.set(k, (buckets.get(k) ?? 0) + r.amount);
  }
  self.postMessage([...buckets.entries()]);
};
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => render(e.data);
worker.postMessage({ records: oneMillionRecords });

这能立刻把主线程从长任务里解放出来,INP 会显著改善。但它有个隐蔽代价:传 100 万条记录靠的是结构化克隆(structured clone)。克隆发生在主线程,对超大对象集合同样会占用几十毫秒,等于把排队的队列搬到了另一处堵点。

策略二:Transferable 零拷贝传参(进阶)

当数据是 ArrayBuffer 这类二进制时,可以用 transfer(转移)而非拷贝,把所有权直接移交给 worker,主线程几乎零成本交出数据:

// 反例:普通 postMessage 触发深拷贝
worker.postMessage({ buffer }); // 拷贝原始字节,主线程阻塞

// 正例:转移 ArrayBuffer 所有权,主线程几乎零开销
const buf = new ArrayBuffer(1024 * 1024 * 128); // 128MB
worker.postMessage({ buffer: buf }, [buf]);
console.log(buf.byteLength); // 0 —— 所有权已转移,不可再用
// worker 侧拿到的是同一块内存,无克隆
self.onmessage = ({ data }) => {
  const view = new Uint32Array(data.buffer);
  let sum = 0;
  for (const n of view) sum += n;
  self.postMessage({ sum });
};

关键细节:转移后源端 buffer 会被 detachbyteLength 归零。如果主线程还需要这份数据(比如要同时渲染),就不能 transfer,只能接受拷贝或用 SharedArrayBuffer 走共享内存。很多人在这里踩坑——transfer 了又继续读原 buffer,拿到一个静默的 0

策略三:可复用 Worker 池(高频调用)

一次性 spawn 的问题在于创建成本。如果场景是用户拖拽滑块实时触发重计算,每帧都 new Worker 会让创建开销和线程开销反超收益。正确的做法是常驻几个 worker 组成池,按需派发:

const pool = Array.from({ length: navigator.hardwareConcurrency - 1 }, spawn);

function spawn() {
  const w = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
  w.busy = false;
  w.onmessage = ({ data }) => {
    w.busy = false;
    resolvePending(data);
  };
  return w;
}

function dispatch(payload, transfer = []) {
  const idle = pool.find((w) => !w.busy);
  if (!idle) return queue.push(payload); // 无空闲则入队
  idle.busy = true;
  idle.postMessage(payload, transfer);
}

池的大小通常取 hardwareConcurrency - 1,给主线程留一路。但池也引入新的复杂度:任务排队、失败重试、worker 崩溃后的自愈(error 事件监听 + 重建)。这是「能力越强、责任越大」的典型。

反例对照:你以为在优化,其实更慢

// 反例 A:数据传输成本 > 计算成本
// 数据只有 200 条,却 spawn worker 去算,postMessage 的结构化克隆反超主线程直接算
worker.postMessage({ records: tinyArray });

// 反例 B:高频通信导致消息队列拥塞
setInterval(() => worker.postMessage(nextChunk), 0); // 主线程消息风暴

// 反例 C:transfer 后仍访问源对象
worker.postMessage({ buf }, [buf]);
console.log(new Uint8Array(buf)[0]); // TypeError: Cannot perform on detached ArrayBuffer

判断标准是量级:只有当单次计算 ≥ 数毫秒级、且数据规模大到值得传输开销时,Worker 才有正收益。轻量任务放主线程反而更快,因为省掉了跨线程通信的这一趟往返。

度量与结论

别靠感觉,靠数据。用 PerformanceObserver 抓长任务,用 transfer 前后对比 structuredClone 耗时:

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.duration > 50) console.warn('长任务', e.duration);
  }
}).observe({ entryTypes: ['longtask'] });

三种策略的选用口诀:

  • 一次性重活 → 一个临时 Worker,用完 terminate()
  • 二进制大块数据 → Transferable,零拷贝移交;
  • 高频小任务 → 复用的 Worker 池,避免重复建线程。

Web Worker 不是银弹,它是把「不该在主线程做的事」还回去的工具。用对传输方式、控好通信频率、拿性能指标说话,才能让 60fps 从口号变成可复现的事实。

0 评论

评论区

登录 后参与评论