JEPA4Japan · 教程

第 1 章:先别画——Canvas 不是产品架构

3,778字 11分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

分别用 DOM、SVG、Canvas 和 GPU 方案渲染同一张业务图,并记录第一份渲染器选型 ADR。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

准备一张白纸、一张透明塑料片、一盒积木和一块可以擦掉重画的小黑板。在纸上写下“订单 A”,在塑料片上画一根箭头,把积木摆成两个方块,最后在黑板上涂一片蓝色。

继续之前,先预测四件事:哪种材料上的文字最容易编辑?哪一种能让你看到它下面的文字?哪一种可以直接用手移动?擦掉之后,哪一种最难知道那里原来有什么?亲手试一次,然后把“订单 A”里的 A 改成 B。你会发现:“它们都能表示同一幅画”,并不意味着“在每一种材料上完成同一操作的成本都相同”。

  1. 在纸上写字可阅读,也可选择
  2. 在薄膜上画线缩放后依然清晰
  3. 摆放积木每块积木都有身份
  4. 给黑板上色留下的只有像素
选择“怎么画”之前,先预测“以后要怎么编辑”。材料并不等于产品。

这就是本章唯一的核心事实:无限画布是一种产品交互模型,用户可以在其中平移、缩放和放置对象;它不是一种渲染技术。 DOM、SVG、Canvas 2D、GPU,或几者的组合,都可以实现无限画布。

把玩具翻译成 Canvas 工程

玩具材料对应的工程技术真正需要提出的问题
白纸上的文字DOM用户是否需要输入、选择、听取内容,或让内容自动布局?
透明薄膜上的线条SVG是否需要矢量几何和可单独寻址的节点?
可擦黑板上的颜色Canvas 2D 位图是否需要大量自定义二维像素绘制?
许多厨师同时摆盘WebGL / WebGPU是否有证据证明批处理或并行计算能够消除瓶颈?
贴在黑板上的纸DOM/Canvas 混合方案在 DOM 中保留文本编辑,把图形绘制交给 Canvas
贴在 GPU 显示屏上的纸DOM/GPU 混合方案在 DOM 中保留语义层,把高吞吐渲染交给 GPU
“一张可以无限移动的桌面”无限画布一种不限定底层材料的产品行为

这个类比到这里就会失效:物理材料一次只能占据一个位置,而浏览器可以把 DOM 叠在 Canvas 上。SVG 也不是真正的透明薄膜,浏览器最终仍会把它光栅化。GPU 更不是一位永远更快的魔法厨师:数据上传、着色器、设备丢失和无障碍都要付出成本。类比能帮助我们先问对问题,却不能替代测量、规范和兼容性测试。

先消灭具有误导性的直觉

  1. “无限画布就应该使用 <canvas>。” 错。无限画布只承诺相机与对象交互。当产品包含大量文字、适量对象,并且语义很重要时,DOM/SVG 往往更合适。
  2. “对象很多就一定要用 Canvas。” 错。总共有十万个对象、但屏幕上只显示十个,与两千个带阴影和图片的可见对象,成本完全不同。先测量可见数量、变化频率和像素面积。
  3. “Three.js 是 Canvas 2D 的升级版。” 错。Three.js 解决的是场景、相机、网格、材质和深度等三维问题。如果普通二维白板不需要这些能力,Three.js 带来的只会是另一套更复杂的约束。
  4. “tldraw 用 Canvas 2D 绘制每个 Shape。” 错。tldraw 的官方 Shape 契约要求 component() 返回一个 React 组件,而导出路径会生成 SVG。库的名字和产品外观都不能证明它使用何种渲染器。这里描述的是 Shape 的主要渲染架构,并不否认个别组件内部可能使用 <canvas>。

生产工程背包

前置契约

本章不要求你了解矩阵或命中测试。你只需要能够运行浏览器、TypeScript 和 Vitest。首先固定产品需求:Canvas Lab 是一个业务流程图编辑器。卡片需要原生标题编辑,连接线缩放时必须保持清晰,网格会频繁移动,导出必须包含 PNG 和结构化 JSON,并且键盘和屏幕阅读器访问都不能缺席。

正式知识:按维度选择,而不是凭信念选择

