
做技术方案、写文档、梳理系统架构的时候大家都遇到过同一个烦恼一张架构图或流程图手工画起来太费劲改起来更费劲。节点一多连线一乱整张图就成了没人敢碰的蜘蛛网。很多人转向文本绘图工具用一串代码生成一张静态图可图一旦生成就基本没法再交互编辑。我在实际折腾过几轮之后越来越确信一件事图表的本质是数据而不是像素。diagram-design 这个项目的出发点就是把设计一张图变成描述一份数据你用声明式的结构定义节点、连线、分组由布局引擎自动排布再在画布上做局部微调。它解决的正是大规模图表不可维护和代码生成图不可交互这两头都不靠的问题。这篇文章会把 diagram-design 的核心设计思路、底层数据模型、布局算法、渲染选型、交互实现和踩坑过程完整拆开讲一遍。适合正在做画图工具、数据可视化或者只想给自己的文档系统配一个可编程画布的前端开发者、文档工程师和架构师阅读。1. 为什么一个声明式绘图工具是刚需1.1 手动绘图的失控临界点先说一个很常见的现象。用桌面绘图软件或者在线白板画图前 30 个节点的时候一切尽在掌握到 100 个节点布局开始乱到 500 个节点整张图基本就沦为一张带箭头的蜘蛛网。问题的根子不在软件而在人工布局这件事本身不具备规模化能力。人脑能同时处理的拓扑关系很有限连线一旦跨层、跨分组手工调整就是一场灾难。所以大多数正规项目里架构师画到一定规模就放弃了改用文字配上几张局部截图。这其实是不得已的妥协因为整图已经无法维护。diagram-design 想解决的核心问题就是把布局这个最消耗人工精力的事情交给算法让人只负责表达有哪些节点、它们之间什么关系。1.2 文本绘图工具与交互编辑器的两极分化文本绘图工具是另一条路。优点很明显图随文走能进 Git能 diff能复用。但短板同样明显——生成的是静态图想拖一下节点位置、微调布线路径做不到。交互编辑器恰好相反手动调整非常灵活可它的文件格式很难读保存下来的内容基本就是一坨坐标数组换个人修改等于重新画一遍。diagram-design 想坐的位置是中间地带DSL 描述图结构引擎负责初始布局画布负责交互微调最后再把微调结果以数据形式写回。也就是说你仍然可以像写代码一样去写图同时又能在画布上手工调整细节调整完之后的结构依然是清晰的、可回读的数据。我最初就是想在一个内部文档系统里用这套思路替代静态截图方案。那时候我逐渐意识到很多人不是不会画图是没有一套可靠的图的工程化方案数据模型、布局算法、渲染引擎、交互录制这四样东西缺一不可市面上现成的开源工具要么只覆盖其中一两层要么绑定太重。这才有了 diagram-design 的雏形。2. 图的底层模型节点、连线、端口与自动布局2.1 四种核心元素真正实现 diagram-design 的第一步不是写渲染而是定义数据模型。我把它收敛成四个最基础的元素节点表示实体边表示关系端口表示连线的附着点分组表示容器或者泳道。一段典型的 DSL 描述长这样{ nodes: [ { id: api, label: API Gateway, group: proxy }, { id: svc-a, label: 服务A, group: backend }, { id: svc-b, label: 服务B, group: backend } ], edges: [ { id: e1, source: api, target: svc-a }, { id: e2, source: api, target: svc-b } ], groups: [ { id: proxy, label: 接入层, kind: swimlane }, { id: backend, label: 业务层, kind: swimlane } ] }端口并不总需要显式声明默认取接口方向即可但复杂场景下必须暴露。比如一个节点既要接收消息又要返回应答两对端口的锚点位置完全不同。端口模型做得好不好直接决定后续连线自动路由的复杂度。2.2 布局引擎分层优先布局算法我调研了很长时间最后在 diagram-design 里默认选择了分层布局而不是力导向布局。原因是多数架构图、流程图本质都是有向无环图天然适合分层排布力导向布局虽然通用结果却不可预测相同的数据每次运行出来的形状都可能不同。分层布局的核心链路拆开是三步分层用最长路径法给每个节点分配层级。起点在第 0 层它指向的所有点在第 1 层依此类推。减少交叉在同一层内调整节点顺序减少边之间的交叉数。这一步常用的是重心启发式重复迭代几次。坐标分配根据层级间距和同层节点间距把层数换算成 y 坐标把节点序换算成 x 坐标。一个简化版本的分层逻辑大概长这样function assignLayers(nodes, edges) { const inDegree {}; const layers []; nodes.forEach(n (inDegree[n.id] 0)); edges.forEach(e inDegree[e.target]); // 把所有入度为 0 的节点作为第 0 层 let current nodes.filter(n inDegree[n.id] 0); while (current.length) { layers.push(current.map(n n.id)); const next []; current.forEach(n { edges.filter(e e.source n.id).forEach(e { if (--inDegree[e.target] 0) next.push(nodes.find(x x.id e.target)); }); }); current next; } return layers; }分段横平竖直的效果靠的正是这套分层逻辑而不是手工对齐。这也是后来所有交互编辑的前提初始布局自动化之后人力只需要处理例外情况。2.3 数据合法性校验先别画先检查很多画图工具是边输入边画到后面才发现连线断头了、环路绕死了。diagram-design 的做法是提交 DSL 之后先做一轮静态检查再进入渲染管线。我实际加了三个检查项有环检测分层布局只支持有向无环图发现环直接报错并标出形成环的边集合。孤点检查没有连线的节点默认保留但给出警告避免用户以为它被吞了。端口引用检查如果一条边的 source 指定了端口但该节点并没有这个端口直接标红。这些校验看起来简单实际价值非常大。它把运行时才爆出来的问题提前到了输入时就能发现尤其在多人协作、DSL 被外部系统生成的情况下这层防线能省掉大量排查时间。3. 渲染层选型的三角博弈兼容、性能与开发效率3.1 三种渲染方案的实际对比数据模型定好之后最花时间的其实是渲染层选型。DOM 方案交互实现最简单节点就是 div连线用 SVG 或 CSS 拼但节点一多就卡。Canvas 性能最好可所有交互命中都要自己算开发量翻倍。SVG 是最均衡的选择单个元素天然绑定事件缩放平移可以用 viewBox 实现样式控制灵活性能虽然不如 Canvas但守住千节点以内顺畅交互这个底线是没问题的。我在 diagram-design 里做了个混合策略默认全量用 SVG 渲染节点和连线当画布内可见节点超过某个阈值时启用 Canvas 渲染静态背景层SVG 只保留交互层。这样做的好处是视觉上还是同一张图但大图拖拽时浏览器压力小很多。方案交互命中性能上限开发成本适用场景DOM CSS天然支持低数百节点卡顿低小型流程图SVG天然支持中千节点内可用中架构图、流程图Canvas需手动计算高可支撑万级节点很高复杂关系图、海量数据图3.2 坐标系统设计世界坐标与视口坐标分离渲染层最容易踩的坑是把缩放平移和业务坐标混在一起。diagram-design 里始终维护两套坐标系图坐标是节点真实的逻辑位置视口坐标是用户当前看到的屏幕位置。所有交互事件先记录为视口坐标再经过矩阵运算换算成图坐标再回写数据。缩放锚点也是个容易出问题的点。正确的做法是以光标为中心缩放缩放后应该让光标所指的图坐标点仍然落在光标下方。实现上只要记录缩放前光标下的图坐标缩放后反推视口坐标再把画布平移到差值位置即可。这个细节如果做错用户会感觉缩放时图总往角落跑。3.3 渲染管线全量重绘是性能天敌初版我偷懒任何数据变化都直接调一遍全量渲染函数。200 个节点时没问题到 500 个节点且开启拖拽时帧率明显下降。后来改成了分片渲染把图元分为静态元素和动态元素只有动态元素发生变化时才触发局部更新。拖拽过程中节点位置高频变化我会用 requestAnimationFrame 合并同一帧内的多次更新只重绘被影响的节点和它关联的边其他区域原样保留。这个优化做完之后500 节点的拖拽体感从卡顿到怀疑人生变成跟手本质上是把 O(n) 重绘降成了 O(受影响元素) 重绘。4. 编辑交互里最花时间的四个细节4.1 拖拽节点与吸附系统画布上的节点可以自由拖拽但要拖得舒服吸附系统是必须的。diagram-design 里做了两类吸附中心点对齐吸附和边界间距吸附。拖动节点时如果它的中心线与其他节点的中心线距离小于阈值就自动贴齐边界吸附则让两个节点靠近时能整齐地保持统一间距。阈值我实测下来设 5 像素最舒服太大容易跳来跳去太小感觉不到吸附。吸附判断还得分优先级先比对中心线再比对边界。否则多个节点靠近时会出现反复横跳的抖动感。有一个交互原则值得单独说吸附只改变视觉上的位置不修改数据里的相对坐标。数据模型里仍然存真实坐标吸附只是渲染时的一层微调。这样用户随时关掉吸附节点也能回到它的原始逻辑位置。4.2 连线交互从临时曲线到自动正交路由画连线是绘图工具里交互难度最高的部分。用户从一个端口拖出连线时我画一条贝塞尔曲线作为临时预览松开鼠标那一刻调用自动路由算法把这条边重排成横平竖直的正交折线。为什么架构图一定要正交折线而不是直接拉一条斜线因为斜线在大量节点交叉时会形成视觉噪声正交线段更容易追踪路径。diagram-design 里实现正交路由用的是网格化加最短路搜索把画布划分成虚拟网格障碍物标记为不可通行格然后用启发式搜索算法找一条绕过节点的路径最后把同向相邻的线段合并降维。这段算法单独调了两周才稳定。最麻烦的不是找路而是别绕太远。有些路径虽然避开了一切遮挡但绕出了一个明显不合理的圈子。解决方法是给搜索算法加上转弯惩罚和路程惩罚让结果优先选择少转弯的近路。4.3 撤销重做命令模式比快照模式可靠撤销重做是所有编辑器绕不开的硬骨头。第一版我用的是快照方案每步操作之后把整个图数据 JSON 序列化存进历史栈。听起来简单实际一用就崩500 节点的图一步操作就是几 MB 内存撤不到十步浏览器就开始喘。而且快照恢复后焦点、选中状态全部丢失体验割裂。后来老老实实改成命令模式。每个操作都封装成一个命令对象内部保存 execute 和 undo 逻辑。移动一个节点时命令里只存节点 id、旧坐标、新坐标创建一条连线时存这条边的完整定义与图上前一个状态。撤销时执行 undo重做时执行 execute。interface Command { execute(): void undo(): void } class MoveNodeCommand implements Command { constructor(private nodeId: string, private from: Point, private to: Point) {} execute() { updateNodePosition(this.nodeId, this.to) } undo() { updateNodePosition(this.nodeId, this.from) } }这版改完之后撤销再也不会把历史记录污染掉内存占用也降到了可忽略的程度。命令模式虽然前期开发量更大但对交互编辑工具来说这是一笔必须还的技术债。4.4 框选、复制粘贴与快捷键框选用的命中检测做了两版。第一版直接遍历所有节点做矩形相交判断200 个节点以下没问题上千节点开始拖框时明显掉帧。第二版引入网格索引把节点按空间位置挂到格子桶里框选时只遍历覆盖范围内的格子耗时从 O(n) 降到接近 O(可见数量)。复制粘贴的坑在于边的映射。复制一批节点时不能把原边的 id 直接带过去否则新图里会出现边引用旧节点的问题。正确做法是先复制节点并建一张旧 id 到新 id 的映射表再基于映射表重建边。这步不做粘贴出来的图一定是断的。快捷键体系我沿用了主流设计工具的习惯Delete 删除选中、Ctrl/Command C/V 复制粘贴、Ctrl/Command Z/Y 撤销重做。多个节点同时选中时删除必须批量执行成一个命令否则撤销一步一步撤销单个节点体验会很碎。5. 跑通 Demo 后我踩过的三个大坑5.1 初次渲染布局抖动文本宽度没测量第一次跑通整个流程我的心态是终于能画图了但很快发现一个诡异现象图渲染完成后部分节点会突然换行撑大节点本身旁边的连线位置跟着偏移整张图像被冲了一下。一开始我怀疑是布局算法不稳定反复检查分层和坐标分配也没有明显问题。后来打了几个日志点才发现根因在文本宽度测量布局时我按固定宽度估算节点尺寸但节点里的文字真正渲染时会因为字体渲染规则产生不同的实际宽度中文和英文混排时差异尤其明显。节点实际渲染出来比估算宽就让布局结果整体偏离。修复方式是两段式测量先用统一的字体样式在离屏画布上测量每个节点的实际文本宽度再基于测量结果做布局。测量和渲染必须使用同一套 CSS 规则否则测出来的宽度依然是错的。这个坑藏得很深因为问题表现更像是布局抖动实际根源却在文本渲染层。5.2 撤销历史被污染对象引用陷阱命令模式上线后我以为撤销重做稳了结果测试时发现撤销走完一步再重做两步整个图的状态就乱了。逐步排查发现问题不在命令栈逻辑而在命令对象里存的数据是图对象的引用。打个比方你移动节点 A的命令里保存了 from 为旧坐标但这个 from 坐标来自实时数据对象的一个属性。如果用户在移动之后又做了别的事改变了同一个对象的属性早先保存在命令里的 from 也会跟着变。撤销时读出来已经是被改过的值自然就乱套。修复方案是对命令里保存的数据做不可变快照移动命令保存坐标数值四个数字不保存对象引用创建连线命令保存整条边的深拷贝。基本规则是凡是进入命令栈的数据一律脱离原对象生命周期。5.3 大画布拖拽帧率暴跌全量重绘拉垮性能500 个节点以上拖拽卡顿的问题前面提过一次这里说下完整的排查链路。先用性能工具抓帧发现每次节点的坐标变化都触发了整张画布的 diff 和重绘。继续下钻看到渲染函数接收的是整棵图表数据树任何局部修改都让整棵树重新走一遍 diff。这个设计在 100 节点以下无感到 500 节点就拖垮了主线程。修法就是前面说的三点可见区域裁剪、数据脏标记、rAF 合帧。可见区域裁剪让渲染函数只处理视口范围内的节点脏标记把谁变了精确到节点与边的粒度rAF 合帧把一帧内的多次拖拽事件合并成一次真实渲染。这三个措施层层递进做完之后性能问题彻底解决拖拽时节点边的更新肉眼完全无感。还有一个附加的坑SVG 的文本在 zoom 缩小到很小时会出现模糊或者重叠。我给标签加了个按缩放级别切换显隐的策略缩放到某个阈值以下时隐藏标签只保留节点色块这样既保住性能也保住了可读性。6. 从能用到好用后续扩展方向与我个人的使用建议核心链路跑通之后diagram-design 已经有能力承载一张几百节点的可交互架构图。但能用和好用之间还有不小的距离我梳理了几个值得做的延伸方向。第一个是导出能力。工程上大家真正需要的是把图塞进文档或者交付给下游系统所以要支持导出干净的高清 SVG、PNG以及直接把内部数据导出成标准 JSON 结构。更进一步可以反推生成可渲染的代码骨架比如把一张系统状态机图直接转成状态机代码。这一步做对了图的角色就从静态文档插图升级成架构资产。第二个是多端协作。既然图的本质是数据那协作就变成数据同步问题而不是画布同步问题。多人同时编辑时可以基于 JSON diff 做变更集合并把交互操作转换成一段最小变更记录推送给协作方。如果两个用户改的是不同节点变更集可以直接合并如果改的是同一个节点再做冲突提示。第三个是插件机制。不同行业对图的定义差别很大有人画流程图有人画部署拓扑有人画用户旅程与其把每种图形逻辑都硬编码进内核不如把核心渲染和编辑能力做成插件 API。外部接入方只需要注册自己定义的节点解析器、渲染器与属性面板就能在一个引擎上长出自己的图形工具。我实际使用中最大的体会是一旦把图当成数据来管理整个工具的可维护性会上升一个台阶。改文字、加节点、调关系都只是一次数据变更生成、对比、合并、回溯都能复用一套工程化能力。这种思路不需要一出场就做成一个庞大的产品你完全可以从一个非常小的 DSL 和几百行渲染代码起步先解决自己团队最痛的图不可维护问题。最后分享一个小技巧用 diagram-design 画完一张图最舒服的流程永远是这样的——先在 DSL 里把节点和连线写清楚让布局引擎自动排一版然后再进画布做少量手动微调。从数据出发而不是从画布出发整张图的结构会清晰太多。你也可以先从自己的高频场景试起比如画一张系统架构图或一份时序交互图跑通之后自然会体会到这套思路的价值。