← 返回文章

JavaScript / 前端性能 / Web Workers

Web Workers 实战:把重计算移出浏览器主线程

前端导入十万行 CSV、压缩图片、计算复杂报表时,即使代码写成 async function,页面仍可能完全卡住。原因很简单:async/await 只改变异步控制流,不会自动把 CPU 计算搬到另一条线程。

Web Workers 提供了真正的后台线程。主线程负责 DOM、输入与绘制,Worker 负责纯计算,两边通过消息传递数据。它不能让算法凭空变快,却能避免一段计算持续占用 UI 主线程。

一、先确认问题适合 Worker

Worker 最适合这些任务:

  • 大量数据解析与聚合;
  • 图片像素、音视频片段处理;
  • 压缩、哈希和复杂数学计算;
  • 本地搜索索引;
  • 可以明确输入和输出的纯计算。

网络请求本身通常不会持续占用主线程,把一次普通 Fetch 放进 Worker 收益有限。频繁操作 DOM 的代码也不适合,因为 Worker 不能直接访问 document 和页面节点。

先在性能面板确认主线程存在长任务,再决定是否引入 Worker。算法复杂度能从 O(n²) 降到 O(n log n) 时,优先优化算法;Worker 是隔离计算,不是掩盖低效实现。

二、创建一个模块 Worker

主线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const worker = new Worker(
new URL('./sales-worker.js', import.meta.url),
{ type: 'module' },
);

worker.addEventListener('message', (event) => {
renderSummary(event.data);
});

worker.addEventListener('error', (error) => {
showError(`计算失败:${error.message}`);
});

worker.postMessage({
type: 'summarize',
rows: salesRows,
});

Worker 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
self.addEventListener('message', (event) => {
if (event.data.type !== 'summarize') return;

const summary = summarizeSales(event.data.rows);

self.postMessage({
type: 'summary',
summary,
});
});

function summarizeSales(rows) {
return rows.reduce(
(result, row) => {
result.total += row.amount;
result.count += 1;
return result;
},
{ total: 0, count: 0 },
);
}

new URL(..., import.meta.url) 让构建工具能够定位 Worker 文件;type: 'module' 允许在 Worker 内使用 ES Modules。具体打包支持仍要以项目工具链为准。

三、消息数据默认会复制

MDN 的 Worker 指南指出,主线程与 Worker 之间通过 postMessage() 发送的数据会被复制,而不是直接共享。底层使用结构化克隆,可以处理数组、普通对象、Map 等多种结构,但函数和 DOM 节点不能这样传递。

1
worker.postMessage({ rows: hugeArray });

如果 hugeArray 很大,序列化与复制本身可能成为开销。不要每处理一条数据就发一次消息,也不要反复传输整份状态。更合适的是:

  • 以合理批次发送;
  • Worker 内保留计算所需状态;
  • 返回聚合结果而不是完整中间数据;
  • 测量消息传输时间,不只测计算函数。

四、大型二进制数据使用 Transferable

ArrayBuffer 可以转移所有权,避免复制整块内存:

1
2
3
4
5
6
const buffer = await file.arrayBuffer();

worker.postMessage(
{ type: 'parse', buffer },
[buffer],
);

传输后,主线程中的原 buffer 会被分离,不能继续读取。这不是 bug,而是所有权已经交给 Worker。

Worker 处理后也可以转回:

1
2
3
4
self.postMessage(
{ type: 'parsed', buffer: resultBuffer },
[resultBuffer],
);

Transferable 很适合图片、音频和大文件,但对小对象滥用只会增加代码复杂度。先用实际数据测量。

五、设计消息协议,不要只传裸数组

随着功能增加,消息应具有明确类型和请求 ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let nextRequestId = 0;
const pending = new Map();

function runWorkerTask(payload) {
const id = ++nextRequestId;

return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, type: 'calculate', payload });
});
}

worker.addEventListener('message', (event) => {
const task = pending.get(event.data.id);
if (!task) return;

pending.delete(event.data.id);

if (event.data.error) {
task.reject(new Error(event.data.error));
} else {
task.resolve(event.data.result);
}
});

请求 ID 防止并发任务结果对应错调用方。消息结构应当像一个小型 API:有版本、类型、输入、成功结果和可展示错误,而不是靠数组下标猜含义。

Worker 内部错误对象不能假设能完整跨线程保留原型与堆栈。生产环境可以返回受控错误码,同时在 Worker 的 error 事件中记录诊断信息。

六、取消与终止

worker.terminate() 会立即终止整个 Worker:

1
2
3
cancelAllButton.addEventListener('click', () => {
worker.terminate();
});

它适合页面离开或 Worker 不再需要,但会中断其中所有任务。若一个 Worker 同时处理多个请求,应设计协作式取消:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 主线程
worker.postMessage({ type: 'cancel', id });

// Worker
const canceled = new Set();

self.addEventListener('message', (event) => {
if (event.data.type === 'cancel') {
canceled.add(event.data.id);
}
});

async function processChunks(id, rows) {
for (let index = 0; index < rows.length; index += 1000) {
if (canceled.has(id)) return;
processBatch(rows.slice(index, index + 1000));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}

纯同步大循环中,Worker 的消息事件也要等当前任务结束才能处理。示例在每批之后让出当前任务,让 Worker 有机会读取取消消息;批次大小仍要根据实际计算耗时调整。

七、Worker 不能直接做什么

Worker 没有页面的 window 和 DOM,不能调用 document.querySelector()。它可以使用一部分 Web API,例如 Fetch、定时器和某些存储能力,但可用范围应查对应 Worker 文档。

正确分工通常是:

1
2
主线程:收集输入 → 发送数据 → 展示进度 → 渲染结果
Worker:解析数据 → 计算 → 定期报告进度 → 返回结果

不要让 Worker 决定页面显示哪个弹窗或修改哪个按钮;那会把 UI 状态分散到线程两边。Worker 返回领域结果,主线程决定呈现。

八、进度反馈与批次

耗时两秒以上的任务应给用户进度:

1
2
3
4
5
6
7
8
9
for (let index = 0; index < rows.length; index += batchSize) {
processBatch(rows.slice(index, index + batchSize));

self.postMessage({
type: 'progress',
completed: Math.min(index + batchSize, rows.length),
total: rows.length,
});
}

进度消息也不要过密。每条记录都上报会让消息成本和主线程渲染抵消 Worker 收益。按时间间隔或较大批次上报更合理。

九、上线前检查清单

  1. 性能记录是否证明瓶颈是 CPU 长任务,而不是网络或 DOM?
  2. Worker 输入输出是否足够小、可序列化?
  3. 大型 ArrayBuffer 是否适合转移所有权?
  4. 并发任务是否有请求 ID 和错误协议?
  5. 页面离开、重复计算和用户取消时如何清理?
  6. Worker 加载失败时是否有可理解的回退提示?
  7. 低端设备上的计算时间与内存是否实测?

Web Workers 的核心不是“多线程很快”,而是把线程职责分开。只要输入输出边界清楚、传输成本受控、错误和取消协议完整,主线程就能把宝贵的时间留给输入与渲染,让复杂计算不再等同于页面冻结。

参考资料