JEPA4Japan · 教程

第 3 章:把绘制变成可重放的配方

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

以确定性的多阶段渲染器取代增量绘制,让它可以根据文档重建完整视图。

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

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

找两块饼干、一颗草莓和一张纸。第一次做“笑脸蛋糕”时,随意摆好所有东西,然后请朋友把盘子清空。现在只告诉他:“把刚才那一点放回去。”他并不知道“那一点”应该放在哪里。第二次先写一份配方:擦干净盘子;把两块饼干放在 (30, 20) 和 (70, 20);把草莓放在 (50, 55);最后画出嘴巴。清空盘子后,把配方交给另一位朋友,他也能重新做出笑脸。

先预测:如果“倒入半透明糖浆”这一步没有写明“之后换回普通糖浆”,下一块饼干会怎样?如果“只在圆盘内部撒糖”没有结束标记,盘子外的装饰又会怎样?

  1. 读取完整配方对象与相机
  2. 擦干净盘子不要依赖旧颜色
  3. 按层摆放背景 → 形状 → 提示
  4. 比较结果每次都相同
可靠的绘制是一份可重放的配方,而不是在旧盘子上打补丁。

本章唯一的核心事实是:**可靠的 Canvas 渲染器必须能够根据数据重建完整场景。**目标接口是 render(model, camera, context),而不是“用户拖动了,所以在旧像素上再涂一块”。

把玩具翻译成 Canvas 工程

蛋糕游戏中的概念Canvas 工程用途
配料清单文档模型保存事实
摆盘视角相机决定世界如何投影
配方纯渲染器相同输入产生相同绘制命令
擦干净盘子清除/背景阶段移除旧位图
模具轮廓Path / Path2D描述几何边界
糖浆与描边Fill / Stroke为路径内部或边缘着色
戴上和脱下手套save() / restore()隔离 Transform、Alpha、Clip 与样式
各司其职的厨师渲染阶段确定背景、网格、形状与覆盖层顺序

这个类比在这里失效:Canvas 当前路径并不完全属于保存状态栈,因此不要以为 save() 会清除 beginPath() 之前创建的路径。Path2D 是可复用的几何容器,却不是文档中拥有稳定 ID 的业务对象。浏览器字体、图像解码和抗锯齿也意味着不同平台上的像素未必逐位相同。因此,确定性必须同时在“命令顺序/模型结果”层面,以及“允许误差范围内的视觉结果”层面验证。

先消灭具有误导性的直觉

  • “拖动时,擦掉旧矩形再画一个新矩形最快。” 旧矩形下面可能有网格、线条或透明层。没有严谨的脏区算法,局部修补会留下残影。教学内核先重建完整场景。
  • “save() 会自动与 restore() 配对。” 不会。异常或提前返回会泄漏状态,所以要使用 try/finally。
  • “每个 Shape 都可以随意修改 Context。” 一个 Shape 的透明度、裁剪、变换或 lineJoin 会污染之后的所有 Shape。Registry 必须建立隔离边界。
  • “看起来一样,就是确定性的。” 随机数、当前时间、尚未加载的字体/图片,以及没有稳定顺序的对象,都可能改变下一次结果。
  • “Path 就是 Shape。” Path 只包含几何信息,没有业务身份、父子关系、权限或持久化语义。

生产工程背包

前置契约

前提是第 2 章的 CanvasHost。每当位图尺寸改变,它会恢复基础 DPR Transform 并调用渲染器。文档是只读输入,渲染器不修改它。相机是显式输入。z 值相同时,使用稳定 ID 打破平局。时间和图像资源通过显式参数传入。

正式知识

Canvas 2D 路径可以使用当前 Path(beginPath/moveTo/lineTo/...)或 Path2D 构建。fill() 填充内部,stroke() 沿边缘绘制。lineCap 控制开放线段端点,lineJoin 控制拐角。两者都会影响可见边界,因此后续几何章节必须与它们保持一致。

clip() 会把之后的绘制限制在某个区域,并且只能通过恢复之前的状态来结束,所以要用 save/restore 包裹。globalCompositeOperation 决定源像素如何与已有目标像素组合,globalAlpha 随后调整整体透明度。两者任一泄漏,都会让“之后绘制的对象消失或变淡”。Gradient 由 Context 创建并包含色标;Pattern 会重复图像或 Canvas。它们是渲染资源,不应塞进序列化文档。drawImage() 可以绘制 HTMLImageElement、ImageBitmap 等来源,资产章节会处理解码状态和 CORS。通过 fillText() 绘制的基础文字适合预览标签,却不提供光标、选择、输入法或富文本编辑。

