阅读视图

发现新文章,点击刷新页面。

手写无限画布4 —— 从视觉图元到元数据对象

画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。

尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。

要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。

元数据定义 (Metadata Definition)

我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。

在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)

为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。

如下代码,这就是我们实际落地的元数据规范:

// src/schema/types.ts

// 所有图元共享的基因——它们必须遵守的基础契约
export interface BaseElementData {
  id: string; // 唯一宇宙编号,协同与更新的基石
  type: ElementType; // 业务大类
  x: number;
  y: number;
  width: number;
  height: number;
  hitColor: string; // 上一篇的命中测试色值,也要元数据化
  strokeColor: string;
  backgroundColor: string;
  opacity: number;
  zIndex: number; // 层级控制,决定覆盖关系
  isLocked: boolean; // 业务属性:用户是否锁定了该元素
  // ...
}

// 业务派生:形状、文字、线条各有自己的专属字段
export interface ShapeElementData extends BaseElementData {
  type: "rectangle" | "ellipse" | "diamond";
}

export interface LinearElementData extends BaseElementData {
  type: "arrow" | "line";
  points: number[][]; // 途经的折点
  startArrowhead: "arrow" | "triangle" | "none";
  endArrowhead: "arrow" | "triangle" | "none";
  startBindingId: string | null; // 线头绑定的元素 ID
  endBindingId: string | null;
}

// 终极联合类型:无限画布的唯一真理对象
export type CanvasElementData =
  | ShapeElementData
  | TextElementData
  | LinearElementData;

注意一个关键细节:上一篇讲到的命中测试色值 hitColor,也被我们收编进了元数据定义。从此刻起,一个图形的一切——它在哪、它多大、它长什么样、它怎么被点中——全部由这颗 JSON 树的一个节点来描述。再也没有游离在数据结构之外的"野状态"了。

纯元数据驱动带来的红利

当你把屏幕上所有花里胡哨的图形,都严格浓缩成上述哪怕只有几百 KB 大小的纯 JSON 文本时,奇迹发生了:

  1. 绝对纯净的持久化与协同:现在保存用户作品,不过就是做一次 JSON.stringify。而做多人协同,也不过是当某个 Node 的 x 发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁 {"id": "node_1", "x": 250}
  2. 极其廉价的时间机器:撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似 Immer.js 等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。
  3. 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。

接入状态管理:Zustand

有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?

绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。

打开 src/store.ts,这是整个工程的心脏:

// src/store.ts

export const canvasStore = createStore<CanvasState>((set) => ({
  // 全部元素的 Record 字典,key 为 id
  elements: initialElements,
  // 应用运行时状态(当前工具、缩放、视口偏移、选中态...)
  appState: defaultAppState,

  // ——— 以下全是纯函数式的 Actions ———
  updateElementProps: (id, props) =>
    set((state) => ({
      elements: {
        ...state.elements,
        [id]: { ...state.elements[id], ...props },
      },
    })),

  addElement: (el) =>
    set((state) => ({
      elements: { ...state.elements, [el.id]: el },
    })),

  selectElement: (id) =>
    set((state) => ({
      appState: { ...state.appState, selectedElementIds: id ? [id] : [] },
    })),
  // ...
}));

值得反复品味的是:无论是创建元素、更新坐标、还是切换选中态,Store 里执行的全部都是浅拷贝替换{ ...state.elements, [id]: ... })。没有任何副作用,没有任何直接 DOM 操作。这意味着前面说的 Undo/Redo "时间机器",你只需要把这些 Immutable 快照存进一个栈里就好了——就是这么廉价。


引擎订阅:一个极致的"哑巴渲染器"

Store 管数据,那谁管画面?答案是 src/engine/index.ts——我们的引擎总控 EngineFacade。它做的事情极其克制:只读数据,只画画面

// src/engine/index.ts — 订阅逻辑

