diagram-design 这个项目,说白了就是一套从零搭建通用图表设计编辑器的前端技术方案。这两年在后台系统里做架构可视化、流程编排、拓扑设计这类需求的人应该不少,大家通常的做法是直接用现成的图形编辑库,或者拿画板库硬写交互,结果要么被框架约束住,要么在交互体验上迟迟打磨不到位。我自己也经历过这样的阶段,最后干脆自己设计了一套围绕“diagram-design”这个目标的完整实现方案,从图元模型、渲染策略到交互反馈、扩展机制全部沉淀下来。这篇文章我会把这个项目的核心思路、关键代码设计、踩坑记录都摊开来讲,适合正在做可视化编辑器、低代码平台或想把图形编辑能力集成进自己产品的开发者参考。
1. 为什么需要 diagram-design:从一个具体痛点说起
1.1 我最早动手做这个项目的真实原因
起因并不复杂。当时我们在做一个内部的数据链路监控平台,需要把几十个服务节点、存储组件、消息中间件之间的关系用图形化方式展示出来,同时还要支持在线调整节点位置、临时增删连线、导出配置。最初也尝试过在开源生态里找轮子,确实有功能完善的图形编辑框架,但用起来始终有几个绕不过去的坎:一个是样式和交互的定制力度不够,想要节点内部展示更复杂的自定义视图非常费劲;另一个是序列化和协议格式太“重”,我们只需要非常轻量的节点、边、属性三段式数据;再一个是学习成本高,有些框架背后整套渲染机制都要顺着它来,项目组新成员接手时得很长时间才能上手。
后来我就想,与其被第三方库的抽象逻辑限制住,不如自己做一个 diagram-design,把核心的图元模型、画布交互、渲染适配都控制在手里。这样做的好处很明显:第一,核心模型层可以做到极简,数据格式自己定义;第二,交互体验可以按业务需求精准实现,比如节点拖拽吸附、连线转折算法、视口缩放平滑性都能细调;第三,后续不管切换底层渲染方式是 Canvas 还是 SVG,模型层几乎不用动。对业务团队而言,这套方案可以直接孵化成低代码平台里的流程编排画布,或者投放到任何需要图形化建模的产品模块里。
1.2 项目定位与读者适配度
diagram-design 严格来说不是一个完整的成品工具,而是构建工具的能力底座。如果你正在做的工作属于下面任意一类,这篇文章的内容会对你有直接帮助:
- 在内部系统里实现架构图、网络拓扑图、ER 图、流程图等可视化编辑器
- 在做低代码平台,需要为用户提供可视化编排界面
- 想要掌握从零绘制“可拖拽、可连线、可缩放”的图形编辑器的核心逻辑
- 不满足于直接套用开源库,想理解底层渲染和交互机制的实现细节
项目整体定位是“前端图形编辑基础设施”,不绑定业务场景,也不限定渲染技术栈。它提供的是节点、连线、画布、命令、扩展点这些最通用的抽象能力,具体业务长成什么样由上层自己定。这样的好处是,哪怕你未来的业务模型跟我的完全不同,这个项目的结构拆解思路也依然成立——先把数据模型稳定住,再谈渲染和交互。
2. 核心设计思路:先稳定模型层,再谈渲染与交互
2.1 图元模型:Node、Edge、Port、Group 四大核心概念
diagram-design 里最基础的设计决策,是把画布上的一切抽象成四类图元:Node(节点)、Edge(连线)、Port(连接点)、Group(分组)。很多人做类似项目时一开始只定义 Node 和 Edge,跟着做到一半才发现,连线到底应该从节点边界出发还是从特定锚点出发?多个节点要不要支持整体拖拽?这些需求藏在后面,逼着你把 Port 和 Group 补上。
Node 是承载主要内容的基本单位。我给它设计的核心结构非常简单:
interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; data?: Record<string, unknown>; ports?: DiagramPort[]; groupId?: string | null; }id 用来标识节点,type 用来映射到对应的渲染组件,x / y 是节点在画布坐标系中的位置坐标,width / height 决定节点矩形边界。data 字段是一个任意对象,用来放业务侧需要的状态,比如服务名、IP 地址、健康状态等。这里要特别强调一个思路:模型层的数据永远保持“干净”,不要把渲染时计算出来的临时坐标塞回 data 里,否则后面做撤销重做和历史状态管理时会非常痛苦。
Edge 负责连接关系。我在设计时没有在 Edge 里存任何路径点数组,而是只存 sourceNodeId + sourcePortId、targetNodeId + targetPortId 以及路由类型。路径的实际坐标是渲染阶段根据两端节点实时计算的,这样当节点拖拽移动时,连线能自动跟随更新,不需要手动同步。
Port 是节点上的连接锚点。我见过不少简化方案把连接点固定在节点四边中点,这样做确实省事,但一旦节点是圆形的或者内部有其他图形布局,连线箭头就会显得很不自然。diagram-design 把 Port 独立建模,每个 Port 有相对节点左上角的偏移坐标 offsetX / offsetY,以及一个方向属性。连线计算时从 Port 的绝对坐标出发,这样能适配任意形状的节点。
Group 是分组容器。它本身不是一个必须渲染出来的图形元素,而是一个逻辑坐标矩形,用来处理多选、整体拖拽和层级折叠。Group 的数据结构里持有成员节点 id 数组,计算成员整体边界时再看是否需要更新。分组功能不会在一开始就用上,但它存在的好处是,当用户框选多个节点后想整体拖动,你只需要找到这些节点共同所属的 Group,把位移应用到 Group 上,再遍历成员,不需要维护单独的多选状态。
2.2 坐标体系:内部坐标与视口坐标分离
这个设计点刚开始容易忽略,但越到后面越重要。用户看到的画布是有滚动条、有缩放比例的视口,而节点模型的 x / y 应该始终基于“画布真实坐标”,也就是内容坐标系。想象一下,你面前有一张巨大的图纸,图纸本身不动,但你用一个放大镜在图纸上方移动,放大镜里看到的区域就是视口。
diagram-design 里的核心换算关系就三行:
// 画布坐标转屏幕坐标 screenX = (canvasX - viewport.x) * viewport.scale screenY = (canvasY - viewport.y) * viewport.scale // 屏幕坐标转画布坐标 canvasX = screenX / viewport.scale + viewport.x canvasY = screenY / viewport.scale + viewport.yviewport 对象里维护 viewport.x、viewport.y(当前视口左上角对应的画布坐标)和 viewport.scale(缩放比例)。所有鼠标事件拿到的位置都是屏幕坐标,做命中检测、拖拽换算时先统一转成画布坐标,再从模型层取真实的节点位置做计算。这套分离逻辑最大的收益是,底层的模型计算和渲染逻辑完全不用关心用户是否滚动了画布、是否放大了画面,任何情况下坐标原点始终是画布的左上角,减少大量边界判断。
如果只是固定画布不缩放,坐标体系分离的优势还不明显。但图表编辑器几乎必然会支持缩放和拖动画布,没有这套抽象,后面的交互代码会越写越乱:拖拽时算位移要减偏移,命中检测时还要做反向换算,稍不注意就出现“明明点中了 A 节点,选中的却是 B 节点”这种怪问题。
2.3 状态设计:工具、选区、交互状态的分层管理
编辑器交互本质上是“当前处于什么模式 + 哪些对象被选中 + 正在发生什么操作”。diagram-design 状态设计上分为三层:
- 工具状态:当前使用的工具类型,有 select、hand、connect、draw 等
- 选择状态:当前选中的节点 id 集合、选中的 Edge id 集合、活动中的 Group id
- 交互状态:是否正在拖动节点、是否正在绘制连线、是否正在框选、是否处于缩放中
这三层状态在 UI 层面我是用一个可观测状态对象统一维护的,模型层不直接持有这些 UI 状态。比如节点被拖拽的过程中,模型层的节点坐标没有真正提交到历史记录,只有拖拽结束的那一刻才把最终坐标写入模型并生成一条 undo 记录。拖拽过程中必须保持高频刷新画布,但绝不能频繁改动历史栈,否则撤销一次操作要回退几十步。
这里也涉及一个细节:什么时候才允许用户跟画布交互?如果当前正在执行某个异步操作,比如远程加载子图数据,那么交互状态就应该置为“锁定”,捕获层直接忽略所有事件。状态设计清楚之后,新增一个工具或交互类型时,只需要扩展状态定义 + 实现对应的事件处理器,不需要翻出全部逻辑重写。
3. 渲染层选型:关于 Canvas 和 SVG 的认真比较
3.1 两种渲染方式的本质差异
diagram-design 最早也纠结过应该用 Canvas 还是 SVG 来渲染画布。这两个方案没有绝对的优劣,纯粹看使用场景。
SVG 的本质是声明式图形树,每个节点、每条连线都是一个 DOM 元素,天然支持 CSS、事件绑定、鼠标 hover、点击处理。对小型图(比如几十个节点)来说,SVG 开发效率非常高,调试也直观,浏览器开发者工具里直接能看到元素结构。但它的瓶颈也很明显:当节点数量上千、连线数量过千时,DOM 节点数量会拖垮渲染性能,特别是在拖拽时触发的大量属性变更会导致布局抖动明显。还有个隐藏问题是,SVG 的文本缩放表现通常需要额外处理,比如在画布缩放时字体大小应该保持不变还是跟着缩放?这部分逻辑实现起来比较绕。
Canvas 的本质是像素绘制,绘制指令走底层图形接口,它的性能优势体现在绘制大量图形时很稳定。整个画布只有一个 Canvas 元素,节点、连线全在同一个绘制上下文里,事件检测需要自己做点选命中,不能像 SVG 那样天然按元素绑定事件。但它的劣势也很明确:内部没有 DOM 元素结构,想要做“鼠标悬停到节点上显示提示文字”这类效果,需要额外维护检测逻辑;此外在视网膜屏上要考虑像素比缩放问题,否则文字容易模糊。
diagram-design 最后的方案是:核心渲染采用 Canvas,但通过分层策略把个别高频交互元素独立到 SVG 层。简单说,静态或低频更新的图形走 Canvas 绘制,拖拽过程中的辅助预览线、框选矩形、临时挂起的连线预览走 SVG 浮层,这样既兼顾性能,又让复杂交互元素能方便绑定事件、添加样式。
3.2 画布分层的具体实现思路
在渲染上,diagram-design 把画布拆成三个逻辑层:
- 底层主画布:负责渲染所有 Node、Edge、Group 的静态内容
- 中层交互层:负责渲染当前被选中的高亮框、拖拽预览、连线提示虚线、框选矩形
- 顶层覆盖层:负责显示缩放比例控制器、坐标提示信息这类纯 UI 组件
三层分离的好处是,主画布内部只关心“当前模型长什么样”,交互层关心“用户正在做什么”,覆盖层关心“用户操作后需要看到什么反馈”。它们之间通过一份公共的画布上下文对象互相沟通,上下文里保存视口配置、坐标换算函数、选中集合等。
这里有个很影响体验的细节:当用户拖动画布时,如果整个画布全部重新绘制,在高分辨率屏幕上会很卡。优化方式是把主画布内容绘制到一张离屏 Canvas 上,拖动画布时只做底层图形的平移重绘,节点内容本身不需要经过一次完整的重算和绘制。只有内容变化(如节点移动、新增连线)时才触发离屏 Canvas 局部区域失效重绘。这个策略在节点数量较多的场景下能明显提高帧率。
3.3 视口裁剪与脏矩形更新的实践参数
理论上无限大的画布不可能全部渲染出来,视口裁剪是必须做的。绘制前先算出当前视口在画布坐标系中的矩形范围,然后遍历节点时只处理与这个矩形相交的节点;连线同理,如果连线的起终点都不在当前视口范围里就直接跳过。裁剪判断的伪代码如下:
function isNodeVisible(node: DiagramNode, viewportRect: Rect): boolean { return ( node.x < viewportRect.right && node.x + node.width > viewportRect.left && node.y < viewportRect.bottom && node.y + node.height > viewportRect.top ); }具体到实际参数,我的经验是视口矩形向外扩展 200 像素再参与判断,因为很多节点有阴影、外发光或连线的箭头端点,严格裁剪反而会让边缘图形突然消失。扩展缓冲值用 200 是在我实测里效果比较合适的,既能避免边缘闪烁,又不会让过多节点参与渲染计算。
4. 交互实现全解:拖拽、缩放、框选与连线
4.1 拖拽与吸附:细节决定手感
拖拽是编辑器最基础的交互,但真正做起来,细节比想象中多。diagram-design 的拖拽流程分三步:命中检测、位移换算、位置回写。
命中检测发生在鼠标按下时。把鼠标坐标转成画布坐标后,按“最上层优先”的顺序判断:先判断是否点中了某个 Port,再判断是否点中了某个节点,再判断是否点中了某条连线,最后判断是否点中了画布空白区域。点击空白区域时清除当前选中状态。
命中检测里有一个高频踩坑点:如果节点内部有透明区域,点击时从视觉上看起来是点中了节点,但图像命中检测无法直接判断。解法是除了计算像素范围外,还要额外存储一份节点命中矩形记录表,每次点击直接查表,避免逐个遍历节点做矩形相交判断,也方便后续为节点增加不规则命中区域。
拖拽过程中的位移计算也很关键。鼠标移动时,每次拿到新的画布坐标,与起始坐标做差得到 deltaX / deltaY,再把 delta 应用到所有被选中节点的坐标上。如果是拖拽 Group,则放到 Group 的坐标上,由 Group 统一调整成员位置。拖拽结束时应进行一次吸附判断:如果节点离某个参考边界距离小于阈值(比如 8px),就把位置值吸附到参考边上。吸附功能看似简单,实则需要提前收集当前画布上所有节点与分组矩形的边界线集合,这对提升编辑器专业感帮助很大。
4.2 画布缩放与平移:以鼠标位置为锚点
缩放画布时,最反直觉的地方是缩放中心。用户通常期望的是“鼠标指向哪里,缩放后哪里保持不动”,而不是画布左上角固定缩放。diagram-design 的缩放逻辑核心是先记录鼠标位置对应的画布坐标,然后调整缩放比例,再把视口平移,让该画布坐标重新回到鼠标位置。
function zoomAtPoint(viewport: Viewport, px: number, py: number, newScale: number) { // 缩放前鼠标所指的画布坐标 const beforeX = px / viewport.scale + viewport.x; const beforeY = py / viewport.scale + viewport.y; viewport.scale = clamp(newScale, 0.2, 4); // 缩放后让 beforeX / beforeY 重新对准鼠标位置 viewport.x = beforeX - px / viewport.scale; viewport.y = beforeY - py / viewport.scale; }缩放倍率范围设为 0.2 到 4 之间是我处理大量业务图后的经验值。低于 0.2 时,图形密集区域挤成一团,语义基本不可读;高于 4 后,像素级拖动精度对大多数场景没有实际用途,反而增加渲染压力。平移画布在拖拽“画布空白区域”时触发,直接把 viewport.x / y 加上鼠标位移增量即可。如果开启了滚轮缩放,还要注意在键盘上按住 Ctrl 键时优先触发缩放而非浏览器页面缩放,这部分需要在事件处理里显式拦截。
4.3 框选:选区矩形与相交策略
框选的实现思路清单一:鼠标在空白区域按下后拖动,会生成一个临时矩形并显示在交互层。随后对画布上所有节点做“矩形相交”判断,与临时矩形相交的节点加入候选选中集合。这里要有个策略选择:是只要相交就选中,还是要求节点完全包含在矩形内才选中?diagram-design 默认采用后者,因为从用户直觉出发,框选一般倾向于“完全框住才算选中”。设置项里可以开放这个行为,但不能只有一种策略,否则在复杂图上两种预期对不上。
框选结束后,临时矩形从交互层移除,选区状态写入状态管理对象,并触发一次缓存命中区域的更新。推荐在框选判断时直接复用视口裁剪时生成的可见节点列表,已经不在视口内的节点没必要参与相交计算,节省不必要的遍历开销。
4.4 连线的核心实现:从端口触发到路径计算
连线交互是图表编辑器里视觉反馈最复杂的一部分。diagram-design 的连线流程是从源节点的 Port 上按住鼠标开始,随后在画布上移动时产生一条跟随鼠标的临时预览线。预览线在不同路由策略下表现不一样:
- 直线路由:直接绘制起点到鼠标位置的直线
- 曲线路由:使用贝塞尔曲线连接,控制点由起终点方向推导
- 正交路由:沿着水平 / 垂直方向绘制折线,实现类似电路图的连线效果
路由策略的选择可以做成可配置项,复杂业务图适合正交路线,因为视觉上更规整;拓扑图或架构图更适合贝塞尔曲线,看起来更柔和。连线路径统一封装在独立的 RouteCalculator 模块里,模型层不感知路径细节。
当鼠标拖动到目标节点附近时,需要做一次 Port 的吸附检测。计算方法是对目标节点上的每个 Port 做距离判断,小于阈值(通常 12px)就认为需要吸附到该 Port 上,并高亮显示吸附目标。真正释放鼠标后,Edge 数据才写入模型,同时把移动过程中产生的临时吸附清除,重新计算一次完整路径并绘制。这里需要注意:如果鼠标释放时没有吸附到任何有效 Port,应该丢弃此次连线操作,而不是生成一个只有起点没有终点的非法 Edge。
4.5 撤销重做:基于命令模式
diagram-design 的命令系统是独立模块。所有可以撤销的操作都是 Command 对象,它必须有 execute 和 undo 两个方法。比如 MoveNodeCommand 里记录了节点 id 和移动前的旧坐标、移动后的新坐标,撤销时把坐标更新回旧值,重做时更新到新值。AddNodeCommand 删除时只需要从节点集合中移除该 id,并把它放回到撤销栈的缓存区。
实现撤销重做时容易忽略两个细节。第一个是命令合并:拖拽节点产生的移动命令如果每一步都入栈,一次拖拽会产生几十条记录,撤销起来非常难受。方案是在命令入栈前判断栈顶命令是否同类型;如果同类型,则用当前命令覆盖栈顶命令的参数,而不是压入新命令。第二个是栈容量控制:历史记录栈最多保留 100 条命令,超过时丢弃最老的记录,避免长会话场景下内存被历史数据塞满。这个阈值的设定是实际使用后调出来的,比较平衡历史回溯长度与内存开销。
5. 工程化落地:从代码库结构到通用能力沉淀
5.1 目录设计与依赖方向
diagram-design 的工程化从第一天就按模块化思路组织,目录结构大致如下:
- core:图元模型、坐标计算、命令栈、事件总线、选择状态
- render:渲染引擎入口、Canvas 绘制器、SVG 交互层、离屏缓冲机制
- interaction:工具处理器、拖拽、框选、缩放、连线交互
- plugins:扩展机制、自定义节点注册、自定义路由注册
模块之间的依赖方向是 core 被 render 和 interaction 依赖,plugins 依赖 core 提供的扩展接口。core 层不应该出现任何跟 DOM、Canvas 或者鼠标事件相关的代码,这样才能保证模型层的纯逻辑能力可以被单元测试覆盖,也能在未来换渲染框架时不被连带重写。
5.2 插件扩展机制:自定义节点与自定义路由
diagram-design 的扩展机制遵循“注册表”模式。考虑一个典型的场景:业务上需要一种特殊的节点形状,比如菱形决策节点。不需要修改核心库代码,只需要在初始化阶段调用注册函数:
registerNodeType('diamond', { render(ctx, node, style) { // 自定义绘制逻辑 }, hitTest(point, node) { // 菱形区域的自定义命中判断 } });每个节点类型实际上就是一个渲染器 + 命中检测函数的组合。核心库内置几个默认类型,如下 矩形、椭圆、圆角矩形必备,但其他类型全部留给业务注册。同理,连线路由也可以注册自定义实现,比如某些领域需要的“力导向自动布局”就能通过这个扩展点注入。
事件系统在插件扩展里也很重要。核心库不会在业务代码里硬编码某个流程,而是通过事件总线发布“节点移动前”“节点移动后”“连线创建后”“分组更新后”等事件。业务侧可以订阅这些事件来驱动开放能力,比如节点位置变化后自动打开关联面板、连线创建后提示填写关系类型等。事件类型定义必须做到“语义稳定”,发布时机要固定,订阅方才能放心依赖。
5.3 数据导入导出:序列化协议设计
图表的持久化形态是 JSON。diagram-design 定义了一个最小但完整的 Schema:
{ "version": 1, "canvas": { "width": 2000, "height": 1200 }, "nodes": [ { "id": "node-1", "type": "rect", "x": 100, "y": 100, "width": 120, "height": 60, "data": {} } ], "edges": [ { "id": "edge-1", "source": "node-1", "target": "node-2", "sourcePort": "p1", "targetPort": "p2" } ], "groups": [{ "id": "group-1", "name": "services", "children": ["node-1", "node-2"] }] }这个 Schema 的特点是只保存业务必要数据和层级关系,不保存渲染层生成的临时路径坐标。回读数据时由渲染层根据模型重新计算完整路径,这样即使未来更新路由算法,旧数据也能以新样式呈现。版本号字段必须保留,目的是做迁移兼容。我在实践里遇到过旧数据里节点坐标是字符串类型的情况,导入时顺手做一次 float 转换,这种脏数据的容错在新版本迭代中几乎每个季度都会遇到。
5.4 测试与调试:给这个项目补上安全网
这种以图形交互为主的项目,常规的组件测试不够,还需要补充几何计算测试与交互流程测试。
我会为坐标换算、节点矩形相交、Port 吸附、命令栈撤销重做等纯函数写单元测试。典型用例比如:某节点运动到某位置后撤销,坐标必须恢复原样;多次缩放后某画布坐标再经过反向换算应与原值一致。这些逻辑是图形编辑器的心跳,一旦出问题,用户手感和数据正确性都会崩。
交互流程测试我用可控的模拟事件完成:在测试环境里构造虚拟鼠标事件序列,让脚本模拟“按下 -> 移动 -> 释放”的过程,然后断言节点坐标是否符合预期;再模拟一次撤销,看坐标是否回退。这套方式对规避回归非常有效,尤其是后续加了吸附、加了多选之后,老功能经常被不小心波及。
另外,浏览器性能调试有一个比较实用的小技巧:在 DevTools 的 Rendering 面板里开启“绘制闪烁”,当 Canvas 绘制频繁触发时可以看到明显的区域高亮,用这个来检查拖拽过程中是不是存在大量不必要的全画布重绘。我优化脏矩形更新策略时就是靠着这个观察手段定位到的问题。
6. 常见问题与排查实录
6.1 坐标系漂移问题:为什么点击位置一直不准
这个坑基本每个人都会踩。表现为:拖动节点后,节点看起来跟住了鼠标,但松手后位置轻微偏移;或者缩放画布之后点击节点,选中的却是旁边的节点。排查方向非常明确:检查鼠标事件里有没有精确换算成画布坐标,尤其要确认是否在当前缩放比和视口偏移下完成的换算。常见出错点是把 viewport.scale 漏乘了、或者把 viewport.x 加了两次。还有一点,Canvas 元素在网页中的占位也可能受 CSS 样式里的 padding / border 影响,需要在计算鼠标相对位置时减去元素本身的偏移。
6.2 拖拽卡顿问题:性能优化从哪下手
节点一多,拖拽就明显掉帧。遇到这个问题,先别急着优化算法,而是先定位瓶颈。打开浏览器性能面板录制一段拖拽过程,看是脚本执行时间长,还是绘制时间长。如果是脚本执行时间占比高,优先检查是不是每次拖拽都触发了全量节点遍历与全量排序;如果是绘制时间长,就考虑离屏缓冲和脏矩形更新。diagram-design 的经验是,画布比较大时把节点列表按坐标排序,利用空间索引结构加速可见区域筛选,而不是每次遍历全部节点。把这个优化做完,上千节点的画布也能保持较高帧率。
6.3 连线路径肉眼看不对劲:贝塞尔曲线方向问题的修正
画贝塞尔曲线时,如果控制点方向处理错了,连线会出现明显的“穿堂绕路”现象,比如从节点 A 右侧连接到节点 B 右侧,曲线却先往左扭一大圈再拐回来。控制点方向的推导规则应该基于 Port 的方向:如果 Port 在节点右边,控制点应该向右侧延伸;如果在底部则向下延伸。延伸长度通常取两端口距离的 0.4 到 0.6 倍,控制点距离过短时曲线会很僵硬,过长则会出现明显的 S 形波浪。我实际调下来觉得取两端点直线距离的 45% 比较顺眼,你可以根据自己的视觉风格微调这个系数。
6.4 撤销栈内存异常增长
撤销记录里如果保存了整棵节点树快照,每次操作节点都会造成大量内存占用。解决方案就是前面提到的命令模式:尽量只保存差异数据,例如移动节点只保存旧坐标、新坐标和节点 id,不需要把整个节点的所有属性全部快照。删除节点时确实需要临时保留完整节点数据,以便撤销时恢复,但可以限制撤销栈深度并及时清理。还有一个容易被忽略的点:命令栈里的节点 id 引用如果绑定的是业务侧对象,删除或重建节点时可能出现悬挂引用,所以在数据清理时也要同步清理那些跟已删除节点相关的命令。
7. 一次核心功能从零到上线的实现复盘
讲原理和踩坑都已经不少,这一节我想用一次具体功能“自定义节点组件”的实现过程做完整串联,帮助你把前面所有概念串起来。业务需求来自某场景:用户希望在画布里加入“数据库存储”节点,这个节点内部除了名称,还要展示存储量占用比例条,并且在节点被选中时出现额外操作按钮。
实现这个功能,第一步是让类型注册机制响应业务需求。调用 registerNodeType,注册一个名为 storage-node 的节点类型,它的 render 函数会在绘制节点矩形之外,额外绘制一条表示存储占用率的进度条。注意这里要保证 render 函数完全基于 node.data 读取数值,不依赖任何外部全局状态,这样每次模型更新后重新绘制的结果都是可控的。
第二步是处理命中检测。普通矩形节点的命中逻辑直接用宽高范围判断,但 storage-node 内有一个特殊子区域:当用户点击进度条时,我们希望选中节点并额外弹出一个可拖拽的滑块,用来实时修改存储占用率。这就要求命中检测返回结果还要带一个子区域标识。扩展思路是命中函数返回命中区域对象,核心库根据区域标识决定显示哪种操作浮层。这个设计验证了插件机制的灵活性:核心库自身完全不知道“进度条”的存在,但通过插件扩展完全可以承载这类业务交互。
第三步是命令记录。通过滑块更改存储占用率本质上是一个业务操作,核心库并不理解“存储占用率”这个概念,但提供了一条通用命令:UpdateNodeDataCommand。它记录节点 id、旧 data 值和新 data 值,undo 时还原旧 data,redo 时应用新 data。业务侧只需要构造命令对象并提交到命令栈,即可获得完整的撤销重做能力。这个例子非常有代表性,它说明核心库不一定要为所有业务场景设计命令,提供通用数据更新命令配合插件自定义语义,就能覆盖大量实际需求。
我的一点心得收尾
做了这个项目之后,我对图形编辑器的认知有一个很大的变化:真正决定工具好不好用的,往往不是渲染引擎多快、动画多炫,而是模型是否清晰、交互状态是否可预测、命令系统是否稳健。很多功能做到一半返工,根本原因不是技术不熟,而是最开始的数据结构抽象不够,后面所有系统都在给这个漏洞买单。
diagram-design 对我个人最大的价值不是代码本身,而是一套可以复用的思维方式。后来再做其他可视化相关需求,我都会下意识先问三个问题:图元模型怎么拆、坐标体系怎么分、交互命令怎么管。如果你也正准备做一个类似的项目,我建议从这三点入手,先把地基打稳,再去追求视觉效果和功能丰富度。项目源码在持续维护中,后续我还会把多选对齐、自动布局、迷你地图这些常见能力逐步沉淀进插件体系里。