渲染器优势主要成本它在 Canvas Lab 中的位置
DOM原生文本、CSS 布局、语义、焦点和自动化测试大量频繁变化的节点会增加样式与布局成本工具栏、表单、文本编辑和检查器
SVG矢量、可寻址的几何节点、CSS 与清晰导出大量持续变化的节点需要测量 DOM 成本小规模连接线或导出的候选方案
Canvas 2D直接控制二维绘制、成熟 API,并适合频繁重绘没有保留对象、原生语义或文本编辑教学内核中的网格、形状与覆盖层
WebGL成熟的 GPU 管线、批处理与着色器必须自行管理缓冲区、纹理、拾取与上下文丢失分析提供证据后的替换候选
WebGPU更现代的 GPU 计算与渲染接口支持范围、设备/管线管理与降级成本渐进增强实验,而非基线
DOM/Canvas语义 UI 与高频像素层各司其职两层之间的坐标、焦点和堆叠必须保持同步当前选择
DOM/GPU原生 UI 加高吞吐渲染两套生命周期和更高的工程门槛规模很大且 GPU 收益得到证明时

评审必须记录每一个维度:原生文本编辑、DOM 语义、无障碍、可见对象数量、对象总数、每秒变化次数、像素效果、视觉与语义导出要求、渲染器替换成本、多人协作模型、目标浏览器,以及团队能否维护几何/GPU 代码。协作本身并不能决定渲染器;它同步的是文档操作,不是像素。

证据与兼容性

浏览器将 <canvas> 定义为一种可以绑定 2d、webgl、webgl2 和 webgpu 等上下文的元素。这个事实本身就说明,该元素并不是某一种具体产品。发布前,应检查目标浏览器的兼容性表并在真实设备上做基准测试;不要把“Baseline”当成性能保证。

以上来源核查于 2026-08-29。WebGPU 的可用性与能力必须在运行时检测;本教程不会把某个浏览器版本号冻结成永久事实。

本章的工程增量

**起点:**一个空页面和一份业务数据。**终点:**DOM、SVG 与 Canvas 2D 实现展示同一张“订单 → 发货”业务图,项目生成第一份 ADR。计划中的文件:

canvas-lab/
  src/lab/ch01/render-three-ways.ts
  src/lab/ch01/index.html
  src/lab/ch01/renderer-choice.ts
  src/lab/ch01/renderer-choice.test.ts
  docs/adr/0001-canvas-2d-teaching-kernel.md

完整的 HTML 容器:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Canvas Lab / Renderer</title>
  </head>
  <body>
    <main>
      <section>
        <h1>DOM</h1>
        <div id="dom-demo"></div>
      </section>
      <section>
        <h1>SVG</h1>
        <svg id="svg-demo" viewBox="0 0 360 140" aria-label="Order process diagram"></svg>
      </section>
      <section>
        <h1>Canvas</h1>
        <canvas id="canvas-demo" width="360" height="140">Order to shipping</canvas>
      </section>
    </main>
    <script type="module" src="./render-three-ways.ts"></script>
  </body>
</html>

完整的 TypeScript:三条路径使用同一份数据,因此没有任何业务对象被锁定到某个渲染器。

type Card = { id: string; label: string; x: number; y: number };
type Diagram = { cards: readonly Card[]; edge: readonly [string, string] };

export const diagram: Diagram = {
  cards: [
    { id: 'order', label: 'Order', x: 20, y: 40 },
    { id: 'ship', label: 'Shipping', x: 240, y: 40 },
  ],
  edge: ['order', 'ship'],
};

function boxStyle(card: Card): string {
  return `position:absolute;left:${card.x}px;top:${card.y}px;width:100px;height:56px;border:2px solid #2563eb;border-radius:10px;display:grid;place-items:center`;
}

export function renderDOM(root: HTMLElement, model: Diagram): void {
  root.replaceChildren();
  root.style.cssText = 'position:relative;width:360px;height:140px';
  const line = document.createElement('div');
  line.style.cssText =
    'position:absolute;left:120px;top:68px;width:120px;border-top:2px solid #64748b';
  root.append(line);
  for (const card of model.cards) {
    const article = document.createElement('article');
    article.dataset.shapeId = card.id;
    article.style.cssText = boxStyle(card);
    article.textContent = card.label;
    root.append(article);
  }
}