this.unsubscribe = canvasStore.subscribe((state) => {
  // 图元变更 → 重新渲染
  if (state.elements !== prevState.elements) {
    this.shapeRenderer.render(state.elements);
  }
  // 选中态变更 → 同步 Transformer 控制框
  if (state.appState.selectedElementIds !== prevState.appState.selectedElementIds) {
    this.selectionManager.syncSelection(state.appState.selectedElementIds);
  }
  // 视口变更 → 同步 Stage 缩放/平移
  if (state.appState.zoom !== prevState.appState.zoom || ...) {
    this.viewportManager.syncViewport(zoom, scrollX, scrollY);
  }
});

请注意这里的引用相等性比较(!==)。Zustand 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的——不多画一帧,不少画一帧。

整个数据流形成了一个干净的单向环路

用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
                ↑                                      │
                └──────── 用户拖拽,Engine 回写坐标 ────┘

Konva 永远不私自修改任何数据。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘——一切都是单向、可追溯的。

而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:

// src/App.tsx — 属性面板(精简)
const PropertiesPanel = () => {
  const selectedIds = useCanvasStore(
    (state) => state.appState.selectedElementIds,
  );
  const elements = useCanvasStore((state) => state.elements);
  const updateElementProp = useCanvasStore((state) => state.updateElementProp);

  const el = elements[selectedIds[0]];
  // 从 store 读数据,渲染颜色选择器、描边样式按钮...
  // 用户点击后,直接调用 updateElementProp() 回写 store
};

我们常说,前端框架 React 的核心公式是 UI = f(State)。 而无限白板的架构真谛就是:Canvas = Konva(Metadata)


回望:四层地基已就位

至此,我们用四篇文章,自底向上地垒完了无限画布系统的四层地基:

层级 解决的核心问题 关键技术
坐标系 "无限"与"缩放"的数学本质 世界坐标 ↔ 屏幕坐标变换
渲染层 高性能绘制大量图形 Konva Scene Graph, 局部重绘
交互层 重建事件感知 离屏 Color Picking, Hit Testing
对象层 让画布拥有序列化的组织 元数据 Schema, Zustand 单向数据流

历经四篇文章的打磨,我们从最底层的数学坐标系起步,最终构筑起这套‘可协同、可撤销、可跨端’的数据驱动画布架构。这段工程演进之路的破局关键,其实就是两个字:克制。清晰划定架构的分层边界,想透每一层该做什么,并坚决杜绝越界。

本系列 实例项目已上传GitHub github.com/Seanshi2025… 项目上有完整的架构组织文档。

手写一个无限画布 #3:如何在Canvas 层上建立事件体系

你以为你点中了一个圆,其实你只是点中了一堆毫无意义的像素点。在画布里,所谓的“选中”,不过是一场精密的数学与色彩幻术。

上一篇我们终于搞定了渲染层,并明确选择了 Konva (Canvas 2D) 作为我们的底层渲染基石。现在,我们的屏幕上终于可以丝滑地渲染出极具表现力的图形了。

但是,当你试图把鼠标悬停在其中一个图形上,或者想拖拽一条连线时,你会遭遇一个巨大的反直觉打击:浏览器完全不知道你点的是什么。

在传统的前端开发中,原生 DOM 是一棵界限分明的树。鼠标移入一个 <div>,浏览器引擎会在底层自动做碰撞检测,并把 mouseenterclick 事件准确无误地派发给这个节点。如果你给 <div> 加了圆角(border-radius)甚至复杂的 clip-path,浏览器依然能完美识别出精确的边缘。这种体验太理所当然,以至于我们从未思考过背后的代价。

但在 Canvas 的世界里,这套秩序完全失效了。

对于浏览器来说,不管你在 Canvas 里画了多少个圆圈、多复杂的文字,它看到的永远只有一个扁平的 <canvas> 标签。 当用户点击屏幕时,浏览器的原生 Event 对象只能递给你一个冷冰冰的坐标:{ clientX: 500, clientY: 400 }。至于这个坐标下是空气、是红色正方形,还是三个交叠在一起的半透明多边形,对不起,只能你自己算。

要在毫无知觉的像素油盆上,重新赋予图形被“感知”的能力,这就是 命中测试(Hit Testing) 的核心命题。

直觉陷阱:纯算几何碰撞

