基于 Worker 的图表设计

AI 摘要用 OffscreenCanvas + Worker 把图表搬出主线程:Transfer 与 Commit 两种模式、渲染流水线原理、以及业务场景下的落地方案。

在现有图表代码中,图表与图表、图表与业务代码都在同一条线程中,任何一处拥有复杂计算,都会使页面暂时失去响应。

📎 demo.zip

来看我们的 demo 示例,Canvas 会在每个 requestAnimationFrame 中不断地增加 1。上面全部在 main 线程中,下面 5 个在不同的 Worker 里面。当我们点击「复杂业务」按钮时,会发现,上面 5 个图表都直接卡死了。

b371f1e882.gif

我们给 Canvas 增加一个点击事件,点击时进行一次复杂的数学运算,并将数值置 0。可以发现,当我们单击下面 OffscreenCanvas 时,只会影响到对应的 Canvas;但是点击上面的内容,会导致整个页面失去响应。

c4e4b6c1c8.gif

这是怎么设计出来的呢?原因在于我们今天要讲的 OffscreenCanvas——把它与 Worker 结合,就可以得到一个多线程的图表。前半部分主要讲两种不同使用 OffscreenCanvas 的方式,后半部分讲基于业务场景落地可能需要解决的问题。

Worker 基础

因为里面涉及到 Worker,所以读了一遍 JS 红宝书,选了 2 个对于理解 Worker 非常重要的点。

线程状态:Worker 线程存在 initial、active、terminated 这三个状态,但是这三个状态对其它上下文是不可见的。如果 Worker 线程没有调用 terminate 或者 close,那么线程就不会处于 terminated 状态,与之相关的内存信息并不会消失。我们可以通过调试器看到当前浏览器环境中的 Worker 线程。

d8a78d61cd.png

线程通信:在支持传统多线程模型的语言中,可以使用锁、互斥量、以及 volatile 变量。在 JavaScript 中,有三种在上下文之间转移信息的方式:结构化克隆算法(structured clone algorithm)、可转移对象(transferable objects)和共享数组缓冲区(shared array buffers)。

  1. 结构化克隆算法针对的是简单的数据类型,相当于 deepClone,在 Worker 中修改被传递的对象,不会影响到原对象。
  2. 可转移对象针对复杂的数据类型,包括:ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas。如果把 ArrayBuffer 对象指定为可转移对象,那么其对缓冲区内存的引用,就会从父上下文中抹去。

5651002b90.png

  1. SharedArrayBuffer 和 Atomics 可以完全原子化操作。SharedArrayBuffer 在 Chrome 上启动这个特性还挺苛刻的,需要添加两个 cross 标头才能启用:
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 和 Canvas 的区别是 OffscreenCanvas 可以在 Worker 中使用。OffscreenCanvas 主要有两种不同的使用方式:

  • Transfer 模式:在 Worker 线程创建一个 OffscreenCanvas 做后台渲染,然后把渲染好的缓冲区 Transfer 回主线程显示,这个 Transfer 的数据结构就是 ImageBitmap。
  • Commit 模式:在主线程 DOM 树上产生一个 OffscreenCanvas,再把这个 OffscreenCanvas 发送给 Worker 线程进行渲染,在 Worker 中进行的操作,会直接更新在 Canvas 元素上面。

Transfer 模式

Transfer 就是将 Worker 中创建的数据 transfer 回主线程的 Canvas 里面。如果主线程被 JS 引擎卡死,那么 Canvas 还是来不及绘制。Transfer 的数据结构是 ImageBitmap。

ImageData

ImageData 对象表示 canvas 元素指定区域的像素数据。

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);
};

data 是一个 Uint8ClampedArray 类型数组的实例,存储的是像素点的颜色值数据,数组元素按每个像素点的 RGBA 4 通道的数值依次排列,该数组的长度必须为 width * height * 4,否则报错。ImageData 可以基于像素操作。

ImageBitmap

ImageBitmap 和 ImageData 不同的点是,ImageBitmap 是只读的,它主要用于渲染,封闭一块 GPU 缓冲区,可以被 GPU 读写,并且实现了 Transferable 接口,可以在不同线程之间 Transfer。

跟 ImageData 不一样,ImageBitmap 并没有提供 JavaScript API 供 CPU 进行读写,这是因为使用 CPU 读写 GPU 缓冲区的成本非常高,需要拷贝到临时缓冲区进行读写然后再写回。这也是为什么规范的制定者没有扩展 ImageData,而是提供了一个新的 ImageBitmap 的缘故。

Transfer

