2026/9/15 7:37:03

diagram-design:用DSL与自动布局重塑团队图表设计流程

diagram-design:用DSL与自动布局重塑团队图表设计流程 聊到图表设计我之前折腾了一个内部项目名字就叫diagram-design。严格来说这不是一个现成工具而是一套面向团队内部的图表设计方案和代码库集合。起因很简单团队里做技术方案评审、系统架构梳理的时候大家用的画图工具五花八门有人用ProcessOn有人用draw.io有人直接在代码仓库里画ASCII图还有人用PPT硬画。出来的图风格完全不统一线条粗细不一样配色千奇百怪放在文档里又没法直接复用。更麻烦的是一旦架构调整原图找不到了或者画图的同事休假了改图就变成一场灾难。所以我就想能不能把“画图”这件事抽象成一套规范流程用结构化数据描述图的内容交给统一引擎去排布、渲染和导出链接进文档系统就能直接更新。听起来有点像一个简化版的Mermaid但又不完全是——因为我们的场景不仅是流程图还有网络拓扑图、微服务调用关系图、系统架构分层图甚至类图和数据模型ER图。这些图的画法各有讲究硬套一套通用模板很容易变成四不像。这篇我就把整个项目的思路拆开讲讲包括DSL怎么设计、渲染引擎怎么选、布局算法踩过什么坑、交互层面怎么补以及几个真实上线后才会遇到的典型问题。如果你也在搞类似的图表工具、想给自己团队做一套规范的图表示例体系这篇应该有不少参考价值。1. 项目整体设计与思路拆解1.1 为什么不自研引擎而是先定规范做这个项目之前我认真比较过三条路线一是直接用现成的开源画布工具比如Draw.io、Excalidraw、AntV X6二是封装Mermaid这类基于文本的图表DSL三就是从零开始自研一套“DSL定义 引擎渲染 布局算法 交互扩展”的完整方案。先说结论我在实际项目里选了第三条路但不是全盘自研而是自研核心DSL和布局引擎渲染层复用SVG。原因是前两条路各有硬伤。现成工具最大的问题不是功能不够而是“风格不统一”和“数据难回收”。Draw.io和Excalidraw这类产品能力很强但用户自由度太高A同事画出来的图和B同事画的完全是两种风格而且图形文件基本上是二进制或私有格式没法在代码评审里直接diff。Mermaid虽然解决了文本化和统一渲染的问题但表达力有限画复杂拓扑和嵌套架构时很吃力而且内置的布局算法对一些我不想要的场景调整起来很麻烦。所以最终确定的核心思路是自己定义一套JSON风格的DSL来描述图再由渲染引擎统一排布。图的所有信息都是纯数据结构可以存在Git仓库里参与Code Review也可以动态生成更可以在不同文档系统之间无缝流转。整条链路是“DSL描述 → 抽象语法树 → 布局计算 → SVG渲染 → 交互/导出”。这个分层方式在后面扩展新图型时帮了大忙每层只处理一件事情边界很清楚。1.2 核心需求画像与目标场景这个项目从第一天起就框定了四个必须覆盖的场景不是通用画板而是“专图专用”。第一个场景是微服务间调用关系图。这类图的特点是节点多、边多、还有熔断降级的标志除了把服务和接口画出来还得表达调用链路上依赖健康状态。第二个场景是系统部署架构图。需要分机房、分可用区、分服务实例每一层是嵌套的盒子结构节点内部还要塞IP、实例ID、镜像版本号这些元信息。第三个场景是云原生或大数据任务链的DAG调度图。这种图对分层排布要求比较高不能有环层级必须清晰上下游怎么看都不能乱。第四个场景是数据库ER图和数据流向图要求能把表实体、字段、主外键关系表达清楚。说实话这四个场景用通用画板都能画但每类图的布局逻辑差异很大拓扑图重力导向部署架构图重嵌套分组DAG图重分层排序ER图重实体间距均匀。如果我搞一个通用引擎硬适配所有场景代码复杂度会爆炸。所以我把底层做成通用但上层拆成了四种“图型方案”每种方案有自己的默认布局策略和节点样式。用户声明“type: topology”就命中拓扑自动布局声明“type: deploy”就命中分层嵌套布局。1.3 技术选型的取舍逻辑技术选型是项目早期最纠结的部分我花了两天时间反复比对。最终确定的组合是DSL层用JSON Schema做校验渲染层用原生SVG绘图交互层用Pixi.js在做高性能方案之前先没接实测中大部分场景SVG已经足够画布实现直接基于SVG DOM布局算法自研核心、参考dagre的启发式思路。选SVG而不是Canvas核心原因是节点数量级。团队实际的图节点一般在50到300之间SVG在这个量级下无论是渲染性能还是事件绑定都很稳。Canvas的优势在成千上万节点时才明显但那种场景在我们内部基建图中很少出现。另一个重要因素是SVG天然支持样式继承和CSS覆盖想改主题色、描边样式团队成员自己就能在浏览器里调不需要重新走一遍渲染逻辑。SVG还有一个杀手级优势是导出矢量图文档里贴的架构图放大缩小都清晰这一点Canvas做不到。选JSON而不是YAML是为了省错。JSON的格式校验生态太成熟了任何语言都有库可以直接解析而且前端拿到JSON以后可以直接做diff这是项目里最实用的一个特性。后文我会专门举例怎么用JSON Diff做架构变更的图形化评审。2. 核心细节解析与实操要点2.1 DSL设计怎么定义一张图的结构DSL是整个项目的地基它决定了上层所有能力的上限。我在第一版只设计了三个核心概念nodes、edges和group这是几乎所有图表的通用抽象。先看一个最简示例这是一张三个服务之间的调用链{ type: topology, nodes: [ { id: gateway, label: API 网关, kind: gateway }, { id: order-svc, label: 订单服务, kind: service }, { id: user-svc, label: 用户服务, kind: service } ], edges: [ { source: gateway, target: order-svc, label: HTTP/gRPC }, { source: order-svc, target: user-svc, label: RPC } ] }这个结构看起来很简单但实际项目里我给它加了不少约束。id必须全局唯一而且建议使用有业务含义的字符串而不是数字编号后面做引用和diff都方便。kind字段很关键它决定了节点渲染成什么风格比如网关节点是特殊的蓝色菱形服务节点是圆角矩形数据库节点是圆柱体。样式不用在节点里写死而是由主题系统根据kind自动映射这样才保证全团队画出来的图风格一致。edges除了source和target可以携带label表示边上的文字也可以带type表示线的样式比如虚线、加粗实线、双向箭头。这里我踩过一个坑Edge的方向语义非常重要在拓扑图里source指向target意味着“调用方向”还是“依赖方向”团队内部必须统一否则画出来的图和实际架构正好反过来误导性极强。我们最终的约定是箭头方向表示“依赖方向”订单服务依赖用户服务所以source是订单服务target是用户服务线上渲染时箭头从依赖方指向被依赖方。这个约定在代码评审里立了大功因为条目化的表达比人眼在图上找线清晰得多。2.2 自动布局算法为什么需要“不让人手动拖”布局引擎是整个项目里技术难度最高的一部分也是投入收益最不成比例的部分因为它太容易被低估。大多数人以为画图的核心是“画得好看”但实际操作中最大的工作量在“摆放位置”。为什么不能都靠拖拽原因有两个。第一文档里的图要支持刷新后重新生成如果每次微服务数量变了靠人手动拖一遍位置维护成本太高。第二自动布局能保证一定的秩序感——相同层级的节点排在同一列交叉线尽量少这比人眼拖出来的图更稳定。我实现的布局分为三类DAG分层布局、力导向布局、分组嵌套布局。DAG布局用于调度链路图。核心思路是先对节点做拓扑排序分配层级再在同一层级内做排序尽量减少边交叉。这一步参考了Sugiyama算法的思路但我没有全部照搬只取了分层和降交叉两个核心阶段。实际步骤如下将有向图按拓扑排序分解成若干层级每个节点得到一个layerIndex。对每个层级内的节点排序迭代降低相邻层级之间的边交叉数。在同层级内做“居中”处理让节点在垂直方向尽量对齐。力导向布局用于拓扑图。原理是模拟物理系统节点之间有弹簧力边相当于弹簧并把所有节点向图中心吸引反复迭代计算位置直到稳定。算法不复杂但要注意迭代次数和冷却系数否则图会一直在抖动。我在项目里设置初始温度0.1每轮衰减0.99最多迭代500次实测下来300个节点以内基本稳定在两三秒内收敛。分组嵌套布局用于部署架构图。核心是把节点按“分组”拆成树结构每个分组内部独立布局再整体拼装到父分组里。这里最大的坑是分组层级过深时子布局算完了父布局一改子图又得重算。后来我采用了“自底向上逐层布局”的方式先布局最内层再根据内层组的边界尺寸来决定外层组的大小避免循环依赖。2.3 渲染引擎的伪代码与核心实现渲染层我全部用原生SVG没有引入额外库。对我个人而言这个选择降低了调试成本。直接操作SVG DOM的好处是任何一个元素的最终样式和位置都能在浏览器开发工具里看到团队里的前端同事接手也快。我先定义了一个简单的坐标结构class RenderNode { constructor(id, label, x, y, width, height, kind) { this.id id; this.label label; this.x x; this.y y; this.width width; this.height height; this.kind kind; } }然后写渲染函数把节点映射成SVG元素function renderNode(node, theme) { const ns http://www.w3.org/2000/svg; const g document.createElementNS(ns, g); g.setAttribute(transform, translate(${node.x}, ${node.y})); const rect document.createElementNS(ns, rect); rect.setAttribute(width, node.width); rect.setAttribute(height, node.height); rect.setAttribute(rx, 6); rect.setAttribute(fill, theme[node.kind].fill); rect.setAttribute(stroke, theme[node.kind].stroke); g.appendChild(rect); const text document.createElementNS(ns, text); text.setAttribute(x, node.width / 2); text.setAttribute(y, node.height / 2 5); text.setAttribute(text-anchor, middle); text.textContent node.label; g.appendChild(text); return g; }这段代码的关键点在于节点坐标存储的是translate的偏移量而不是直接操作x、y属性。这样做的好处是后续要做拖拽或动画只需要改transform不会触发整个SVG的重排。边的渲染稍微复杂一些因为需要处理箭头、弯曲和标签。我的实现是默认用直线但如果有弯曲需求就在中间插入两个贝塞尔控制点生成path元素箭头用marker定义一次全局复用。渲染一条边的函数长这样function renderEdge(edge, points, markerId) { const ns http://www.w3.org/2000/svg; const path document.createElementNS(ns, path); const d M ${points[0].x} ${points[0].y} C ${points[1].x} ${points[1].y}, ${points[2].x} ${points[2].y}, ${points[3].x} ${points[3].y}; path.setAttribute(d, d); path.setAttribute(marker-end, url(#${markerId})); return path; }2.4 主题与样式的统一设计样式这块我单独总结出一条经验对“类型”定义样式而不是对“节点实例”定义样式。一开始我也允许在每个节点里写死颜色但后来发现只要有一两个节点是特殊颜色团队其他人就会觉得可以随意改颜色整体风格就失控了。所以最终版的主题系统完全基于kind字段映射。我用一个主题对象控制全局样式类似这样const theme { background: #ffffff, node: { gateway: { fill: #e8f0fe, stroke: #4285f4, shape: diamond }, service: { fill: #f3f6fc, stroke: #5f6368, shape: rect }, database: { fill: #fef9e7, stroke: #f9a825, shape: cylinder } }, edge: { solid: { stroke: #dadce0, strokeWidth: 1 }, thick: { stroke: #333333, strokeWidth: 2 } } };主题文件和数据文件彻底分离数据和样式解耦的好处是不用改数据就能一键切换整套视觉风格浅色、深色、打印友好、投影仪友好都不是问题。团队只需要约定好kind的命名规范新节点类型可以由各业务线自行扩展而不用改渲染引擎代码。3. 实操过程与核心环节实现3.1 手把手从零构建一个最小可用的图表引擎这一步我来完整演示一下如何在一个空目录里从零构建一个最小可用的图表引擎。假设我们已经有了上面定义的DSL结构接下来要做的是解析、布局、渲染三步。环境准备很简单只需要一个现代浏览器不需要框架。我先创建一个HTML页面引入JavaScript然后定义核心入口。核心入口的调用方式是这样的const spec { type: topology, nodes: [...], edges: [...] }; const diagram new DiagramEngine(spec); diagram.layout(); diagram.render(document.getElementById(container));这里的DiagramEngine是一个类内部持有三个子系统layout负责算位置render负责生成SVG DOMinteraction负责挂载事件。在布局阶段我先根据type选择不同的策略class DiagramEngine { constructor(spec) { this.spec spec; this.nodes spec.nodes; this.edges spec.edges; } layout() { if (this.spec.type topology) { this.layoutManager new ForceDirectedLayout(); } else if (this.spec.type dag) { this.layoutManager new DAGLayout(); } else if (this.spec.type deploy) { this.layoutManager new NestedLayout(); } this.layoutManager.compute(this.nodes, this.edges); } }然后把计算出来的坐标存回每个节点的x、y字段。这样渲染函数只要读取坐标即可不需要关心布局细节。最后的渲染环节需要注意一个性能细节当节点数量较多时创建一个新的SVG根元素比反复在旧SVG上追加更干净。我每次渲染都把容器内的旧元素清空然后重新构建整个SVG结构。虽然看起来有些“暴力”但实测在500个节点以内重建一次的耗时都在几十毫秒以内完全可接受。3.2 交互能力增强拖拽、缩放、连线编辑画图工具只有渲染显然不够团队用的时候肯定想微调布局。所以我在第二迭代加入了几个常用交互节点拖拽、画布缩放、新增连线。节点拖拽的实现思路很简单SVG元素上绑定pointerdown记录起始坐标pointermove时更新transform。但有一个坑拖拽后布局引擎计算出来的坐标已经变了节点的新位置必须同步回DSL数据里否则下次重渲染又会回到布局算法算出来的位置用户会感觉“白拖了”。所以我加了onNodeMoved回调每次拖拽结束都更新节点的x、y。画布缩放用的是SVG的viewBox机制。需求是“以鼠标为中心缩放”不是简单的修改viewBox宽高而是要先计算鼠标相对于画布的位置再调整缩放比例function zoomAt(factor, mouseX, mouseY) { const vb svg.viewBox.baseVal; const newWidth vb.width / factor; const newHeight vb.height / factor; // 保持鼠标下的点不变 const scaleX mouseX / svg.clientWidth; const scaleY mouseY / svg.clientHeight; vb.x mouseX * (newWidth / svg.clientWidth) - (vb.x - mouseX * (vb.width / svg.clientWidth)); ... }这个实现比较绕但核心逻辑就是等比缩放的同时平移视口让鼠标悬停的那个业务坐标点不动。3.3 导出能力SVG、PNG、Markdown图表画完总要沉淀到文档里所以导出能力必不可少。我最常做的是导出SVG和PNG两种。SVG导出很简单把渲染好的SVG DOM转成字符串加一个Blob下载即可。PNG导出则要用到canvas绘制SVG内容这一步最大的坑是跨域问题如果SVG里嵌入了外部字体或图片canvas会报安全异常导出内容变成空白。我的解决方式是在导出前把SVG里所有引用的图片都转为Base64内联并且将所有style标签里的规则直接内联到元素上。这样导出的SVG就是一个完全独立的文件不会再依赖任何外部资源。另外还有一项很有用的导出能力是Markdown代码块。团队写技术方案的时候经常直接把图表的DSL以JSON形式贴在Markdown里这样看代码的人能直接看懂结构看文档的人能看到渲染图。我封装了toMarkdown()方法把它输出成标准的代码块。这个功能一开始觉得很朴素但后来发现是团队使用频率最高的功能之一。3.4 真实案例用这个引擎重画老架构图这里分享一个我实际做过的迁移案例。团队里有一份微服务架构图原本是用某在线画图工具手绘的图里大概有40多个节点并且很多节点名称是中文缩写互相之间的连线也没标注协议。我拿到这个机会后把这幅图转成了diagram-design的DSL描述做了几步关键处理。先把所有节点整理成了规范的kind类型网关、服务、中间件、数据库。再把边全部补上方向语义有几个依赖关系在旧图里画反了迁移过程中直接被找出来了。接着我删掉了所有坐标信息完全交给布局引擎自动排布出来的图和旧图一比节点排列整齐很多几乎没有边交叉。最让团队惊喜的是我把DSL提交进Git仓库后可以在MR里直观看到每一次架构变更是增删了哪些节点、哪些依赖关系变了这个体验比看图找差异高效太多了。4. 常见问题与排查技巧实录4.1 布局错乱节点全叠在一起怎么办这个问题发生频率最高而且场景千奇百怪。最常见的原因是节点尺寸没设置或者尺寸设置为零。布局算法在每个节点初始化时都会读取width和height如果这两个字段缺失算法会认为节点没有占用空间于是所有节点都被排到同一个坐标上。排查方法在布局完成后打印每个节点的坐标和尺寸看看是否有x、y相同、width为0的情况。如果是尺寸缺失可以设置一个默认值或者在解析阶段给所有节点兜底赋值。另一个导致叠在一起的场景是和分组嵌套布局相关。当一个父分组没有设置最小宽度而子节点很多时父分组宽度会被计算成0导致所有子节点跑出可视区域。我的解决方法是给分组节点设置一个minWidth和minHeight默认值分别是父节点内边距的两倍加子节点布局后的最大宽高。4.2 文本溢出节点小但文字很长这是图表类工具永远避不开的问题。方案无非三种截断、换行、缩放。我选择的是“自动换行 自适应节点宽度”。自动换行的实现逻辑是根据字体大小和节点宽度计算出每行最多能放多少字然后按词或按字符切分。英文按空格切中文按单字切。切完之后再根据行数重新计算节点高度并动态调整相邻节点位置。这个功能放在布局算法内部因为节点尺寸会直接影响布局结果。如果开启自动换行后节点依然放不下我的兜底方案是“省略号悬浮提示”鼠标悬停时显示全名。这个方法简单有效不需要重排整个图。4.3 渲染性能瓶颈从800个节点降到60ms一开始我贸然追求“帧率”想着有没有可能在浏览器里流畅绘制上千节点的图。最终测试发现纯SVG渲染2000个节点时确实会有卡顿但把节点级别控制在500以下渲染耗时基本在80ms以内完全不影响使用。真正拖慢性能的是大量独立事件监听器。我一开始给每个节点单独绑定了click、mousemove、mouseleave事件节点一多页面立刻卡。后来改成事件委托只在SVG根元素上绑定一次事件通过event.target.closest([data-node-id])判断点击到了哪个节点。这个改动让渲染和交互性能都提升了一截。类似的经验也适用于边和箭头不需要每一条边都单独创建marker复用同一个箭头定义即可。4.4 协同编辑多人同时改同一张图的冲突策略迭代后期很多团队会提出多人协同编辑的需求。这个需求的设计难点不在于如何广播数据而在于冲突策略。我在这一块做了一个很轻量的方案编辑锁。当用户开始拖拽节点时前端向后端申请该图表的编辑锁其他用户拿到锁之前只能只读不能编辑。拖拽结束时图表的所有DSL数据以全量方式推送一遍给其他在线成员。这个方案简单直接避免了复杂的CRDT协同算法对内部工具来说足够可靠也不用担心数据冲突。4.5 常见问题速查表问题常见原因快速解决方案节点全部叠在一起节点尺寸缺失或为0解析阶段兜底设置默认宽高边方向不对source/target约定不一致统一定义“箭头依赖方向”并写入文档导出的PNG内容空白外部图片/字体跨域限制导出前将图片转为Base64内联样式内联缩放后文字模糊SVG导出被转成位图优先导出SVG或在导出PNG时设高倍率dpr节点拖不动事件被遮挡或监听目标错误检查是否加了pointer-events: none的遮罩节点布局结果不稳定每次刷新位置都变力导向初始随机性太强固定随机种子或先按层级分配初始位置4.6 一个独家避坑技巧永远先把“数据合法性”校验放在第一步这个项目的教训是很多布局异常和渲染异常根源都不在布局算法而在数据本身。所以我后来写了一个严格的数据校验函数放在引擎入口处非法数据直接抛错并提示具体字段。校验内容包括node的id是否重复、edges里的source和target是否都能在nodes中找到、节点kind是否被主题支持、字段类型是否匹配。这套校验在开发期就跑出来很多潜在问题帮团队省了大量查bug的时间。5. 个人经验小结这个项目做到第三周的时候我一度觉得自研布局算法是不是走偏了因为直接引入一个开源图算法库似乎更省事。但后来复盘发现自研带来的收益远大于成本一是对特殊需求的适配速度更快比如分组嵌套布局的细节控制二是对异常情况的可诊断性出问题时我可以直接打印中间计算过程而不是在黑盒库里反复猜。如果你也要做类似的东西我的建议是先别急于写代码花一天时间把DSL设计好。DSL就是你的数据契约契约稳定了后面引擎、布局、渲染都是围绕契约打工。DSL改来改去才是整个项目最大的成本源头。另外一个小技巧在开发调试时把布局结果实时可视化输出到画布再叠加一个“显示轮廓”的开关这样能快速看出布局算法是在哪个环节产生了错位。这个工具我至今还在用每加一个新图型都能省下不少调试时间。