渲染器应使用固定阶段。Background 完整建立底色;Grid 绘制与相机相关的参考线;Shape 按 z 顺序绘制;Overlay 绘制选择框和提示,但不写入文档。为每个阶段和每个 Shape 设置 withSavedContext 边界,使上下文状态隔离可以审计。这里的“纯渲染器”并不意味着数学意义上的无副作用——它必然写入位图。它的含义是:除目标 Context 外,不修改模型、不读取隐藏全局状态,并且相同输入产生相同命令序列。

证据与兼容性

以上来源核查于 2026-08-29。请检查目标浏览器对 roundRect() 和部分 Path2D 用法的支持。不支持时,使用 moveTo/lineTo/quadraticCurveTo 构建同样的轮廓。视觉回归测试必须固定字体、DPR、色彩空间和资源解码时点。

本章的工程增量

**起点:**第 2 章 Host 中的示例回调直接调用 fillRect。**终点:**固定四个阶段;矩形、椭圆、折线、图片占位符和基础文本标签全部根据文档重建。

canvas-lab/src/lab/ch03/
  model.ts
  renderer.ts
  renderer.test.ts

完整的模型与渲染器,关键逻辑没有隐藏省略:

export type Camera = { x: number; y: number; zoom: number };
export type Shape =
  | {
      id: string;
      type: 'rectangle';
      x: number;
      y: number;
      w: number;
      h: number;
      fill: string;
      z: number;
    }
  | {
      id: string;
      type: 'ellipse';
      x: number;
      y: number;
      rx: number;
      ry: number;
      fill: string;
      z: number;
    }
  | {
      id: string;
      type: 'polyline';
      points: readonly { x: number; y: number }[];
      stroke: string;
      z: number;
    }
  | {
      id: string;
      type: 'image';
      x: number;
      y: number;
      w: number;
      h: number;
      label: string;
      z: number;
    }
  | { id: string; type: 'text'; x: number; y: number; text: string; color: string; z: number };
export type DocumentModel = {
  background: string;
  shapes: readonly Shape[];
  selectedIds: ReadonlySet<string>;
};

function isolated(ctx: CanvasRenderingContext2D, draw: () => void): void {
  ctx.save();
  try {
    draw();
  } finally {
    ctx.restore();
  }
}