面对这个问题,多数人脑海里冒出的第一个念头一定是算数学题。

“既然我知道画布上每个方块的长宽、每个圆的半径,那鼠标点下去的时候,去遍历所有图形做个碰撞测试不就好了?”

比如点矩形,就看鼠标坐标是不是在它的上下左右边界内;点圆,就算勾股定理看距离是不是小于半径;如果是多边形,大不了掏出大学计算机图形学里教的“射线法(Ray-Casting)”,看看射线和多边形交点是奇数还是偶数。

在很多游戏开发新手教程里,这确实是讲解命中测试的第一课。

但只要你真的在业务里动手写过,就会立刻体会到这种朴素算法带来的“工程绝望”:

如果是最基础的方块和圆还好,可你在白板工具(如 Excalidraw / Figma)里,最常面对的是用户鼠标画出的一条粗细不均、极度扭曲的自由手绘墨迹(Freehand Draw)。成百上千个点连出来的畸形曲线,你拿什么算交点?

即使你咬着牙把每根线段都算了,还有图形的中空与穿透问题。当用户点在一个空心圆环的正中间,或者字母 "O" 的空白处时,根据最粗糙的外围包围盒(Bounding Box),它是被命中的;但这根本违反了用户“我明明点在透明的地方,我想点它背后元素”的心理预期。哪怕你真算出了鼠标确实落在图形线条上,你又怎么确保,这层图形的正上方,没有被另一个半透明的阴影盖住呢?

别忘了最绝杀的性能噩梦。不仅是点击,鼠标每在屏幕上划过一个像素,就会高频触发 mousemove。如果同屏有几千个杂乱的图形交叠,每移动一毫米就要把所有多边形的射线方程重新算一遍,你的 CPU 风扇会直接起飞,页面帧率瞬间崩盘。

想靠纯写 if-else 的几何穷举来搞定一个不仅带各种圆角、线宽、自交错,还带层级遮挡的生产级别白板交互,可以说是直接在给 CPU 判死刑。


优雅的黑魔法:离屏 Canvas 与 Color Picking

针对纯正向几何数学算不通的情况,业界的顶级绘图引擎往往会使用一招极度聪明且优雅的逆向黑魔法:利用颜色查表法(Color Picking)。这也是 Konva 最为核心的看家本领机制。

hit-test-color-picking.png

它的核心逻辑堪称“暗度陈仓”,分为以下几个精妙的步骤:

1. 建立影分身(Hidden Canvas)

在内存中,创建一个跟主屏幕尺寸完全一致的隐藏 Canvas(用户看不见它)。主屏幕负责渲染展现给用户看的漂亮图形,而这个“影分身”只专门用来做苦力——命中测试。

2. 分配身份色(Color Hash)

当我们要往主屏幕画一个崭新的图形(比如一个带有高斯模糊阴影的蓝色虚线圈)时,引擎会在内存里给这个图形分配一个全局唯一、随机生成的 RGB 颜色值(比如 #000001)。 然后在内存的隐藏 Canvas 的同样坐标处,用这个唯一颜色 #000001 画一个同样轮廓的圆。无论主画布上的圆有多花哨,隐藏画布上的圆统统画成没有阴影、没有抗锯齿的纯色实心/实线

与此同时,维护一个字典(Hash Map),记录:#000001 映射到 蓝色虚线图对象引用

3. O(1) 的降维打击:只读一个像素

见证奇迹的时刻到了。 当前的场景是:主画布上画了成千上万个复杂的图形。隐藏画布上也用同样的布局画了成千上万个纯粹色块。

当用户在主屏幕上点击 (x: 500, y: 400) 时,引擎不去做任何数学几何碰撞计算,除了获取坐标外只做极其底层的一步:

  1. 走到隐藏 Canvas 面前。
  2. 精确地读取它 (500, 400) 这个坐标点上的 1 个像素的 RGB 颜色值getImageData)。
  3. 如果读出来的颜色是黑色(完全透明),说明没点中任何东西。
  4. 如果读出来的颜色是 #000001,引擎立刻去 Hash Map 里查表——破案了!对应的是那个蓝色的虚线圈对象。