export function renderSVG(root: SVGSVGElement, model: Diagram): void {
  const ns = 'http://www.w3.org/2000/svg';
  root.replaceChildren();
  const line = document.createElementNS(ns, 'line');
  Object.entries({
    x1: '120',
    y1: '68',
    x2: '240',
    y2: '68',
    stroke: '#64748b',
    'stroke-width': '2',
  }).forEach(([key, value]) => line.setAttribute(key, value));
  root.append(line);
  for (const card of model.cards) {
    const group = document.createElementNS(ns, 'g');
    group.dataset.shapeId = card.id;
    const rect = document.createElementNS(ns, 'rect');
    Object.entries({
      x: String(card.x),
      y: String(card.y),
      width: '100',
      height: '56',
      rx: '10',
      fill: 'white',
      stroke: '#2563eb',
      'stroke-width': '2',
    }).forEach(([key, value]) => rect.setAttribute(key, value));
    const text = document.createElementNS(ns, 'text');
    text.setAttribute('x', String(card.x + 50));
    text.setAttribute('y', String(card.y + 34));
    text.setAttribute('text-anchor', 'middle');
    text.textContent = card.label;
    group.append(rect, text);
    root.append(group);
  }
}

export function renderCanvas(canvas: HTMLCanvasElement, model: Diagram): void {
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('Canvas 2D unavailable');
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.strokeStyle = '#64748b';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(120, 68);
  ctx.lineTo(240, 68);
  ctx.stroke();
  for (const card of model.cards) {
    ctx.fillStyle = 'white';
    ctx.strokeStyle = '#2563eb';
    ctx.beginPath();
    ctx.roundRect(card.x, card.y, 100, 56, 10);
    ctx.fill();
    ctx.stroke();
    ctx.fillStyle = '#0f172a';
    ctx.textAlign = 'center';
    ctx.font = '16px system-ui';
    ctx.fillText(card.label, card.x + 50, card.y + 34);
  }
}

renderDOM(document.querySelector('#dom-demo')!, diagram);
renderSVG(document.querySelector('#svg-demo')!, diagram);
renderCanvas(document.querySelector('#canvas-demo')!, diagram);

决策接口与测试为 ADR 提供可验证的输入:

export type ProductNeeds = Readonly<{
  nativeSceneText: boolean;
  semanticScene: boolean;
  visibleDynamicShapes: number;
  changesPerSecond: number;
  benchmark?: Readonly<{ domP95Ms: number; canvasP95Ms: number; frameBudgetMs: number }>;
}>;
type SceneChoice = 'dom' | 'canvas2d' | 'prototype-required';

export function chooseLayers(n: ProductNeeds): { ui: 'dom'; scene: SceneChoice } {
  if (n.nativeSceneText || n.semanticScene) return { ui: 'dom', scene: 'dom' };
  if (!n.benchmark) return { ui: 'dom', scene: 'prototype-required' };
  const canvasWins =
    n.benchmark.canvasP95Ms <= n.benchmark.frameBudgetMs &&
    n.benchmark.canvasP95Ms < n.benchmark.domP95Ms;
  return { ui: 'dom', scene: canvasWins ? 'canvas2d' : 'dom' };
}
import { describe, expect, it } from 'vitest';
import { chooseLayers } from './renderer-choice';
describe('renderer ADR rule', () => {
  it('never moves semantic product UI into the bitmap', () => {
    expect(
      chooseLayers({
        nativeSceneText: true,
        semanticScene: true,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
      }),
    ).toEqual({ ui: 'dom', scene: 'dom' });
  });
  it('refuses to turn an object count into an unmeasured renderer verdict', () => {
    expect(
      chooseLayers({
        nativeSceneText: false,
        semanticScene: false,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
      }).scene,
    ).toBe('prototype-required');
  });
  it('permits Canvas only when the measured scene meets its frame budget', () => {
    expect(
      chooseLayers({
        nativeSceneText: false,
        semanticScene: false,
        visibleDynamicShapes: 2000,
        changesPerSecond: 120,
        benchmark: { domP95Ms: 24, canvasP95Ms: 8, frameBudgetMs: 12 },
      }).scene,
    ).toBe('canvas2d');
  });
});

