【图形编辑器架构】节点树篇 --- 从零构建你的编辑器数据中枢
发布时间 2025年10月1日 | 阅读预估:8分钟
最近在面试,想要整理下之前做过的内容,便把自己之前写的demo编辑器继续开发了,简单记录一些开发思路
🧑💻 写在开头
点赞 + 收藏 = 支持原创 🙏
如果你正在做画板、可视化、图形编辑器、流程图工具、思维导图这种东西,那么节点树绝对是核心模块。
这篇文章带你理解:节点树为什么要、有啥职责、如何写、怎么高效、能怎样扩展。
下面先说「你看完能学到什么」:
- 节点树为什么是编辑器的"大脑"
- State/Node 分离设计模式
- 增删改查、层级关系管理、子节点体系
- 与交互、渲染系统的协作流程
- 性能优化和扩展思路
仓库: canvas-test-demo 其中react-reconciler分支是最新的,实现了节点树、react-reconciler对接canvas,渲染api层隔离等
🍎 系列 & 背景
节点树是你做图形编辑器 / 画布类工具时"必须有"的那一层架构。
很多时候,页面只画一个按钮就够;但编辑器里你会有几百个形状同时存在,有拖拽、选中、层级、锁定、隐藏、组合、undo/redo......
节点树就是支撑这些功能的根基。
一、为什么要节点树?
在最初做图形编辑器时,很容易走向这种写法:
- 在组件里维护一个
elements: Element[]
数组 - 写一个
render()
遍历 all elements,画在 canvas 上 - 写交互逻辑:点选、拖拽、框选,直接操作这个数组
这种写法看起来没问题,但问题会在下面浮现:
- 查询效率低:每次点选要遍历所有元素,复杂度高
- 状态逻辑混乱:数组 + 属性混着写,组件/渲染层耦合严重
- 扩展困难:后面要加节点类型、组合、容器、遮罩、子节点等,就容易混乱
- 持久化 / 快照 / undo-redo:状态不好分离,快照大难做
所以,我们要把"节点"抽象出来,做一个统一的节点树,用它作为所有图形对象的根结构。这就是节点树的存在意义:
节点树 = "数据管理 + 交互中心 + 渲染数据源"三合一
它不是渲染,不是交互本身,而是承载它们的基础。
二、核心设计思想
在开始写代码之前,我们要确立一些设计原则:
- 状态与逻辑分离(State / Node 分离)
- 树形层级结构(父子关系)
- ID 引用,不直接对象引用
- 内存层 + 持久化层协同
- 最小职责原则:节点树只管理节点相关事务,不该干渲染、UI、交互太多,它是供渲染层消费的材料
2.1 State / Node 分离
- State:纯数据结构,保存节点的位置信息、尺寸、样式、类型、子节点数组等。它没有方法。方便序列化、快照、对比 diff。
- Node :封装业务逻辑、方法,操作 State,维护父子关系、提供便利 API(如
move()、resize()、contains(point)
等)。
示例:
js
interface BaseState {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
// ...可能还有 style、transform、children: string[] 等
}
class BaseNode {
protected _state: BaseState;
constructor(state: BaseState) {
this._state = state;
}
get x() { return this._state.x; }
set x(v: number) { this._state.x = v; }
get y() { return this._state.y; }
set y(v: number) { this._state.y = v; }
contains(point: { x: number; y: number }): boolean {
return (
point.x >= this.x &&
point.x <= this.x + (this._state.width || 0) &&
point.y >= this.y &&
point.y <= this.y + (this._state.height || 0)
);
}
}
这样你既能拿 State 做快照 / 存盘 / undo,又能用 Node 提供操作行为。
2.2 节点树 (NodeTree) 管理
NodeTree 是一个管理器,职责如下:
- 存放所有节点实例(用
Map<string, BaseNode>
) - 提供
addNode
,removeNode
,getNode
,getAllNodes
等操作 - 维护父子结构(parent / children)
- 同步到持久化存储(本地、服务器)
- 支持初始化构建(把一组状态数据变成节点实例)
示意结构:
css
NodeTree
├── nodes: Map<string, BaseNode>
├── addNode(state: BaseState)
├── removeNode(id: string)
├── getNode(id)
├── getAllNodes()
└── buildFromStates(states: BaseState[])
注意:父子关系一般不要用直接对象引用(node.children = Node[]
),而是用 children: string[]
存子节点 id。这样好序列化,也避免循环引用、内存泄漏。
2.3 多存储同步
NodeTree 是内存层,但你通常还会有持久化层(本地 cache、数据库、JSON 存盘等)。因此:
- 在
addNode
/removeNode
/ 更新 State 时,额外同步写入存储 - 存储保存的是 State(纯数据)
- 初始化时,先从存储把所有 State 读出来,再
buildFromStates
构建节点实例
这就形成"内存 ↔ 存储同步"的机制。
2.4 工厂 / 注册机制
新类型节点(例如矩形、路径、文本、组合)会越来越多,你不想在核心代码里写一大堆 switch(type)
。
可以做一个注册机制 / 工厂模式:
js
const nodeRegistry: Record<string, (state: BaseState) => BaseNode> = {};
function registerNodeType(type: string, ctor: (state: BaseState) => BaseNode) {
nodeRegistry[type] = ctor;
}
function createNodeByState(state: BaseState): BaseNode {
const ctor = nodeRegistry[state.type];
if (!ctor) {
throw new Error(`未知节点类型:${state.type}`);
}
return ctor(state);
}
这样新增类型时只要 registerNodeType("rectangle", RectNode)
,创建函数核心里就不需要改。
项目里面现在还是用的switch,后面我会改的
三、常用操作流程拆解
下面我们通过几个典型操作来演示节点树是怎么用的。
3.1 初始化
当编辑器启动或加载已有项目时,你会拿到一串状态数据(可能是 JSON),类似这样:
ini
const states: BaseState[] = loadFromStorage();
nodeTree.buildFromStates(states);
buildFromStates
的逻辑:
- 遍历每个 state
- 用工厂创建对应的 Node
- 放入
nodes
Map - 绑定 parent/children 关系
完成后,你就有了完整的节点树实例,可以用于渲染和交互。
3.2 点选节点(点击 / hitTest)
用户在画布点击时,要判断点落在哪个节点上,一般流程是:
- 坐标转换:屏幕坐标 → 世界 / 画布坐标
- 拿当前页面 / 容器的子节点
- 反向遍历子节点(z-index 高的先测)
- 对每个节点用
node.contains(point)
判断,返回命中节点 - 更新 selectionStore 等
伪代码:
js
const nodes = pageNode.state.children.map(id => nodeTree.getNode(id));
for (let i = nodes.length - 1; i >= 0; i--) {
if (nodes[i].contains(worldPoint)) {
return nodes[i];
}
}
return null;
3.3 拖拽 / 移动节点
拖拽就是修改节点的 x, y
,比如:
js
const node = nodeTree.getNode(draggingId);
node.x = worldX - offsetX;
node.y = worldY - offsetY;
因为 Node 的 setter 里会更新 State,所以状态同步、渲染系统可以监听这些变化,做到自动刷新。
3.4 新建节点 / 添加子节点
通常你会这样的流程:
js
const rectState: BaseState = {
id: generateId(),
type: "rectangle",
x: 100, y: 100, width: 200, height: 100,
// ...style、其他属性
};
nodeTree.addNode(rectState);
// 假设当前在 pageId 上操作
const pageNode = nodeTree.getNode(pageId);
pageNode.state.children.push(rectState.id);
3.5 特殊节点:路径 / 铅笔工具
自由笔触 / 路径节点要处理动态点(point 列表)、实时边界更新、路径简化等。
你可能在 Node 内部写:
js
class PencilNode extends BaseNode {
path: { x: number; y: number; pressure?: number }[] = [];
addPoint(pt) {
this.path.push(pt);
this.updateBounds();
}
simplifyPath() {
// 对 path 数组做简化处理,删除冗余点
}
private updateBounds() {
// 计算 minX, maxX, minY, maxY,写到 state.x, state.y, width, height
}
}
这样路径节点也能被节点树管理,同时在点击 / 框选时也能参与命中检测。
四、节点树 + 渲染 / 交互 协作
仅有节点树还不够,真正的编辑器还要渲染、交互、变换、视口控制(缩放 / 平移)等。
4.1 渲染流程
一般架构是:
js
节点树 (NodeTree) → 拿所有节点状态 → 渲染组件 / 渲染引擎 → 画到 Canvas / WebGL / Skia
你的渲染模块从 NodeTree 拿状态,然后生成绘制命令。这期间涉及坐标变换(世界坐标 → 视口坐标 → 屏幕坐标)。
例如:
js
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
for (let node of nodeTree.getAllNodes()) {
drawNode(ctx, node._state);
}
ctx.restore();
为了性能好,你要做增量渲染(只重绘受影响区域)或分片渲染。
4.2 交互 / 事件系统
点击 / 拖拽 / 框选 / 多选等操作都要和节点树协作:
- 点击:hitTest → 更新 selection
- 拖拽:修改 node.x / node.y
- 框选:遍历子节点判断是否在框内(
node.bounds
与框重叠) - 删除 / 复制 / 组合 / 取消组合:调用 nodeTree 的 API
交互系统从 UI 层 / 事件层接收鼠标 / 触摸事件,经过坐标转换后交给节点树 / hit 重逻辑来处理。
五、性能优化策略
当你有几十、几百、甚至上千个节点时,就要开始考虑性能。
5.1 数据结构选择
- 存节点推荐用 Map<string, BaseNode> ,查询 O(1)
- 父子关系用
children: string[]
而非对象引用,避免循环依赖
5.2 碰撞检测 / hitTest 优化
- 从后往前遍历节点列表(z-index 越高越先判断)
- 如果节点形状复杂,可先做包围盒检测,再做精细检测
- 对静态节点做缓存,比如包围盒计算缓存、不每帧重新算
5.3 路径 / 铅笔点简化
在绘制过程中,给 PencilNode
的路径做简化(比如 Douglas--Peucker 算法),减少点数,提升渲染和交互效率。
5.4 懒计算 / 缓存策略
某些属性如转换矩阵、样式计算可做缓存;当状态真正改动时才更新。避免在 render 阶段做大量计算。
5.5 空间索引结构
对于超大节点集合,考虑引入 四叉树 / R 树 / k-d 树 之类的空间索引结构,加速 hitTest / 区域查询。
六、扩展 & 进阶方向
节点树设计好之后,你可以在它之上扩展很多功能:
- undo / redo(撤销 / 重做) :用 State 快照或差分机制
- 协同编辑 / 多人实时编辑:节点状态同步 + 冲突解决机制
- 组合节点 / 组 + 解组:把一组节点当作一个节点来操作
- 蒙版 / 遮罩 / 裁剪:让某些节点成为 mask 子节点的容器
- 样式系统 / 主题 / 渐变 / 阴影等:状态里扩展样式字段,Node 提供样式操作方法
- 事件捕获 / 冒泡机制:比如在节点层、容器层监听鼠标 / 点击事件
- 虚拟化渲染:当画布很大、节点很多时,只渲染视口内节点
七、总结 & 写给未来的你
节点树在图形编辑器里就像中枢神经,是其他所有模块的基础。搞清楚节点树的职责、设计思路和实现方式,能让你做出结构清晰、性能可控、可扩展强的编辑器系统。