为什么这个方案是统治级的?

  1. 彻底无视几何形状的难度。不管你画的是自由手绘还是残缺的文字轮廓,只要它被渲染引擎画在屏幕上,那对应的颜色像素就实打实地落在了隐藏画布上。它巧妙地利用底层的 GPU 渲染规则来替你完成极度复杂的轮廓光栅化判定。
  2. 天然解决重叠遮挡。主画布怎么叠加层级的,隐藏画布也是按同样顺序绘制的。你在隐藏画布上读出来的那个带颜色像素,必然是最顶层、没被别人遮挡的那个对象的颜色。完全不需要自己遍历判断层级。
  3. 极端的性能空间换时间。把原本复杂的 O(N×几何顶点数)O(N \times 几何顶点数) 的每帧遍历计算,直接降维成了读取内存图像一个单像素点的 O(1)O(1) 常数级查表时间。即使屏幕上有十万个对象,鼠标在上面疯狂移动也是绝对丝滑的。

站在巨人的肩膀:这就是 Konva

要在原生 Canvas 上实现一个可用于生产环境的稳健命中测试系统基建,工作量是极其庞大的。你要自己去维护那个巨大的离屏画布上下文同步、自己分配十六进制颜色、自己实现局部重绘优化、还要自己派发所有的模拟 DOM 冒泡事件。

这正是我们放弃从零手写引擎底层,转而选型采用 Konva 的终极原因。

Konva 在底层极其克制且优雅地封装了这套“离屏颜色拾取算法”。在开发者眼里,你完全感受不到那个诡异的“彩色隐藏画布”的存在。

它直接把这套脏活累活,包装成了我们最熟悉的、一如在写原生 DOM 一样的前端语法范式。这就让我们能够完全剥离繁复的数学几何泥潭,将精力投入在画布“事件分发与交互流控制”上:

// 这种久违的、确定的秩序感,对于开发无穷交互的白板来说是极其珍贵的。
import Konva from "konva";

const rect = new Konva.Rect({
  x: 50,
  y: 50,
  width: 100,
  height: 50,
  fill: "blue",
  draggable: true, // 开启拖拽!底层所有复杂的变换全自动运算并重绘画布。
});

// 你仿佛重新拥有了原生的 DOM 事件绑定系统
rect.on("mouseenter", () => {
  document.body.style.cursor = "pointer";
  rect.fill("red"); // 悬浮触发变色响应
});

rect.on("mouseleave", () => {
  document.body.style.cursor = "default";
  rect.fill("blue");
});

// 即使有成百上千个图形交叠,它也能极速计算,精准捕捉顶层响应
rect.on("click", (e) => {
  console.log("极速且精准地点中了我:", e.target);
});

有了 Konva 兜底解决“感知盲区”,我们终于补齐了跨越无限画布最重要、也是最难缠的一块技术栈拼图。

我们不再是在冷冰冰的像素点数组上作画,而是真正在操控和编排一个个有边界、能响应手势、知晓自身存在的“实体对象”

经历三篇的文章,我们已经打通了从“坐标系”、“底层渲染引擎选型博弈”到“重建事件分发秩序”的全部技术基建。

接下来,我们将长驱直入应用数据的深水区:在这块充满感知能力的画布上,我们该如何用正确的数据结构来对这些可被协同、可被导出、可被反序列化的对象进行定义?

手写一个无限画布 #2:渲染层的博弈:Canvas 还是 WebGL ?

DOM 和 SVG 为什么第一个被淘汰?

很多人会问:为什么不用 DOM?为什么不用 SVG?

答案其实很残酷。

浏览器为了渲染 DOM 和 SVG 节点,底层维护了一套极其庞大的对象模型和布局引擎。每一次你在触控板上轻轻一划,哪怕只是让相机的世界坐标移动几个像素,都可能被迫引发大量节点的重排(Reflow)与重绘(Repaint)

当节点树突破临界点,主线程被压垮,原本丝滑的缩放和平移就会掉进帧率泥潭。

无限画布的核心诉求只有一个:

