Worker 图表运行时
摘要把图表计算与渲染移入 Worker,比较 ImageBitmap Transfer 和 OffscreenCanvas 两种跨线程执行模型,以及它们怎样隔离主线程故障域。
当时图表渲染和业务代码都挤在主线程里。仪表盘上只要有一处计算稍重,其它图表也会一起失去响应。我做了一个很小的 demo,先确认把 Canvas 移进 Worker 到底能解决多少问题。
demo 里,每个 Canvas 都在 requestAnimationFrame 中把数字加 1。上面的 5 个跑在主线程,下面的 5 个各自放在 Worker。点击「复杂业务」后,上面一排同时停住,下面一排继续更新:

我又给每个 Canvas 加了点击事件:先做一段重计算,再把数字归零。点击下面的 OffscreenCanvas,只会卡住它所在的 Worker;点击上面的普通 Canvas,整个页面都会受影响。

旧 GIF 只能看结果,下面把同一个实验直接搬进文章。两块画布都在持续计数:左边由主线程逐帧绘制,右边的控制权已经交给 Worker。先阻塞主线程,再单独阻塞 Worker,区别会非常直观。
动手实验 · 约 20 秒
到底是谁被长任务卡住了?
每次阻塞 800ms。右侧使用真正的 Worker + OffscreenCanvas。
主线程 Canvas
帧计数:0
Worker OffscreenCanvas
帧计数:0
观察两个数字正常增长,然后依次点击两个阻塞按钮。
这个现象基本确认了方向:耗时计算和绘制都应该尽量离开主线程。下面记录两种 OffscreenCanvas 用法,以及把现有图表库搬过去时碰到的边界。
Worker 基础
为了把 Worker 的边界确认清楚,我又翻了一遍《JavaScript 高级程序设计》。和这次实验直接相关的只有生命周期与通信。
Worker 有 initial、active、terminated 三种状态,但其它上下文不能直接读取这些状态。在页面仍然存活时,不能指望 Worker 因为空闲就自动进入 terminated;不用了应该从主线程调用 terminate(),或在 Worker 内调用 close()。调试器里可以直接看到当前页面还挂着哪些 Worker:

Worker 不能直接读取主线程里的普通对象,通信大致有三条路:
- 普通可克隆数据走结构化克隆。效果接近一次深拷贝,Worker 修改收到的对象,不会改到发送方那份。
ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas等对象可以走 Transfer。这不是复制,而是转移所有权;例如ArrayBuffer转走后,发送方那边的缓冲区会被分离。