在 Worker 中,从 OffscreenCanvas 取出内容,通过 postMessage 把数据发送到主线程。其中第二个参数是 Transfer 数组,用于指定第一个参数里面的 buffer 对应的 value 不使用复制,而是 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]);

可以使用 ctx.drawImage 把 transfer 过来的 ImageBitmap 绘制出来:

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 Transfer 之后所有权发生了转移,如果再次使用就会报错,因为原本的缓冲区引用被置空成了一个空引用。

Commit 模式

Commit 模式允许应用在 Worker 线程直接对 DOM 树里面的 Canvas 元素进行更新。Commit 模式比 Transfer 模式更快,因为不会被主线程里面的 JS 阻塞。

渲染流水线

要详细理解 Commit 模式为什么性能更好,有必要了解一下什么是渲染流水线。Canvas 渲染流水线(Rendering Pipeline)是指在 HTML5 的 <canvas> 元素中,将图形和图像从高层抽象的绘制指令,逐步转换为像素并显示在屏幕上的过程:

  1. 开发者通过调用绘制函数(如 fillRect()drawImage() 等)给 canvas 发出绘制命令。
  2. 当你调用绘制函数时,这些命令不会立即生效,而是被添加到一个绘制队列中。
  3. 每一帧的渲染会基于这些入队的命令进行组合。渲染流水线将这些命令转化为底层的图形操作,并最终准备好要绘制的图形。在 WebGL 上,这些操作会被转化为 GPU 调用。
  4. 一旦绘制指令被组合好,浏览器会将这些命令转化为像素,并通过显卡在屏幕上显示——也就是栅格化以及纹理映射。
  5. 渲染流水线可能包含多个图层(layers),特别是如果涉及到 CSS 动画、其他 HTML 元素或者复杂的 WebGL 场景。浏览器需要合成这些图层并将最终的图像在屏幕上显示。

整个渲染流水线会涉及到多个线程的参与:

  1. 主线程:JavaScript 代码执行、DOM 操作、布局计算、样式计算。
  2. 合成线程:主要负责图层的合成操作,将不同的图层组合到一起,形成最终画面。
  3. 栅格线程:主要负责栅格化,也就是将矢量图形转换为实际的像素数据。
  4. GPU 线程:负责将光栅化后的像素数据发送到 GPU,并控制实际的渲染流程。

在浏览器中,动画和渲染过程分为合成器动画和非合成器动画。

合成动画是直接由合成线程处理的动画,不需要走完整的渲染流水线(包括样式计算、布局计算、绘制)。这类动画会利用 GPU 加速,并通过直接操作图层进行,例如 transform、opacity、filter 等 CSS 属性,因为不依赖于主线程的每次更新,即使主线程被阻塞,合成线程依然可以处理这些动画。

非合成动画需要主线程执行 JS 或重新计算样式、布局,因此会触发页面的完整渲染过程(例如重排和重绘)。如果主线程负担过重,动画可能会出现卡顿或掉帧现象。传统的 Canvas 走的是非合成动画,这是因为 JavaScript 可以操作 Canvas 的绘制命令。

Commit

传统的 Canvas 存在以下致命的问题:如果页面结构过于复杂,那么就会有较多的 Overhead;如果同时有其它的 DOM 元素更新,Canvas 的更新就会被其它 DOM 元素的光栅化所阻塞,导致性能下降。

如果直接使用 Commit 模式,那么浏览器就会直接将 OffscreenCanvas 的当前绘制缓冲区发送给 Display Compositor,然后 Display Compositor 就会合成新的一帧输出到当前窗口,这样就可以拥有较短的渲染路径。Commit 走的是下面的路径,所以更快。

b6684f1bcd.png

下面是代码演示:

<!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);
};

业务场景落地

我们当前图表分三层:配置层、解析层、渲染层,详见 图表代码的研究

配置层和解析层较为简单,主要是一些 JS 计算,这部分可以直接放在 worker 里面。渲染层中主要是 preact、zrender、以及一些手势。这里面用到了不少 document 和 window 里面的属性,不过幸运的是我们只是读取了相应的信息。这样我们可以通过 postMessage 将相应的信息发送到 worker 中,在 worker 中构造一份只读的 document 和 window 属性。我尝试了一下,将它们全部提取出来后,正确画出了基于 OffscreenCanvas 的图表。

因为 JS 没有线程挂起这个机制,所以需要将原本图表对外暴露的方法都改成异步方法。其实影响也不大,我们图表很多习惯都是直接对事件进行监听。

c4adaaf7aa.png

而手势这些与交互相关的行为就只能放到 Main 线程了。如果这个架构能落地,仪表盘上的图表应该可以提高几倍性能,看起来还是十分有吸引力的。