无论同屏多少元素,都要稳定 60fps。

想达成这一点,你必须脱离浏览器的排版流,进入纯粹的"像素缓冲区"世界。

在当前的 Web 技术栈中,能承载这个目标的只有两条路:Canvas 2DWebGL

它们不是同一个问题的两种解法,而是两种截然不同的工程解法。


Canvas 2D:浏览器替你扛住复杂度

Canvas 2D 是绝大多数画布项目的起点——在线白板、流程图工具、轻量设计工具。

原因很简单:它太好用了。

画一个带边框的矩形:

ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(10, 10, 100, 100, [5]);
ctx.fill();
ctx.stroke();

想画曲线就调用 quadraticCurveTo,想写字就 fillText,想加阴影就配置 shadowBlur

Canvas 2D 给你的不是 GPU 访问权,而是一套图形语义 API——你在表达"我要画什么",而不是"我要如何让 GPU 算出这些像素"。底层可能是 Skia、CoreGraphics 或 Direct2D,你不需要知道。

这就是它的价值。

但这背后有个隐藏代价:你把控制权交给了浏览器。

Canvas 2D 的指令调用是单向且串行的,绝大多数场景依赖 CPU 处理绘图指令。当你同屏几万个图形,每一帧都要重新执行所有 draw 指令。哪怕只是平移一个像素,也要把全部图形循环重绘一遍。

你没法告诉浏览器:

"把这批图形放到显存里,下次只改变换矩阵就行。"

Canvas 不给你这个能力。

所以当大量使用图层合成、全局阴影时,每帧的绘制时间很容易突破 16.6ms 的底线。

对于中等规模应用,Canvas 2D 性价比极高。但当你开始追求"Figma 级别"的体验时,它会成为瓶颈。


WebGL:你需要接管整个物理世界

跨入 WebGL 的那一刻,你会遇到一个残酷现实:

这个世界里没有矩形。只有三角形。

我们来做一个直观对比。

在 Canvas 2D 里画一个红色矩形:

ctx.fillStyle = "red";
ctx.fillRect(10, 10, 100, 100);

2 行代码。

在 WebGL 里画同一个矩形:

// 1. 定义矩形四个顶点的坐标(拆分成两个三角形)
const vertices = new Float32Array([
  -0.5,
  0.5, // 左上
  -0.5,
  -0.5, // 左下
  0.5,
  0.5, // 右上
  0.5,
  -0.5, // 右下
]);

// 2. 将数据送入显存 Buffer 中
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

// 3. 编写 Vertex Shader 算坐标,编写 Fragment Shader 涂成红色
// ...(此处省略几十行 Shader 编译与变量绑定代码)...

// 4. 通知 GPU 绘制这两个三角形(Triangle Strip)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

如果你想画一个"带圆角的 2 像素红框矩形"呢?

在 WebGL 中,连 roundRect 都没有。你需要用数学算法算出圆弧上的离散点,将边框转换成一个个细小的三角形,然后还要自己在着色器里处理抗锯齿(Anti-Aliasing)。

甚至连文字排版都是个难题。WebGL 本身不懂文字——你要么把文字先用 Canvas 渲染成图片,再贴到 WebGL 里作纹理(Texture),要么引入复杂的 SDF(有向距离场)算法让 GPU 渲染出不失真的文字轮廓。

复杂度是指数级增长的。

选择 WebGL,你不只是在换一个 API。你是在决定成为一个"引擎开发者"。


真正的问题不是性能

我后来想明白了一件事:

大多数人不是死在 Canvas 性能不够,而是死在过早复杂化。

如果你的同屏元素不超过 5000,Canvas 2D 够用。如果你连空间索引、脏矩形优化都没做,谈 WebGL 就是在逃避真正的问题。

但如果你的目标是做重型设计工具级产品——WebGL 是绕不过的坎。

区别在于:你是否愿意为"控制权"买单。


我们的选择:现实、克制

关于 Canvas 还是 WebGL,没有统一的正确答案。取决于你的产品对同屏元素量级的刚需,以及团队对底层图形基建的掌控力。

