跳到主要内容

立即模式与场景图

Canvas 2D 每次 fill / stroke / drawImage 都把结果 立即写入 backing store。调用返回后,引擎不保留「刚才那条贝塞尔」的对象句柄。这和 SVG/DOM 的保留模式相反。

1. 两种模式

保留模式(SVG / DOM)
scene graph ──► 渲染器按树重绘
改 node.x,下一帧自动反映

立即模式(Canvas 2D)
你的代码 ──► 像素
改「逻辑 x」如果自己不重绘,屏幕上什么都不变

生产后果:

  • 拖拽一个矩形,必须 清 backing store(或脏区)再把整棵场景画一遍
  • 撤销不是 ctx.undo(),而是回放命令或恢复场景快照后重绘。
  • 从像素里「点选颜色」可以得到 RGBA,得不到「点到了哪个业务对象」。

2. 最小场景图

下面这个结构是绝大多数 2D 编辑器/可视化的底座。它不依赖框架。

type Mat2D = DOMMatrix;

interface SceneNode {
id: string;
/** 相对父节点的局部变换 */
local: Mat2D;
/** 局部坐标下的几何,用于绘制和命中 */
path: Path2D;
fill?: string;
stroke?: string;
lineWidth?: number;
children: SceneNode[];
/** 跳过命中(装饰层、已烘焙背景) */
pickable?: boolean;
}

function worldMatrix(node: SceneNode, parent: Mat2D): Mat2D {
return parent.multiply(node.local);
}

function paintNode(
ctx: CanvasRenderingContext2D,
node: SceneNode,
parent: Mat2D,
): void {
const world = worldMatrix(node, parent);
ctx.save();
ctx.setTransform(world);
if (node.fill) {
ctx.fillStyle = node.fill;
ctx.fill(node.path);
}
if (node.stroke) {
ctx.strokeStyle = node.stroke;
ctx.lineWidth = node.lineWidth ?? 1;
ctx.stroke(node.path);
}
ctx.restore();
for (const child of node.children) {
paintNode(ctx, child, world);
}
}

export function paintScene(
ctx: CanvasRenderingContext2D,
root: SceneNode,
): void {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
paintNode(ctx, root, new DOMMatrix());
}

关键点:

  • 几何活在 Path2D,不要每帧 beginPath + 一堆全局 lineTo,除非路径每帧都变。
  • 变换活在矩阵里,不要用一串 translate/rotate 指望人脑跟踪当前 CTM。
  • setTransform(world) 是绝对设置,不会叠乘脏 CTM。热路径里不要 transform() 连乘却从不重置。

3. 什么时候可以不建场景图

适用:一次性海报、服务端出图、每帧数据完全来自外部(音频频谱、监控波形)。这时「数据数组 → 直接画」即可,数组本身就是场景。

不适用:对象要被点选、对齐、成组、做动画插值。没有场景图你会在第一周之后把状态拆进 8 个平行数组,然后无法做父子变换。

4. 失败形态

症状原因
拖一下花屏/残影clearRect 或没覆盖脏区
越画越慢每帧重建巨大 Path2D,或 getImageData 读全画布
点不准命中用了 CSS 坐标,绘制用了 backing store 坐标
撤销错乱把 backing store 当 source of truth 做了 putImageData

权威资料

核对日期:2026-08-26