Worker 图表运行时

摘要把图表计算与渲染移入 Worker,比较 ImageBitmap Transfer 和 OffscreenCanvas 两种跨线程执行模型,以及它们怎样隔离主线程故障域。

文章目录10 节
  1. Worker 基础
  2. OffscreenCanvas
  3. Transfer 模式
  4. ImageData
  5. ImageBitmap
  6. Transfer
  7. Commit 模式
  8. 渲染流水线
  9. Commit
  10. 业务场景落地

当时图表渲染和业务代码都挤在主线程里。仪表盘上只要有一处计算稍重,其它图表也会一起失去响应。我做了一个很小的 demo,先确认把 Canvas 移进 Worker 到底能解决多少问题。

demo 里,每个 Canvas 都在 requestAnimationFrame 中把数字加 1。上面的 5 个跑在主线程,下面的 5 个各自放在 Worker。点击「复杂业务」后,上面一排同时停住,下面一排继续更新:

b371f1e882.gif

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

c4e4b6c1c8.gif

旧 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:

d8a78d61cd.png

Worker 不能直接读取主线程里的普通对象,通信大致有三条路:

  1. 普通可克隆数据走结构化克隆。效果接近一次深拷贝,Worker 修改收到的对象,不会改到发送方那份。
  2. ArrayBufferMessagePortImageBitmapOffscreenCanvas 等对象可以走 Transfer。这不是复制,而是转移所有权;例如 ArrayBuffer 转走后,发送方那边的缓冲区会被分离。

5651002b90.png

  1. 真正需要共享一块内存时才用 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.dataUint8ClampedArray,每 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 维护画布内容,由浏览器把更新后的缓冲区纳入合成。对持续动画来说,这条路径更短,也更符合这个实验的目标。

b6684f1bcd.png

最小代码如下。主线程只负责创建 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 和手势代码会读取不少 documentwindow 属性,而 Worker 里没有 DOM。好在这里大多只是读取环境信息。我把需要的值在主线程取出,通过 postMessage 发过去,再在 Worker 里提供一层只读的 documentwindow 适配,已经能正确画出 OffscreenCanvas 图表。

另一个变化是 API。跨线程调用一定要经过消息,原来同步返回的方法需要改成异步;好在图表本来就大量依赖事件,这部分改造还算可控。

c4adaaf7aa.png

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