本系列不会从零手写 WebGL 引擎。

我们选择基于 Canvas 2D,但不会裸写绘制指令。

我们会引入 Konva.js 作为底层支撑。Konva 是一个基于 Canvas 2D 的场景图引擎,自带图元管理、分层渲染和像素级事件系统。我们将在此基础上,剥离前端框架(React/Vue)的边界,用纯 TypeScript 构建一个接近 Excalidraw 架构的轻量级无限画布。

这不是性能最极限的方案。

但它是当前阶段最诚实的解法。


真正的深水区:交互

无论 Canvas 还是 WebGL,它们本质上都只是一块"死板"的像素画布。

当你脱离 DOM,你就失去了节点树。

浏览器的 click 事件只能告诉你:用户点在了 { x: 500, y: 400 }

但不会告诉你:这是第 7823 个图元。

悬停、拖拽、框选、事件冒泡——全部失效。

当画布变成一个没有结构的像素世界,点击本质上变成了一道几何题

下一篇,我们用真实代码来解这道题:在没有 DOM 的"野生像素图"上,从零实现空间检测与事件分发系统。

手写一个无限画布 #1:坐标系的谎言

一个你每天都在做的操作

打开 Figma,或者启动蓝湖(Lanhu)查看标注,又或是打开 Lovart 开始绘图。

双指捏合,画布缩小了。两指一推,画布滑到了左边。点一下那个蓝色的矩形,选中了。

你每天都在做这三步。你从来没有觉得它们有什么值得思考的。

但我想请你回答一个问题:

这三步操作里,有几个坐标系在同时工作?

你大概会说:一个吧。就是画布的坐标系嘛,X 和 Y,所有东西都在里面。

不对。

答案是至少三个。而正是这个"至少三个",分开了"会用画布"和"理解画布"的两种人。


画布没有动过

我们来思考一个问题。

你在 Figma 里按住空格键拖动,画面跟着你的手指在移动。你管这叫"拖动画布"。

但请你换一个视角想想——

如果画布真的在动,画布上的那些矩形、文字、线条,它们的坐标变了吗?

答案是: 没有。

你给一个矩形定的位置是 (200, 300),无论你怎么拖、怎么缩放,这个矩形在画布世界里的坐标始终是 (200, 300)。没有任何东西移动了。

那到底什么在动?

你在动。

或者更准确地说——你的"摄像机"在动。

这不是一个比喻。这就是无限画布的字面原理。你的屏幕是一个取景框,画布世界是一个无限延伸的平面,你的每一次拖拽,不是在推动世界,而是在推动自己的视口。

如果你玩过超级马里奥 ——你对这件事不会陌生。马里奥往前跑的时候,屏幕跟着移动,但世界并没有向后走。世界始终在那里,移动的是摄像机。

无限画布的逻辑完全一样。

Coordinate System Animation

Google Maps 更直观。你在手机上滑动地图,你管它叫"移动地图",但地球显然没有动。你移动的是你的观测位置。

现在你可以理解为什么答案是"至少三个坐标系"了:

世界坐标系(World Space) :画布世界本身的坐标。一个矩形在 (200, 300),它就永远在那里,跟你的拖拽无关。这是物体存在的坐标。

屏幕坐标系(Screen Space) :你的显示器上的像素坐标。鼠标在屏幕上的 (500, 400),这是你手指触达的坐标。

局部坐标系(Local Space) :一个对象相对于它父级的坐标。一个文字节点在某个分组(Group)里面,它的位置是相对于那个分组的。这是对象相对存在的坐标。

三个坐标系,三套数字,每一次用户操作都要在它们之间做转换。

这才是无限画布做的事。


摄像机:一个被隐藏的核心概念

如果你做过 Canvas 白板开发,你可能写过类似这样的逻辑:

当用户拖拽时,你维护一对 offsetX / offsetY,然后在每帧渲染时,把所有元素的坐标都减去这个 offset 再画。

这就是一个摄像机。只不过你可能从来没有这么叫过它。

当用户缩放时,你维护一个 scale 变量,渲染时把所有坐标乘以这个 scale。