ADR 必须说明:Canvas 2D 被选作教学内核,是为了让你亲自实现位图生命周期、渲染器、几何系统与调度机制;工具栏、表单和文本编辑仍留在 DOM 中。替换触发条件包括:“当原生文本与语义占据主导时,回到 DOM/SVG”,以及“当基准测试证明 Canvas 2D 无法满足 p95 帧预算,同时 GPU 原型确实带来改善时,再迁移到 GPU”。兼容性、团队能力、导出、协作和退出成本也必须记录。

visibleDynamicShapes 和 changesPerSecond 会进入实验场景与 ADR,但它们不是适用于所有产品的通用阈值。没有基准数据时返回“需要原型验证”,可以防止示例数字伪装成普遍规律。运行 npm run dev,浏览器应显示三张内容相同的图,其中 DOM 文本可以选择。运行 npx vitest run src/lab/ch01/renderer-choice.test.ts,预期结果为 3 passed。

回到开头的游戏:我们并没有争论纸张和黑板谁“更先进”。我们把可编辑文字写在纸上,把需要反复重绘的场景放在黑板上。

故意让它出错

把标题和普通表单也画进 Canvas,然后从页面中移除真正的 <h1>、<label> 和 <input> 元素。

注入的故障症状证据修复回归测试恢复方式
像素标题标题无法选择,也不在文档大纲中开发者工具无障碍树中没有标题使用真正的 <h1>;Canvas 只绘制场景getByRole('heading') 成功恢复 DOM 标题
像素输入框Tab 无法进入;屏幕阅读器不知道它的标签和值键盘录制与无障碍树使用 <label><input> DOM 覆盖层只用键盘即可输入并提交删除伪造的输入框
Canvas 布局的表单CSS Grid、换行与验证都要手工重写比较实现体积和浏览器测试数量产品 UI 留在 DOM缩放到 200% 时无重叠恢复原布局
坐标断言测试字体或 DPR 改变就让测试变得脆弱换一个操作系统后测试失败查询 DOM role;测试 Canvas 模型与截图Node 单元测试加浏览器视觉测试删除硬编码坐标

屏幕阅读器、键盘、文本选择、CSS、布局与测试,并不是以后再添加的装饰;它们都是选择渲染器时必须计算的成本。完成故障实验后,恢复真正的 DOM 控件,并重新运行键盘与屏幕阅读器检查。

用证据验收

需要证明的结论可接受的证据不可接受的回答
哪一层需要 Canvas动态可见对象与帧追踪、ADR 和原型“Canvas 更快”
哪一层保留 DOM键盘、语义树与文本编辑要求“大家都这么做”
渲染器可替换三种实现都使用同一个 Diagram 类型把业务对象存成 DOM 节点
兼容性受控目标浏览器测试与特性检测只看一张全球支持表
  • 我能解释无限画布与 <canvas> 元素的区别。
  • 我比较过 DOM、SVG、Canvas 2D、WebGL、WebGPU 和两种混合方案。
  • 我填写了全部十二个选择维度,而不是只看对象数量。
  • 三种实现展示同一张业务图,并使用同一份数据。
  • Canvas 伪表单故障留下了录屏、语义树证据和测试,并且已经恢复。
  • ADR 记录了当前选择、替换条件,以及协作/导出/许可/团队成本。

给五岁孩子讲明白

不要使用“渲染器”“DOM”或“GPU”这些词,回答:为什么一本能够自己朗读的故事书,不应该被完整画成一张照片?再回答:为什么一幅快速移动、色彩丰富的背景,不适合由一万张可编辑的小纸条拼成?

展开一个不用术语的好答案

照片很擅长一次展示许多颜色,却不知道哪一部分是标题,也不知道哪里可以输入。负责朗读书籍的机器同样不知道应该先读什么。因此,文字和按钮应当继续是能够被找到、选择和操作的东西。反过来,如果把一大片不断移动的颜色拆成太多小纸条,每次移动时都重新摆放所有纸条,就会变成大量工作。最好的办法,是让人们必须理解和操作的东西保留各自的身份,再把需要快速重画的部分交给画板——就像我们在游戏里把纸贴到黑板上一样。