画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。
尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。
要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。
元数据定义 (Metadata Definition)
我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。
在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)。
为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念 。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。
如下代码,这就是我们实际落地的元数据规范:
typescript
// 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 文本时,奇迹发生了:
- 绝对纯净的持久化与协同 :现在保存用户作品,不过就是做一次
JSON.stringify。而做多人协同,也不过是当某个 Node 的x发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁{"id": "node_1", "x": 250}。 - 极其廉价的时间机器 :撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似
Immer.js等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。 - 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。
接入状态管理:Zustand
有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?
绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。
打开 src/store.ts,这是整个工程的心脏:
typescript
// 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。它做的事情极其克制:只读数据,只画画面。
typescript
// 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 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的------不多画一帧,不少画一帧。
整个数据流形成了一个干净的单向环路:
markdown
用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
↑ │
└──────── 用户拖拽,Engine 回写坐标 ────┘
Konva 永远不私自修改任何数据 。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘------一切都是单向、可追溯的。
而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:
typescript
// 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... 项目上有完整的架构组织文档。