这也是摄像机的一部分——焦距。

把这些变量合在一起看,你有的是:

12345

Camerax: number       // 摄像机在世界中的 X 位置  y: number       // 摄像机在世界中的 Y 位置  zoom: number    // 缩放比例}

这三个数字,定义了"你在世界中站在哪里、看多大范围"。

可能你之前从没有把 offsetX / offsetY / scale 想象成一台摄像机。但一旦你这么理解了,很多事情会突然变得清晰。

缩放不是把对象放大,是把摄像机推近了。

这个区别不是语言游戏。当你"放大一个矩形"时,矩形的世界坐标和宽高没有变——(200, 300) 和 100×100 还是那些数字。改变的是摄像机的 zoom 值。当 zoom 从 1 变成 2,每个世界坐标映射到屏幕上的像素数翻倍了,所以你"看起来"矩形变大了。

这就是为什么缩放时所有元素保持相对位置不变——因为它们根本没动。动的是你看它们的方式。

如果你用 Fabric.js,你调用的 canvas.setViewportTransform() 本质上就在设置这台摄像机的参数。如果你用 Canvas 2D 原生 API,你调用的 ctx.setTransform() 或 ctx.translate() + ctx.scale() 也是在操纵这台摄像机。

你一直在用摄像机。只是没有人告诉过你。


坐标变换:整个系统的地基

有了摄像机的概念,一个核心问题浮现了:

用户点击屏幕上的 (500, 400),他到底点中了世界里的谁?

这是每一个画布系统都必须回答的问题。它的学名叫 Hit Testing(命中测试),但本质上,它是一个坐标变换问题。

从屏幕坐标到世界坐标的转换公式概念上很简单:

12

worldX = (screenX - camera.x) / camera.zoomworldY = (screenY - camera.y) / camera.zoom

屏幕上的点,减去摄像机的偏移量,再除以缩放比例,就得到了这个点在世界中的真实位置。

反过来也成立。你有一个世界坐标 (200, 300),想知道它在屏幕上画在哪里:

12

screenX = worldX * camera.zoom + camera.xscreenY = worldY * camera.zoom + camera.y

这两个公式是无限画布的心脏。  你做的每一件事——选中、拖动、缩放、对齐、吸附——都在使用它们的某种变体。

如果你用矩阵来表达,这就是一个仿射变换矩阵的正变换和逆变换。但关键不在于数学形式,而在于你是否意识到:

你的画布系统里,每一次用户输入和每一次渲染输出,中间都隔着一层坐标变换。  不理解它,你写的代码就是在黑暗中摸索;理解了它,所有后续的工程决策都有了一个统一的锚点。


这个模型解释一切

一旦你接受了"世界 + 摄像机"的理解模型,很多看似复杂的特性都变得顺理成章。

多人协作

Figma 的多人协同是怎么回事?多个用户在同一个画布上编辑,你能看到其他人的光标在你的屏幕上移动。

用摄像机模型来理解:世界只有一个,但每个用户有自己的摄像机。

用户 A 可能正在看世界的左上角,zoom 是 1.5;用户 B 正在看右下角,zoom 是 0.5。他们操作的是同一个世界里的同一批对象,但"看到的画面"完全不同——因为他们的摄像机位置和焦距不同。

要在你的屏幕上显示其他人的光标,你需要做一步坐标转换:

把对方的世界坐标位置,经过你自己的摄像机变换,映射到你的屏幕上。

这就是为什么当你缩放自己的画布时,其他人的光标也会跟着缩放——因为你改变了自己的摄像机参数,同一个世界坐标映射到屏幕上的位置变了。

缩放到指定位置(Zoom to Point)

你有没有注意过:在 Figma 里双指缩放时,画布是围绕你手指中心点缩放的,而不是围绕画布中心?

这不是简单地修改 zoom 值。如果你只改 zoom,画面会围绕摄像机的原点缩放,效果会很怪。正确的做法是:在改变 zoom 的同时,调整摄像机的 x 和 y,使得手指所在的那个世界坐标点在缩放前后映射到屏幕上的同一个像素。