- 真正需要共享一块内存时才用
SharedArrayBuffer,并用Atomics协调读写。页面还得进入 cross-origin isolated 环境,服务端至少要加上这两个响应头:
app.use(async (ctx, next) => {
ctx.set("Cross-Origin-Opener-Policy", "same-origin");
ctx.set("Cross-Origin-Embedder-Policy", "require-corp");
await next();
});
这次图表实验没有用到共享内存,后面不再展开。
OffscreenCanvas
OffscreenCanvas 最有价值的一点,就是没有 DOM 依赖,可以在 Worker 中拿到 2D 或 WebGL 上下文。这次试了两条路径:
- Transfer 模式:Worker 自己创建 OffscreenCanvas,渲染后导出
ImageBitmap,再把 bitmap 交给主线程画到可见 Canvas。 - Commit 模式:先在页面里创建 Canvas,再通过
transferControlToOffscreen()把控制权交给 Worker。后文沿用当时的叫法;严格说,旧示例里的显式commit()已经废弃,这里实际讨论的是 Canvas 控制权转移后的自动呈现。
Transfer 模式
Transfer 模式只把生成位图的工作移出了主线程。最后那次 drawImage 仍然在主线程执行,所以主线程被长任务堵住时,结果还是不能及时显示。这一点决定了它更适合后台生成静态帧,而不是解决持续渲染的卡顿。
ImageData
ImageData 是一块可以由 JavaScript 直接读写的 RGBA 像素数据。下面是最常见的灰度处理:
const canvas = document.getElementById("grayCanvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = "your-image-url.jpg";
img.onload = () => {
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const gray = 0.3 * r + 0.59 * g + 0.11 * b;
data[i] = data[i + 1] = data[i + 2] = gray;
}
ctx.putImageData(imageData, 0, 0);
};
imageData.data 是 Uint8ClampedArray,每 4 个值依次表示一个像素的 RGBA,因此长度是 width * height * 4。需要逐像素处理时用它很直接,代价也同样直接:CPU 要碰每一个值。
ImageBitmap
ImageBitmap 的定位不同:它是一份适合直接绘制的、不透明的位图句柄,没有逐像素读写 API,但实现了 Transferable,可以在线程之间转移。浏览器内部是否把它放在 GPU 资源里属于实现细节;在业务代码里,重要的是别把它当成另一种 ImageData。
Transfer
Worker 里调用 transferToImageBitmap() 取出当前帧,再把 bitmap 放进 postMessage 的 Transfer 列表:
const canvas = new OffscreenCanvas(500, 500);
const ctx = canvas.getContext("2d");
for (let i = 0; i < 5; i++) {
ctx.fillRect(Math.random() * 500, Math.random() * 500, 30, 30);
}
const bitmap = canvas.transferToImageBitmap();
// 第二个参数是 Transfer 数组
postMessage({ bitmap }, [bitmap]);
主线程收到以后,用 drawImage 画到可见 Canvas:
const worker = new Worker("./worker.js");
worker.onmessage = (e) => {
const { bitmap } = e.data;
const canvas = document.createElement("canvas");
canvas.width = 500;
canvas.height = 500;
document.querySelector("body").append(canvas);
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
};
转移完成后,发送方不能继续使用原来的 ImageBitmap。如果每帧都这么做,还要留意 bitmap 的释放和消息积压,不能让 Worker 无限制地产帧。
Commit 模式
这条路径并不是让 Worker 操作 DOM,而是把某个 Canvas 的绘制控制权转成 OffscreenCanvas,再交给 Worker。Worker 直接在同一个画布上绘制,省去了“每帧把 ImageBitmap 发回主线程、再画一次”的中转。
渲染流水线
调用 fillRect()、drawImage() 只是提交绘制指令。浏览器后面还要经过栅格化与图层合成,才会把一帧送到屏幕上。这里通常不止主线程参与,还有栅格、合成和 GPU 相关线程。
真正影响这个方案的区别是:普通 Canvas 的绘制指令由主线程 JavaScript 发起,主线程遇到长任务,下一帧指令就发不出来;控制权交给 Worker 后,绘制循环不再依赖主线程逐帧执行。最终合成仍由浏览器负责,所以这不是“绕过整个渲染流水线”,只是把最容易被业务代码堵住的那一段挪开了。
Commit
Transfer 模式多了一次跨线程消息和主线程绘制;transferControlToOffscreen() 则让 Worker 维护画布内容,由浏览器把更新后的缓冲区纳入合成。对持续动画来说,这条路径更短,也更符合这个实验的目标。

最小代码如下。主线程只负责创建 Worker、转移控制权;绘制循环全部留在 Worker:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OffscreenCanvas Commit Mode Example</title>
</head>
<body>
<canvas id="mainCanvas" width="800" height="600"></canvas>
<script>
// 创建一个新的 Worker
const worker = new Worker("worker.js");
// 获取主线程的 canvas 元素
const canvas = document.getElementById("mainCanvas");
// 将主线程 canvas 转移到 Worker 中作为 OffscreenCanvas
const offscreen = canvas.transferControlToOffscreen();
// 向 Worker 发送 OffscreenCanvas
worker.postMessage({ canvas: offscreen }, [offscreen]);
</script>
</body>
</html>
self.onmessage = function (event) {
const offscreenCanvas = event.data.canvas;
const ctx = offscreenCanvas.getContext("2d", { desynchronized: true });
function draw() {
ctx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
ctx.fillStyle = "blue";
ctx.fillRect(50, 50, 200, 150);
ctx.fillStyle = "white";
ctx.font = "30px Arial";
ctx.fillText("Hello from OffscreenCanvas!", 60, 150);
}
setInterval(draw, 1000);
};
业务场景落地
现有图表代码分成配置层、解析层、渲染层,结构见 图表代码的研究。
配置层和解析层主要是 JavaScript 计算,搬进 Worker 没有太大障碍。麻烦在渲染层:preact、zrender 和手势代码会读取不少 document、window 属性,而 Worker 里没有 DOM。好在这里大多只是读取环境信息。我把需要的值在主线程取出,通过 postMessage 发过去,再在 Worker 里提供一层只读的 document、window 适配,已经能正确画出 OffscreenCanvas 图表。
另一个变化是 API。跨线程调用一定要经过消息,原来同步返回的方法需要改成异步;好在图表本来就大量依赖事件,这部分改造还算可控。

手势和其它直接依赖 DOM 事件的逻辑仍然留在主线程,整理成消息再交给 Worker。到这一步,架构上已经走得通了。当时我预期仪表盘场景能有几倍提升,但这只是落地前的估计,不是基准测试结论;真正决定收益的,还是每张图里计算和绘制各占多少。