Web Worker 实战:任务切分、可取消计算与 OffscreenCanvas
本文目录
搜索大量本地记录、解析大型文件、计算图表数据或做图像转换时,主线程的长任务会挤占输入响应和绘制时间。Web Worker 可以把一部分 JavaScript 放到独立的执行上下文,但它不是“把任意函数扔到另一个线程”:主线程仍要负责 DOM 和界面更新,两边通过消息交换可序列化数据,启动、复制和调度本身也有成本。
这篇按三个决策组织:哪些工作值得移走、怎样设计可取消的消息协议,以及什么时候让 Worker 接管 Canvas 绘制。小任务、纯 I/O 等待或频繁传输巨大对象,未必适合 Worker;先测主线程长任务和交互延迟,再决定。
API 选型与能力边界
| 能力 | 适合做什么 | 不能假设什么 |
|---|---|---|
| Dedicated Worker | 单个页面发起的 CPU 密集任务、文件解析、搜索与转换 | 不能直接读写 DOM,也不自动让任务更快 |
| SharedWorker | 多个同源页面共享一个长生命周期连接或状态协调器 | 生命周期、兼容性和端口清理要单独设计;不是持久消息队列 |
| Service Worker | 请求代理、缓存、推送及浏览器安排的后台事件 | 浏览器可随时休眠或终止它,不适合当常驻 CPU 线程 |
| Worklet | 与渲染、音频等特定浏览器流水线配合的小型任务 | 不是可以承载任意应用逻辑的通用 Worker |
| OffscreenCanvas | 把 Canvas 绘制控制权交给 Worker,减少主线程绘制负担 | DOM 布局和输入仍在主线程;支持与绘图上下文需检测 |
Worker 的全局对象不是 window,通常通过 self 访问。Worker 没有 DOM,但可用能力并非“只有计算”:许多场景仍可使用 fetch、定时器、部分存储 API 等。具体 API 以目标浏览器支持情况为准。
模块 Worker 与可验证的消息协议
在 Vite 等构建工具中,用相对当前模块的 URL 让构建器发现 Worker 入口;不要把用户输入拼成脚本 URL,也不要用 eval 执行消息内容。
const worker = new Worker(
new URL('./filter.worker.js', import.meta.url),
{ type: 'module', name: 'local-filter' },
);
消息应有协议版本、任务类型和关联 ID。postMessage() 使用 structured clone;函数、DOM 节点和许多带自定义原型的实例不能按原样跨线程传输,复杂对象也可能产生显著复制成本。只发送计算需要的数据,结果尽量返回稳定 ID 或紧凑结构。
主线程示例:
let nextId = 0;
let activeId = 0;
function startSearch(rows, query) {
if (activeId) {
worker.postMessage({ version: 1, type: 'cancel', id: activeId });
}
const id = ++nextId;
activeId = id;
try {
worker.postMessage({ version: 1, type: 'filter', id, rows, query });
} catch {
activeId = 0;
showError('无法发送搜索任务');
return null;
}
return id;
}
function cancelSearch() {
if (!activeId) return;
worker.postMessage({ version: 1, type: 'cancel', id: activeId });
activeId = 0;
showCancelled();
}
worker.addEventListener('message', ({ data }) => {
if (data?.version !== 1 || !Number.isSafeInteger(data.id) || data.id !== activeId) return;
if (data.type === 'result') {
activeId = 0;
showRows(data.ids);
} else if (data.type === 'error') {
activeId = 0;
showError(data.code);
}
});
worker.addEventListener('error', () => {
activeId = 0;
showError('Worker 执行失败');
});
worker.addEventListener('messageerror', () => {
activeId = 0;
showError('无法读取 Worker 消息');
});
Worker 侧把工作切成小批次,并在批次间让出事件循环,这样取消消息才有机会被处理。单个同步、无法让步的超长循环不会因为主线程发出 cancel 就立刻停下来;需要从算法结构上加入检查点。以下示例限制输入规模、仅回传匹配 ID,并对外隐藏原始异常内容:
const running = new Set();
const cancelled = new Set();
const CHUNK_SIZE = 500;
const MAX_ROWS = 100_000;
self.addEventListener('message', ({ data }) => {
if (data?.version !== 1 || !Number.isSafeInteger(data.id)) return;
if (data.type === 'cancel') {
if (running.has(data.id)) cancelled.add(data.id);
return;
}
if (data.type !== 'filter' || !Array.isArray(data.rows) || data.rows.length > MAX_ROWS || typeof data.query !== 'string') {
self.postMessage({ version: 1, type: 'error', id: data.id, code: 'INVALID_REQUEST' });
return;
}
void filterRows(data);
});
async function filterRows({ id, rows, query }) {
running.add(id);
const ids = [];
const needle = query.toLocaleLowerCase();
try {
for (let start = 0; start < rows.length; start += CHUNK_SIZE) {
if (cancelled.has(id)) {
self.postMessage({ version: 1, type: 'cancelled', id });
return;
}
const end = Math.min(start + CHUNK_SIZE, rows.length);
for (let index = start; index < end; index += 1) {
const row = rows[index];
const title = typeof row?.title === 'string' ? row.title : '';
if (title.toLocaleLowerCase().includes(needle)) ids.push(row.id);
}
if (end < rows.length) await new Promise((resolve) => setTimeout(resolve, 0));
}
if (!cancelled.has(id)) self.postMessage({ version: 1, type: 'result', id, ids });
} catch {
self.postMessage({ version: 1, type: 'error', id, code: 'TASK_FAILED' });
} finally {
running.delete(id);
cancelled.delete(id);
}
}
示例适合说明协议,不是通用搜索库。生产实现还应限制同时运行的任务数、队列长度、输入字节数和结果数;结束旧任务时同时忽略它迟到的结果。高频查询可以合并,只保留最新任务。Worker 崩溃后要让界面离开“处理中”,按需重建 Worker,并提供主线程小数据回退或明确错误态。
大块二进制数据:复制还是转移
普通消息通常会复制数据。对于大型二进制缓冲区,可以把 ArrayBuffer 放入 transferable 列表,转移所有权而不是保留两份可读副本:
const buffer = await file.arrayBuffer();
worker.postMessage({ version: 1, type: 'decode', buffer }, [buffer]);
console.log(buffer.byteLength); // 0:主线程已不再拥有这段缓冲区
被转移后的原始 buffer 会 detach,后续不能再当作原数据使用。转移列表中的对象必须同时出现在消息数据里,否则所有权虽然转走,接收端却拿不到它。TypedArray 本身不是 transferable;需要转移其底层 .buffer。传输前先明确哪个线程负责释放/复用数据,避免“性能优化”变成难追踪的数据丢失。
OffscreenCanvas:把绘制移出主线程
当 Canvas 绘图本身是长任务时,可以在尚未调用主线程 getContext() 的情况下,把 Canvas 控制权转给 Worker。尺寸观察仍留在主线程,实际像素绘制由 Worker 负责;观察稳定的容器,而不是让绘图回写反复改变尺寸来源。
function mountWorkerCanvas(canvas, container, renderOnMainThread) {
if (typeof Worker !== 'function' || typeof canvas.transferControlToOffscreen !== 'function') {
return renderOnMainThread();
}
let worker;
try {
worker = new Worker(new URL('./canvas.worker.js', import.meta.url), { type: 'module' });
} catch {
return renderOnMainThread();
}
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ version: 1, type: 'init', canvas: offscreen }, [offscreen]);
const observer = new ResizeObserver(([entry]) => {
worker.postMessage({
version: 1,
type: 'resize',
width: entry.contentRect.width,
height: entry.contentRect.height,
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
});
observer.observe(container);
return () => {
observer.disconnect();
worker.terminate();
};
}
Worker 收到 init 后创建 2D 或 WebGL 上下文;收到 resize 时更新 backing buffer,再绘制最新场景。画布只转移一次,转移前不要在主线程创建绘图上下文。CSS 尺寸、DPR、像素总量应分别设上限;超大画布即使移出主线程也会耗费大量内存。若转移后 Worker 初始化失败,不能假设原 Canvas 仍能安全接回主线程,应该显示清楚的恢复入口或在创建前采用兼容性判断。
OffscreenCanvas 适合持续、昂贵的 Canvas 绘制,不值得为一张简单图表增加线程与消息复杂度。ResizeObserver 只告诉你元素尺寸变化,绘制工作移交后仍需在组件卸载时 disconnect() 和 terminate();尺寸盒模型与反馈环另见 前端计时器与布局测量:用 requestAnimationFrame 和 ResizeObserver。
构建、安全与部署条件
- 构建入口: 使用构建器能识别的静态
new URL(..., import.meta.url)写法;不要运行时拼接 Worker 路径。确认产物包含 Worker 文件且 MIME 是 JavaScript。 - CSP: 检查响应头的
worker-src是否允许本站 Worker。不要为了让 Worker 启动就无差别加入blob:或放宽全站脚本来源。 - 消息边界: 消息字段、版本、ID、长度、枚举值都要校验。Worker 不是权限边界,也不是存放服务端密钥的安全区域。
- 共享内存: 普通 Worker 和 OffscreenCanvas 不要求为了启动而开启跨源隔离。只有确实需要 SharedArrayBuffer 等能力时,才评估 COOP/COEP;这会影响第三方 iframe、资源嵌入和跨源加载。
- 资源管理: 视图卸载时移除事件监听、撤销对象 URL、停止任务并终止不再复用的 Worker;长寿命共享 Worker 则定义显式关闭消息。
- 可观测性: 记录任务类型、耗时、输入大小和失败类别,不记录文件正文、隐私数据或完整模型提示词。
验收清单
- 对比主线程与 Worker 版本的长任务、交互延迟、总耗时和峰值内存;如果总耗时更差,不要因为“用了多线程”就保留。
- 用慢任务复现连续输入:旧任务取消后迟到的结果不能覆盖新结果;任务取消后界面应及时退出忙碌状态。
- 测试 structured clone 失败、空消息、超大输入、Worker 加载失败、
error与messageerror。 - 确认转移 ArrayBuffer 后主线程数据确实不可用,且接收端能正确恢复视图和偏移量。
- 在支持与不支持 OffscreenCanvas 的浏览器分别验证绘制、降级、零尺寸、DPR 变化和组件销毁。
- 用移动端真机测量内存和发热;Worker 数量受设备限制,不要按
hardwareConcurrency不加上限地创建线程池。 - 通过生产 CSP 与构建产物验证 Worker URL、MIME、缓存和错误提示;开发服务器能运行不等于生产部署配置正确。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。