
diagram-design 这个名字是我在某次重写可视化监控面板时随手起的。当时要做的事其实很朴素把几十个服务的调用关系、运行状态、变更记录画成一张能交互的大图支持拖拽、缩放、框选、连线并且允许业务方自定义每个节点的长相。市面上现成的图编辑工具我几乎试了一圈最后发现真到了“每个节点都要长得不一样”的时候有的大厂库太重有的开源库把数据结构绑得太死与其在别人的抽象里做各种 workaround不如维护一个自己的图内核。于是 diagram-design 就从那个项目里长了出来——它不是某个完整业务编辑器而是一个专门负责“用节点和边表达关系”的设计内核。这篇文章就把我在做 diagram-design 过程中沉淀下来的核心设计思路、踩坑过程和取舍依据写一写如果你也在考虑自研图编辑器、图渲染内核或者只是想把流程可视化做得更顺手应该能派上用场。1. 为什么我明知道轮子一堆还是写了 diagram-design很多人一听“重复造轮子”就皱眉头。说实话我也皱眉头。图编辑器这种领域已经卷得不能再卷从流程图到脑图到架构图能直接调用的开源项目一大把。但问题在于现成的东西解决的是“通用场景”而很多业务场景需要的不是通用是“半定制”。1.1 现成工具到底卡在哪我整理过三类常用方案每一类都有明显的边界第一类是“全能型图编辑器”界面成熟插件不少但它的节点类型、属性面板、序列化格式都是按自己那套体系设计的。你想让一个节点同时展示实时监控数据、支持点击后弹出详情、并且根据后端状态改变边框颜色往往得去翻它的插件 API甚至要 hack 它的内部数据结构。短期能跑长期升级版本时特别痛苦。第二类是“图形渲染底层库”它只给你画布、元素、事件绑定相当于一个带图元语义的 UI 框架。这种库自由度很高但等于把编辑器的交互逻辑全部交给你。撤销重做、框选、多选、连线吸附、图层顺序、视口变换、自动布局……每一项都是细节活。我当时算了一下真要把这些功能都补齐工作量不比自研一个小内核少。第三类是“业务定制编辑器”多为某个特定场景而做比如只画 ER 图或者只画网络拓扑。这种方案的问题是想跨越场景复用几乎不可能。你今天用它在画网络拓扑明天另一个项目需要画组织架构图数据模型和渲染方式都得改。1.2 我把 diagram-design 定位成什么反复权衡之后我给 diagram-design 定的位是一个与具体业务解耦的“图的运行时内核”。它只负责五件事管理节点Node、端口Port、连线Edge和组Group的数据结构管理画布的视口变换缩放和平移提供基础的交互能力拖拽、框选、连线、编辑提供可插拔的布局算法入口提供统一的序列化与导出能力。业务长什么样diagram-design 不管。节点里是放一个表格放一个饼图还是放一段视频业务方通过注册自定义渲染器来决定。这就是我选择自研而不是直接套现成框架的原因我不想被一家项目的节点模型绑住也不想把所有的交互细节都从零写一遍。diagram-design 更像是“半成品的底座”既给了够用的交互能力又保留了上层的自由。2. 渲染层选型Canvas、SVG 和“全都要”之间的坑作图工具最核心的决策之一就是底层用 Canvas 还是 SVG或者两者混合。这个决策会影响后续所有的工作量。我在 diagram-design 早期做过一次特别失败的实验这里值得拿出来说。2.1 我从一次反例里学到的教训当时有位一起做项目的同学建议“既然有的节点偏图形化有的节点偏文本化那干脆图形节点用 Canvas 画文本节点用 SVG 画两边的优点都占了。”听起来很美好结果做下去就发现问题了。Canvas 和 SVG 是两种完全不同的事件模型。SVG 天然支持元素级事件比如 mouseenter、click 可以挂在某个g元素上Canvas 则需要自己做坐标换算、自己命中检测。当两种渲染方式混在同一个画布里框选、拖拽、图层计算都要分别维护一套逻辑。更要命的是一个 Canvas 图层叠在一个 SVG 图层上面两者的坐标对齐一旦因为浏览器缩放产生像素偏移就会出现鼠标点了半天选不中的诡异问题。我把这个方案推翻之后得出一个很朴素的结论不要为了炫技去做双渲染内核除非你对边界情况有十足的把握。2.2 实测之后我为什么选择 Canvas 主渲染 HTML 浮层我在 diagram-design 里最终采用的方式是主画布用 Canvas 渲染所有图元节点内部的复杂编辑控件用 HTML 浮层叠加。这个方案并不是什么黑科技但它把问题分成两个清晰的层次。Canvas 负责看起来“重”的部分节点边框、阴影、连线、曲线、箭头、缩放动画。SVG 在这种场景下的缺点是节点数量一多DOM 节点数会跟着膨胀。我测过一个大约有 300 个节点、600 条边的图用 SVG 渲染时页面里的元素轻松超过两千个鼠标拖拽缩放明显有迟滞感而 Canvas 只有一块画布没有 DOM 压力性能问题只剩重绘范围这个可控变量。HTML 浮层负责需要“编辑”的部分双击节点后弹出一个输入框或者把节点内容区域的复杂图表交给业务方用 HTML 渲染。这样既避免 Canvas 里做文字编辑的麻烦也没有把节点的渲染全部塞进 Canvas 的绘制代码里。我的建议是如果你的场景里节点数量不太可能超过 200 个SVG 完全够用开发效率还更高如果你要面对的是几百上千个节点、并且节点样式经常变化Canvas 其实是更稳妥的路线。diagram-design 选择 Canvas是因为它面向的场景就是大图、高性能、强交互。3. 节点、端口、连线与分组先把图的“地基”敲定很多图编辑项目死在起步阶段不是因为交互做不出来而是因为数据模型设计得太随意。今天画个框明天画条线全凭痛点驱动最后代码里到处是临时补丁。diagram-design 开局做的最重要的一件事是把节点、端口、连线这三个概念拆清楚。3.1 端口必须是一等公民我第一次做图工具时连线就是简单的“从节点 A 连到节点 B”坐标直接记录两个节点的中心点。这样做的后果是一旦节点被用户拖动连线就得跟着重新计算端点如果节点边框是圆角矩形线头还会穿进节点内部难看得要命。diagram-design 的数据模型里增加了一个概念端口Port。端口挂在节点上表示这个节点允许从哪里连出、从哪里连入。下面是一个简化版的数据结构type PortDirection left | right | top | bottom | free; interface PortData { id: string; nodeId: string; direction: PortDirection; offset: { x: number; y: number }; label?: string; } interface NodeData { id: string; type: string; x: number; y: number; name: string; width: number; height: number; ports: PortData[]; properties: Recordstring, unknown; } interface EdgeData { id: string; sourcePortId: string; targetPortId: string; label?: string; router?: string; }端口不一定非要在节点边框上它也可以是“虚拟锚点”。如果业务上只想让连线连到节点周边任意位置我会在节点上动态生成一个自由方向的端口而不是在线数据里写死一个坐标。这样做的好处是路由计算逻辑统一所有连线都是“端口到端口”不会出现一条连线只管两个坐标点、另一条连线又去查节点边框的混乱局面。3.2 逻辑坐标与视口坐标必须分离另一个容易被忽略的设计是节点坐标的存法。diagram-design 的数据模型里节点永远保存的是逻辑坐标也就是不经过缩放和平移的坐标。渲染时再由视口变换换算成屏幕坐标。为什么要分离因为如果你把节点坐标直接存成“屏幕上看到的位置”一旦用户缩放你就要去改所有节点的坐标值随之而来的还有连线端点的坐标整个数据模型都会被视图状态污染。正确的思路是让视图状态挂在画布层比如{ zoom, scrollX, scrollY }节点只关心自己在一个无限画布上的逻辑位置。这样序列化、撤销重做、多端同步都会简单很多。我在实现里还引入了一个轻量级的变更集机制。用户每次拖拽节点、修改文本、增加连线不会直接 mutate 原来的数据对象而是生成一个描述变化的 patch然后应用到整个图数据上。这个 patch 会被记录下来撤销和重做就变成了简单地回放或回退 patch 列表。用一句话概括diagram-design 的内核状态管理做得像一个极简的 Redux只是它管理的是图数据而不是页面数据。4. 自动布局让层级图不打架让网状图不缠绕如果只是手动拖拽那图编辑器其实已经够用了。但业务场景里经常会出现几十上百个节点手动排一遍不仅慢而且很难整齐。diagram-design 把布局算法定位成可插拔模块核心内置了两种方向层级布局和力导向布局。4.1 分层布局的工程化思路层级布局最适合流程图、架构图、依赖关系图这类有明显方向性的图。它的基本流程是先对节点之间的边做个方向判断构建一个有向图用拓扑排序把节点分配到不同层级处理环状依赖如果存在环就把一部分边反向或者标记为“弱边”在同一层内调整节点顺序减少连线交叉根据层级和层内顺序计算最终坐标。第一次做分层布局时我犯了一个很典型的错误按原始顺序从左到右排列节点结果连线交叉得乱七八糟。后来才明白交叉问题必须在“同一层内做排序”这一步解决不能靠最后微调。具体做法是遍历每条跨层边如果它从左侧连到右侧就让目标节点尽量靠近源节点反复迭代几次之后交叉数量会明显下降。4.2 力导向布局不是万能药力导向布局适合网状结构比如知识图谱、社交关系图。它的原理可以概括为一句话相连的节点互相吸引不相连的节点互相排斥经过多轮迭代后达到一个相对稳定的位置。理论上很美实际用起来有两个麻烦。第一是“随机初始位置”影响很大。如果初始位置选得不好算法可能收敛到一个局部最优解出来的图依然是乱糟糟的一团。我的做法是先跑一遍基于广度优先的初始排布把节点按层级粗略散开然后再进入力导向迭代。第二是“用户意图”会被算法覆盖。用户手动调整过的位置如果重新跑一遍自动布局很可能被冲掉。diagram-design 的处理方式是给节点加一个layoutLocked标记。被标记的节点不参与布局计算其它节点绕着它排。这样自动布局只是辅助而不是对用户指手画脚。还有一个很实用的经验布局算法的结果不要直接写回数据模型。diagram-design 的做法是把计算结果暂存为一个“布局候选”在界面上用虚线预览给用户看用户点确认后才应用到画布。这样用户可以比较布局前后的差异也方便一键撤销。5. 交互打磨视图变换、命中检测和拖拽最容易翻车图编辑器里“看起来很简单”的交互往往是实现起来最容易翻车的地方。我花了很长时间才把坐标换算、命中检测、拖拽连线这三个细节理顺。5.1 坐标换算先把世界坐标和屏幕坐标的关系建立起来Canvas 里所有的鼠标事件拿到的都是屏幕坐标也就是相对浏览器画布左上角的像素位置。但图里的节点存的是逻辑坐标所以必须做一次坐标变换。如果视口状态是const viewport { zoom: 1.2, scrollX: 80, scrollY: 48 };那么逻辑坐标转屏幕坐标const screenX node.x * viewport.zoom viewport.scrollX; const screenY node.y * viewport.zoom viewport.scrollY;屏幕坐标转逻辑坐标const logicalX (screenX - viewport.scrollX) / viewport.zoom; const logicalY (screenY - viewport.scrollY) / viewport.zoom;这套公式看似简单但容易出问题的地方在于鼠标事件对象里的坐标有时候是相对画布容器的有时候是相对页面的如果不统一基准就会出现在某个缩放比例下选中准确、换个比例就偏移的情况。我的经验是在所有事件处理入口统一转成“逻辑坐标”后续的命中检测全部在逻辑坐标空间进行避免来回换算。5.2 命中检测从遍历节点到简单的空间索引Canvas 没有 DOM 帮你检测点击了谁所以必须自己搞命中检测。最直接的做法是遍历所有节点判断鼠标坐标是否落在节点矩形范围内。几百个节点时没什么问题几千个节点时鼠标一动就要遍历全部节点性能就有压力了。diagram-design 里做了一层轻量级优化把节点按zIndex排序命中检测按从顶层到底层的顺序进行同时维护一个简单的网格索引把画布切成若干块鼠标落在哪个格子就只检测那个格子里的节点。这个方案比四叉树简单写起来也快实际项目中几千个节点时依然能保持流畅。连线的命中检测比节点麻烦一点因为连线是一条曲线鼠标一般不会精确点在线条上。我设置的逻辑是计算鼠标点到曲线的最小距离小于一定阈值就认为命中。阈值不要用逻辑坐标里的固定值否则缩小时会变得极难选中。应该在命中检测时把阈值乘以视口缩放比例或者统一在屏幕坐标下计算距离。5.3 拖拽连线与自动吸附很多用户习惯从节点的一个端口拖出一条线连到另一个节点上。diagram-design 的交互逻辑是拖拽开始时创建一条临时的边尾端跟随鼠标移动鼠标移动过程中不断寻找鼠标附近的端口找到后给这个端口高亮并显示吸附提示松开鼠标时如果吸附目标存在就生成正式的边否则丢弃临时边。吸附阈值的细节很容易踩坑。如果阈值用逻辑坐标的 8 像素在缩放到 200% 时实际吸附范围就是屏幕上的 16 像素会显得很灵敏且难控制在缩放到 50% 时阈值又只有屏幕上的 4 像素用户得精确对准才能连上。正确做法是给吸附逻辑提供独立的屏幕像素阈值计算时先将端口坐标转成屏幕坐标再用鼠标事件里的屏幕坐标去算距离。拖连线的同时还要注意目标端口到底允不允许当前这条边连入。有些节点只允许一个入边有些边不允许循环连接这些业务约束应该在拖拽过程中就实时反馈不能等松手后再弹错误提示。6. 把图“留下来”序列化、导出与业务插件化一个图编辑器如果只是画得开心但保存不了、导出不了、复用不了那它就是一个玩具。diagram-design 在序列化和导出上做了不少细节处理这里面有几个地方值得单独说一说。6.1 序列化格式要“可读、可合并”我最开始把图数据直接 JSON.stringify 存到后端结果版本升级时模型一变老的图数据就解析不了了。后来加入了一个version字段格式大致如下{ version: 2, viewport: { zoom: 1, scrollX: 0, scrollY: 0 }, nodes: [ { id: node_1, type: server, x: 120, y: 80, name: api-server-01, properties: {} } ], edges: [ { id: edge_1, sourcePortId: node_1.out, targetPortId: node_2.in } ] }这里最重要的一点是不要让序列化格式变成“给机器看的黑盒”。如果业务系统要基于图数据做二次处理比如统计节点类型、分析链路耗时、合并多张图的数据那么可读的 JSON 远比二进制格式好用。diagram-design 的图数据中保留了节点的id并且把业务属性放在properties里这样内核升级时只要保证properties的兼容图就能继续打开。合并两张图也是一个高频需求比如把两张子图拼成一张大图。我的做法是导入时对节点 id 做一层映射避免 id 冲突同时对外暴露一个mergeGraph方法。很多编辑器没有处理这种场景导致用户只能手动复制粘贴非常痛苦。6.2 导出图片的尺寸坑导出 PNG 时最容易踩的坑是画布大小。如果直接用 Canvas 对象去 toDataURL导出的只是当前屏幕可见的区域不是完整的图。正确做法是根据所有节点的包围盒计算出图的大小然后把 Canvas 的宽高设置为逻辑尺寸乘以导出倍率。但这里有一个隐藏限制const canvas document.createElement(canvas); canvas.width worldWidth * exportScale; canvas.height worldHeight * exportScale;一旦图很大、导出倍率设得很高Canvas 的宽高会超过浏览器的最大限制结果 canvas 变成空白或者直接报错。diagram-design 里的应对方案是先检测目标尺寸是否超过限制如果超过就把图切成多个区域分别渲染到离屏 Canvas最后拼接到一张大图上。这个过程不复杂但需要在导出逻辑里多写一步很多项目就是漏了这一步导致大图导出经常失败。导出 SVG 的话反而没这个问题因为 SVG 是矢量格式可以直接描述完整世界坐标下的图。diagram-design 的导出模块里同时保留了 PNG 和 SVG 两种路径PNG 用于做缩略图和外部嵌入SVG 用于正式文档和后续编辑。6.3 节点渲染工厂让内核不认识你的业务最后这一点可能是 diagram-design 与普通图库最大的区别。它本身不内置“服务器节点”“数据库节点”“用户节点”这些概念而是提供了一套节点渲染工厂。业务方可以通过注册接口把自己定义的节点类型挂进来diagram.registerNodeType(server, { render(ctx, node, options) { const { x, y, width, height } node; // 绘制圆角矩形 ctx.beginPath(); ctx.roundRect(x, y, width, height, 8); ctx.fillStyle #334155; ctx.fill(); // 节点里的业务信息通过 node.properties 读取 const status node.properties.status; ctx.fillStyle status ok ? #22c55e : #ef4444; ctx.beginPath(); ctx.arc(x width - 16, y 16, 6, 0, Math.PI * 2); ctx.fill(); }, resizeHandles(node) { return [right, bottom]; }, allowConnect(port, targetPort) { return port.direction right targetPort.direction left; } });连线的样式也可以注册。比如业务方想用流动的虚线代表异步消息想用红色曲线代表异常链路都可以注册成自定义的edgeRenderer。diagram-design 内核只负责把节点和边的位置算好、把交互事件派发好剩下的视觉表达完全交给业务注册表。这样做的好处是明显的内核的代码量可以一直控制在可维护的范围内新业务接入时不需要改动公共代码只注册自己的渲染器就行。坏处也有就是文档和调试成本变高。所以我给 diagram-design 也内置了一个调试面板能实时查看当前图上每个节点挂载的类型、属性和端口信息这算是给开发者留的后门。回到开头的问题到底有没有必要自研一个图设计内核我的结论是如果只是一个流程图 Demo完全没必要如果业务里出现大量定制节点、大数据量、频繁的布局和导出需求自研一个小的图内核反而能省去长期改造外部库的隐性成本。diagram-design 现在的样子也不是我一开始想好的而是在反复的项目需求里逐渐长出来的。你如果也要走这条路最稳妥的做法是先拿两个不同的业务场景试水等把公共模型抽出来再继续加功能。框架和内核这东西只有被真实的需求磨过才知道哪里该宽、哪里该窄。