function shapeBounds(shape: Shape): { x: number; y: number; w: number; h: number } {
  if (shape.type === 'rectangle' || shape.type === 'image') return shape;
  if (shape.type === 'ellipse')
    return { x: shape.x - shape.rx, y: shape.y - shape.ry, w: shape.rx * 2, h: shape.ry * 2 };
  if (shape.type === 'text')
    return { x: shape.x, y: shape.y - 16, w: shape.text.length * 10, h: 20 };
  if (shape.points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
  const xs = shape.points.map((p) => p.x),
    ys = shape.points.map((p) => p.y);
  return {
    x: Math.min(...xs),
    y: Math.min(...ys),
    w: Math.max(...xs) - Math.min(...xs),
    h: Math.max(...ys) - Math.min(...ys),
  };
}

function drawShape(ctx: CanvasRenderingContext2D, shape: Shape): void {
  isolated(ctx, () => {
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = 'source-over';
    if (shape.type === 'rectangle') {
      const path = new Path2D();
      path.rect(shape.x, shape.y, shape.w, shape.h);
      ctx.fillStyle = shape.fill;
      ctx.fill(path);
      ctx.strokeStyle = '#0f172a';
      ctx.lineWidth = 1;
      ctx.stroke(path);
      return;
    }
    if (shape.type === 'ellipse') {
      ctx.beginPath();
      ctx.ellipse(shape.x, shape.y, shape.rx, shape.ry, 0, 0, Math.PI * 2);
      ctx.fillStyle = shape.fill;
      ctx.fill();
      return;
    }
    if (shape.type === 'polyline') {
      const [first, ...rest] = shape.points;
      if (!first) return;
      ctx.beginPath();
      ctx.moveTo(first.x, first.y);
      for (const point of rest) ctx.lineTo(point.x, point.y);
      ctx.strokeStyle = shape.stroke;
      ctx.lineWidth = 3;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      ctx.stroke();
      return;
    }
    if (shape.type === 'image') {
      ctx.fillStyle = '#e2e8f0';
      ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
      ctx.strokeStyle = '#64748b';
      ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
      ctx.beginPath();
      ctx.moveTo(shape.x, shape.y);
      ctx.lineTo(shape.x + shape.w, shape.y + shape.h);
      ctx.moveTo(shape.x + shape.w, shape.y);
      ctx.lineTo(shape.x, shape.y + shape.h);
      ctx.stroke();
      ctx.fillStyle = '#334155';
      ctx.fillText(shape.label, shape.x + 8, shape.y + 18);
      return;
    }
    ctx.fillStyle = shape.color;
    ctx.font = '16px system-ui';
    ctx.textBaseline = 'alphabetic';
    ctx.fillText(shape.text, shape.x, shape.y);
  });
}

export function renderDocument(
  model: DocumentModel,
  camera: Camera,
  ctx: CanvasRenderingContext2D,
  viewport: { width: number; height: number; dpr: number },
  trace: (event: string) => void = () => undefined,
): void {
  const ordered = [...model.shapes].sort((a, b) => a.z - b.z || a.id.localeCompare(b.id));
  trace('pass:background');
  isolated(ctx, () => {
    ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
    ctx.fillStyle = model.background;
    ctx.fillRect(0, 0, viewport.width, viewport.height);
  });
  trace('pass:grid');
  isolated(ctx, () => {
    const scale = camera.zoom * viewport.dpr;
    ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
    ctx.strokeStyle = '#e2e8f0';
    ctx.lineWidth = 1 / camera.zoom;
    const left = Math.floor(camera.x / 20) * 20,
      top = Math.floor(camera.y / 20) * 20;
    for (let x = left; x < camera.x + viewport.width / camera.zoom; x += 20) {
      ctx.beginPath();
      ctx.moveTo(x, camera.y);
      ctx.lineTo(x, camera.y + viewport.height / camera.zoom);
      ctx.stroke();
    }
    for (let y = top; y < camera.y + viewport.height / camera.zoom; y += 20) {
      ctx.beginPath();
      ctx.moveTo(camera.x, y);
      ctx.lineTo(camera.x + viewport.width / camera.zoom, y);
      ctx.stroke();
    }
  });
  trace('pass:shape');
  isolated(ctx, () => {
    const scale = camera.zoom * viewport.dpr;
    ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
    for (const shape of ordered) {
      trace(`shape:${shape.id}`);
      drawShape(ctx, shape);
    }
  });
  trace('pass:overlay');
  isolated(ctx, () => {
    const scale = camera.zoom * viewport.dpr;
    ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
    ctx.strokeStyle = '#f97316';
    ctx.lineWidth = 2 / camera.zoom;
    ctx.setLineDash([6 / camera.zoom, 4 / camera.zoom]);
    for (const shape of ordered) {
      if (!model.selectedIds.has(shape.id)) continue;
      trace(`selection:${shape.id}`);
      const b = shapeBounds(shape);
      ctx.strokeRect(b.x, b.y, b.w, b.h);
    }
  });
}

这里有意把 viewport.width/height 定义为 CSS 像素,并让每次 setTransform() 都包含 dpr。第 2 章的 Host 会先建立 DPR Transform,但渲染器自己调用 setTransform() 时会替换它。如果这里写成单位矩阵,第 2 章设定的高分辨率位图就只会在左上角区域绘制。trace 是可选的测试探针,它不会进入文档,也不会改变绘制结果。

Background 阶段可以用 createLinearGradient() 创建 Gradient,用 createPattern(decodedImage, 'repeat') 创建 Pattern。真实资源使用 drawImage(),并必须由显式的 Resource Registry 提供。资源尚未解码时,应绘制上面实现的占位符;绝不要让异步回调偷偷给旧位图打补丁。

测试通过状态快照证明隔离,并用重复的命令记录证明顺序具有确定性:

import { describe, expect, it, vi } from 'vitest';
import { renderDocument, type DocumentModel } from './renderer';

describe('deterministic renderer', () => {
  it('restores caller state and emits the same ordered draw trace', () => {
    const canvas = document.createElement('canvas');
    canvas.width = 640;
    canvas.height = 400;
    const ctx = canvas.getContext('2d')!;
    const model: DocumentModel = {
      background: '#fff',
      selectedIds: new Set(['b']),
      shapes: [
        { id: 'b', type: 'rectangle', x: 20, y: 20, w: 60, h: 40, fill: '#bfdbfe', z: 2 },
        { id: 'a', type: 'text', x: 30, y: 45, text: 'Order', color: '#111827', z: 2 },
      ],
    };
    const setTransform = vi.spyOn(ctx, 'setTransform');
    ctx.globalAlpha = 0.37;
    ctx.setLineDash([9, 3]);
    const first: string[] = [];
    renderDocument(
      model,
      { x: 0, y: 0, zoom: 1 },
      ctx,
      { width: 320, height: 200, dpr: 2 },
      (event) => first.push(event),
    );
    expect(first).toEqual([
      'pass:background',
      'pass:grid',
      'pass:shape',
      'shape:a',
      'shape:b',
      'pass:overlay',
      'selection:b',
    ]);
    expect(setTransform).toHaveBeenCalledWith(2, 0, 0, 2, 0, 0);
    expect(ctx.globalAlpha).toBe(0.37);
    expect(ctx.getLineDash()).toEqual([9, 3]);
    ctx.clearRect(0, 0, 640, 400);
    const second: string[] = [];
    renderDocument(
      model,
      { x: 0, y: 0, zoom: 1 },
      ctx,
      { width: 320, height: 200, dpr: 2 },
      (event) => second.push(event),
    );
    expect(second).toEqual(first);
    expect(model.shapes.map((s) => s.id)).toEqual(['b', 'a']);
  });
});

运行 npx vitest run src/lab/ch03/renderer.test.ts --environment jsdom,预期结果为 1 passed。在浏览器视觉测试中,先清空 Canvas,再提供相同的 Document/Camera;截图必须在定义的容差内匹配。回到配方:渲染器每次都会擦干净盘子,再执行固定流程。任何饼干都不依赖“它上次大概在这里”。

故意让它出错

故意删除 drawShape 外层的 restore(),然后在第一个 Shape 中设置一个变换、globalAlpha=0.2、圆形裁剪和 lineJoin='bevel'。

注入的故障症状证据修复回归测试恢复方式
Transform 泄漏后续对象偏移/旋转记录 getTransform() 与截图save + finally restore调用前后的变换相等恢复隔离器
Alpha 泄漏之后的每个对象都变淡渲染后的 globalAlpha 与原值不同显式隔离每个 ShapeAlpha 哨兵断言移除注入
Clip 泄漏圆形区域外的对象消失后续 Shape 命令存在,但像素不存在把 clip 保留在配对状态内截图证明裁剪后的对象仍可见清空并完整重绘
Line Style 泄漏折线端点/拐角错误lineCap/Join 快照在 Shape 内设置并在结束时恢复测试 Shape 顺序的每种排列恢复默认顺序
随机数/时间进入渲染相同输入得到不同截图对比两次命令追踪把时钟/种子设为显式输入固定种子可完全重放移除全局读取

每次注入都遵循同一个循环:启用一个故障开关 → 记录症状 → 保存命令/状态/截图证据 → 修复隔离边界 → 运行单元与视觉回归测试 → 清空 Canvas,仅根据数据恢复。不要用“把有问题的 Shape 移到最后”来隐藏污染。

用证据验收

验收门槛自动/手动证据
位图可以完全清空自动测试显式调用 clearRect 后依然能重建
文档是唯一场景输入自动渲染器不读取历史像素/全局 store
阶段顺序稳定自动命令追踪为 background→grid→shape→overlay
Shape 状态隔离自动Transform/Alpha/Clip/Line Style 故障回归
五种 Shape 完整手动同一张截图包含矩形、椭圆、折线、图片占位符与文本标签
  • 我能解释 Path/Path2D、fill/stroke 以及 lineCap/lineJoin。
  • 我把 clip、合成、alpha、gradient、pattern、drawImage 和基础文本放在了正确层次。
  • 每个 save() 都有异常安全的 restore()。
  • 渲染器不修改文档、不读取随机时间,且顺序稳定。
  • 完全清空 Canvas 后,我仅凭文档、相机和资源状态就能重画相同结果。

给五岁孩子讲明白

不要使用“渲染器”“位图”“上下文”“阶段”或“确定性”这些词,回答:为什么“在旧盘子上加一点”不可靠?怎样保证另一位厨师也能做出相同的笑脸?

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

旧盘子上的东西可能已经被拿走、盖住,或被糖浆抹花。只说“加一点”,无法告诉我们下面原来有什么。我们需要完整清单和固定配方:擦干净盘子,铺好背景,放上每一种食物,最后再加提示。每当用完一种特殊糖浆或模具,都在这一步结束时把它收好。这样一来,即使盘子彻底空了,另一个人也能按照清单重建,而且结果不会依赖上次留下的碎屑。