这又是一个坐标变换问题。如果你没有摄像机模型,你很可能会写一大堆看起来能用但自己也说不清为什么的补偿代码。有了这个模型,逻辑是干净的。

图层(Layer)

你可能习惯了 CSS 的 z-index。在画布世界里,图层是另一件事。

画布中的图层不是 DOM 层级的概念。它是世界空间中的 Z 轴排序——哪个对象"在上面",哪个"在下面"。这个顺序是画布世界数据的一部分,跟渲染层级没有直接关系。

Canvas 2D 的渲染是画家算法(Painter's Algorithm):后画的覆盖先画的。所以图层顺序直接决定了你的绘制顺序。你改变了图层,不是在改变某个 CSS 属性——你在改变世界的一部分。

视口裁剪(Viewport Culling)

当你的画布上有 10 万个元素,但你的屏幕只能看到其中 200 个时,你不需要渲染全部 10 万个。

你需要做的是:用摄像机的位置和 zoom 计算出当前视口在世界空间中覆盖的矩形范围,然后只渲染落在这个范围内的元素。

这就是视口裁剪。又是一个纯坐标变换问题。没有摄像机模型,你甚至不知道从哪里开始思考这个优化。


你以为的 vs 实际的

让我把这些放在一起,做一个对比:

你以为的 实际上
拖动了画布 移动了摄像机
放大了元素 改变了摄像机焦距
画布是有边界的 世界是无限的,有限的是视口
图层是 CSS 概念 图层是世界空间的 Z 排序
点击选中了某个元素 屏幕坐标经坐标逆变换后做命中测试
多人协作是同步画面 多台摄像机观察同一个世界
缩放是视觉效果 缩放是摄像机的空间变换

如果你做过画布项目,你现在可以回头重新审视自己的代码。那些 offsetXoffsetYscaletranslateX ——它们不是零散的状态变量。它们是一台摄像机的参数。你的渲染函数不是在"画东西",它是在"拍摄世界"。

这不是一个更好的术语,而是一个更好的心智模型。  心智模型决定了你能优雅地解决多少问题,以及在多少问题前一脸茫然。


为什么这很重要

你可能会想:这就是个模型上的区别,代码还是那些代码啊。

不一样。

当你用 DOM 思维去做画布,你会不自觉地把每一个需求当成独立问题去"打补丁":这里加个 offset,那里乘个 scale,在某个角落补一个不知道为什么 work 的反向偏移。

但当你用"世界 + 摄像机"思维做画布,你有一个统一的框架可以推导:

  • • 需要做选中?→ 先把屏幕坐标变换到世界坐标,再做碰撞检测。
  • • 需要做缩放到点?→ 求解摄像机参数在变换前后的约束方程。
  • • 需要做多人协作?→ 不同摄像机看同一个世界,坐标变换是桥梁。
  • • 需要做视口裁剪?→ 用摄像机参数计算视口在世界中的矩形范围。

每一个问题都回到同一个地基。  这就是心智模型的威力——它不给你答案,但它给你一致的推理起点。

这也是为什么有些人做画布做了两年,代码里还全是看不懂的 magic number 和 workaround;而另一些人做了三个月,系统干净得像教科书。区别不在编码水平,在于脑子里有没有一个足够好的模型。

不理解坐标系统的人,写出的画布代码永远是在打补丁。


无限画布的一句话定义

写到这里,我可以给你一个你在别处看不到的定义:

无限画布 = 一个无限世界 + 一台摄像机 + 一套坐标变换规则

世界负责"有什么"——对象、位置、层级。摄像机负责"看什么"——偏移、缩放。坐标变换负责"如何翻译"——用户输入到世界位置,世界位置到屏幕像素。

这三个部分耦合得极紧,但职责完全不同。理解了这个结构,你才算理解了无限画布的第零步——在谈论渲染技术、交互系统、协同架构之前,你首先需要理解的那个东西。


下一篇

如果你接受了"画布世界 + 摄像机" 这个理解模型,下一个问题是:

谁来负责渲染这个世界?

下一篇,我们来聊无限画布的渲染技术。

❌