声明式图表设计工具 diagram-design:让架构图成为可维护的代码 上个月团队要交付一期系统重构方案后端同事在群里喊“架构图帮我加两个服务改三条链路顺便导出高清版本。”这个请求听起来很常规但如果你维护过一套频繁变动的架构图就知道麻烦在哪源文件不知道扔到哪个目录了打开可视化画图工具拉了半天节点线叠线、字压字好不容易改完隔两天又要调。我当时的应对方式是把一直维护的diagram-design这个图表设计工具项目重新捡起来。diagram-design做的事很简单用声明式的文本描述节点、连线和分组工具自动完成布局和渲染最终生成可交互、可导出、可版本化的图表。它解决的核心痛点是手工绘图的三件事——不可 diff、难以复用、每次修改都推倒重来。对长期维护技术文档、架构图、流程图、时序图的团队和个人来说这套思路比打开可视化编辑器一张张拖拽要省心得多。这篇文章我会把整个项目的关键设计完整过一遍为什么不用现成的画布工具、数据流怎么拆、布局算法选哪种、渲染交互有哪些坑、导出时遇到过什么问题。无论你是想自己写一个图表设计工具还是只想在公司内部搭一套“图即代码”的文档方案里面都有可以直接抄的部分。1. 为什么我决定自己设计一个图表工具手工绘图的隐性成本1.1 画一张架构图的时间账算完就不想画了先说一笔时间账。一张中等规模的系统架构图大概 15 个节点、25 条边。用手工画布工具来画我把节点拖到画布、对齐、改样式、拉连接线、再调整文字位置平均要花三十分钟到四十分钟。这里面真正耗时的地方根本不是“画”而是“摆”让节点排整齐、让连接线不绕路、让文字不重叠。但真正的问题从来不是第一次画图而是后续的“维护”。一个项目迭代三个版本每个版本都要改图加一个服务、改两条链路、删一个组件。每次改图少则十分钟多则二十分钟。迭代三次之后维护成本已经超过第一次画图的成本。如果再遇到跨人协作别人画完的图你根本不敢动因为不知道某个节点为什么摆在这个位置牵一发而动全身。更麻烦的是手工画出来的图没有 diff 可言。评审的时候只能打开两张截图左右对比靠人眼找不同。虽然有些文件格式本质上是结构化的但日常使用中没有人会拿文本 diff 去审查一张视觉产物。做过几次文档评审之后我彻底意识到只要图还是“画”出来的它就永远游离在版本控制和自动化流程之外。当时心里只有一个念头——能不能让图变成代码让每一次改动都可追踪、可生成、可评审。1.2 两类现有方案的能力缺口摆在面前的有两条路继续用可视化画布或者转向图即代码文本描述生成图表。我把两者做了一次直接对比能力维度可视化画布工具图即代码方案交互直觉强拖拽即可弱需要学一门小型 DSL样式调整随手改所见即所得靠 token 和覆盖规则有学习成本版本 diff基本无效只能对比截图天然支持文本 diff 清晰可见批量生成很难脚本难以操作私有格式很容易文本可以拼接和循环布局稳定性每次手摆无法复现自动布局输入不变输出不变自动化集成弱难嵌入 CI 流程强可以生成文档和持续更新从对比来看图即代码的收益非常明显可以进 Git、可以自动生成、可以复用结构。缺点集中在两个地方一是要定义一个够用的领域描述语言二是自动布局的质量必须够稳定。前者是设计和解析问题后者是算法问题。我当时的目标很简单不追求替代所有画图场景只做一个稳定可靠的“文本进、图表出”的渲染管线让它能嵌入到常规的文档工作流里。于是diagram-design就围绕这两个缺点开始设计。1.3 边界感diagram-design 不做什么项目启动前我先列了一个“不做清单”。这条清单后来被证明比需求清单更重要。第一不做像素级设计器。像专业设计软件那样逐像素调节点阴影、渐变、对齐线绝对不是这个项目的目标。图表是文档的一部分不是海报。第二不做复杂动画。节点进出场、边生长动画这类效果虽然炫酷但会显著拖慢渲染和交互逻辑投入产出比太低。第三不做对象级富文本编辑。节点里的文字就该是普通文本支持样式的富文本编辑器会让数据模型变得很重。把边界切掉之后核心要做的事只剩三个DSL 解析、自动布局、渲染交互。这三件事足够撑起一个长期有价值的工具也足够保证每个环节做到“够用且稳定”。真正做工具的人都明白一个道理看起来全面但每一块都没打磨完的产品生命周期通常不会太长。diagram-design给自己的定位就是一个“体面的渲染引擎加文本协议”而不是一个万能画板。2. 核心架构拆解一条从文本到图形的四段流水线2.1 整体数据流每一段都能独立替换diagram-design的内部结构是一条单向流水线DSL 文本 → 解析器 → 中间表示IR → 布局引擎 → 渲染器。每一段只做一件事而且和相邻段的接口用纯数据对象来传递。解析器的职责是把 DSL 文本变成结构化的抽象语法数据。它只关心“这段话表达了什么结构”完全不关心节点画多大、边用什么颜色。解析完成后AST 会被转换成中间表示 IR。IR 是纯数据模型里面只有节点node、边edge、端口port、分组group这几种实体以及它们的属性和关系。这个 IR 是全局核心因为布局引擎只消费 IR渲染器也只认识 IR。这样拆分的好处我是在后面几次迭代里才真正体会到的。第一次修改 DSL 语法只改了解析器布局和渲染一点没动后来想把渲染从 SVG 换成 Canvas也只动了渲染器这一层。如果当初把语法解析、布局、渲染全部耦合在一个类里面每一次变更都会变成连环车祸。做一个工具型项目最值得投入的就是把数据流拆干净。2.2 DSL 的设计最小语法长什么样一门新语言最忌讳一上来就搞复杂语法。diagram-design的 DSL 第一版只支持三样东西分组、节点、箭头连接。下面是一个简化示例group 用户端 { node 前端页面 node 移动端 } group 服务端 { node 网关 node 订单服务 node 支付服务 } 前端页面 - 网关 移动端 - 网关 网关 - 订单服务 订单服务 - 支付服务这个 DSL 的语义很直白group表示一个分组容器大括号里的 node 都属于这个组-表示箭头连接左侧是起点节点右侧是终点节点。解析时按行读取用缩进和大括号判断层级关系箭头左右两侧再解析成边的端点。初期实现不需要上完整的编译器工具链。用一行一行遍历文本的方式可以覆盖 80% 的需求。等语法复杂到需要表达式嵌套、条件判断时再替换成正式的词法解析和语法分析也不迟。后续我在这个基础上扩展了三个能力端口语法node A:port1 - node B:port2、样式覆盖node 网关 { color: red }这类局部覆盖、模板片段用变量拼接一段重复的节点组。每加一个能力解析器就要多处理一种结构但因为数据流已经分层改动仍然控制在这一层。2.3 布局引擎为什么选分层布局而不是力导向布局是整个图表生成中最容易出效果也最容易翻车的一步。架构图天然有方向感用户端在上、服务端在下、数据库在底部。分层布局正好匹配这种“从上到下”的表达习惯所以我选了分层布局作为核心算法而不是更通用但更难控制的力导向布局。分层布局的流程大体分四步。第一步是去环如果图里有循环依赖比如 A 指向 B、B 又指回 A分层算法会临时把某些边标记为反向边确保图在逻辑上是有向无环的渲染时再用虚线或曲线来还原真实关系。第二步是分层给每个节点分配一个层级编号最常用的策略是最长路径法入口节点在第一层下游节点根据最长链路逐层向后排。第三步是层内排序也就是调整同一层节点的左右顺序目标是减少连线交叉这一步通常用重心启发式来完成每个节点的理想位置取决于它连接的上层节点的平均位置反复迭代几轮后交叉数量会显著下降。第四步是坐标分配根据层级间距和节点宽度算出每个节点的 x 和 y。还有一个关键细节是跨层长边处理。分层是严格的逐层推进如果一条边直接从第 1 层连到第 5 层它跨越了三个层级。为了让这条边不要弯弯绕绕地横穿整张图布局算法会把这条长边拆成若干段在跨过的每一层里插入一个“虚拟节点”。虚拟节点不渲染内容只负责把边拉成一条竖直方向的链路。这个细节最初被我忽略了结果所有跨层长边都贴着一个很奇怪的路径绕来绕去交叉数量爆炸。补上虚拟节点之后图面立刻干净了一个档次。至于力导向布局它的思路是把节点当作带电粒子用斥力防止重叠用连接关系模拟弹簧引力通过物理模拟迭代出稳定位置。听上去很通用但用在架构图上效果并不好因为没有方向感层次和分组会被拉扯得不成样子。社交网络图、无向的关联图更适合力导向。选布局算法的核心原则是和你内容的语义结构匹配而不是选一个“听起来厉害”的通用算法。3. 渲染与交互的实战选择画布方案不是越炫越好3.1 SVG、Canvas、WebGL给图表场景划一条使用边界渲染方案的选择会直接影响后续所有交互功能的开发量。我见过不少项目一上来就选 Canvas理由是大图性能好结果做了半年发现命中检测、文字测量、事件分发全要自己造轮子。我也见过坚持只用 SVG 的项目节点一上千就卡成幻灯片。这里没有银弹只有适用边界。渲染方案核心优势核心短板适用规模SVGDOM 元素天然支持事件、样式、调试直观节点多时 DOM 开销大布局和重绘慢节点数几百以内、交互要求高Canvas绘制性能高适合频繁重绘没有事件系统需要自研命中检测和增量绘制上千节点、无限画布、滚动缩放场景WebGLGPU 加速能扛几万节点开发成本高文本渲染麻烦兼容性细节多超大规模图或特殊可视化场景常规图表工具节点数在几百以内用 SVG 是最省力的选择。但diagram-design要支持无限画布、滚动缩放和潜在的上千节点纯 SVG 会吃力。最终我用了混合方案静态底图和大部分元素用 Canvas 绘制交互热点用一薄层 SVG 叠加。热点层只包含当前需要点击、拖拽、悬浮的元素数量级远低于全量节点DOM 压力完全可控。这样既拿到了 Canvas 的性能又不至于手动实现所有交互。第一版就上 WebGL 是我一直劝人避开的路线除非业务一开始就明确要渲染两万个节点以上否则前期的开发成本大概率拖垮项目。3.2 无限画布坐标变换与缩放锚点无限画布听起来很玄核心就一条坐标变换公式屏幕坐标 世界坐标 × 缩放比例 画布偏移量。每次鼠标滚轮缩放时把这条公式反过来解就能算出鼠标位置对应的世界坐标。缩放时最容易出现的问题是“缩放中心跑偏”。你希望鼠标指着的那个节点在缩放后还停在鼠标下面而不是跳到别的地方。做法是缩放前先算出鼠标位置对应的世界坐标然后把新的平移量设为“鼠标位置减去新缩放比例乘以同一个世界坐标”。套一行公式就能保持锚点不漂移。这个细节我第一次实现时忽略了结果每次滚轮缩放图都像被甩出去一样乱跳体验非常糟糕。有了坐标变换就还差一个视口裁剪。无限画布意味着图可以很大但屏幕只有一个视口只需要把视口矩形内的节点和边渲染出来。滚动和缩放时提前计算可见范围把视口外的元素直接跳过。这个步骰不做节点一多就会陷入反复绘制全量元素的泥潭。视口裁剪是无限画布方案里最便宜但最有效的性能优化。3.3 命中检测、拖拽与框选自己动手的交互细节Canvas 渲染模式下没有 DOM 节点可用所有“点到哪个元素”的问题都要自己解决。命中检测我采用了两级策略先用包围盒快速筛选把离鼠标位置较远的节点全部排除再对候选节点做精确的图形检测。节点数量上来之后包围盒遍历本身也会变慢这时候可以用网格桶或者四叉树建立空间索引把查找范围限制在鼠标附近的一小块区域。节点的大小不是写死的而是由文本内容撑起来的。每个节点的宽度等于文本测量宽度加上左右内边距高度等于行数和行高加上上下内边距。文本测量用统一的上下文测量结果放进缓存 Map 里同一个字符串和同一套字体样式只测一次。这个测量函数是布局层和渲染层共用的布局阶段用它估算节点尺寸渲染阶段也用同一套结果避免两处计算出不同的宽度。拖拽的体验细节有两个一个是坐标系换算鼠标事件拿到的是屏幕坐标要先转成世界坐标再更新节点位置另一个是吸附当节点中心接近网格线或者接近其他节点的对齐线时强行吸附到那个位置。吸附阈值设在 4 到 6 个像素比较合适太小没感觉太大会让人觉得节点被粘住了。框选则需要记录鼠标按下的世界坐标和当前的坐标形成一个矩形再和空间索引里的节点包围盒做相交判断批量选中。4. 从“能用”到“好用”的几个关键优化4.1 布局不卡主线程Worker 加防抖图表项目的体验瓶颈很多时候不在渲染而在布局计算。虽然分层布局的复杂度不算高但图一旦到几百个节点、上千条边解析加布局也能跑出几十毫秒甚至更长的耗时。如果把这段耗时放在主线程里用户一边输入 DSL 一边看着界面卡顿体验的直接落差会让人放弃这个工具。我的做法是两段式处理。第一段是输入防抖用户停止输入 300 毫秒后才触发解析和布局避免每敲一个字符就重算整张图。第二段是把解析和布局整个丢进 Web Worker。Worker 只接收纯数据对象内部完成 DSL 解析、IR 构建和坐标计算再把结果通过消息传回主线程。主线程拿到带坐标的 IR 后只做渲染。实测下来即使是几百节点的图布局在 Worker 里跑一两百毫秒主线程的渲染依然流畅。这一条优化投入小回报大值得做任何图表工具的人优先考虑。还有个细节在设计 IR 数据结构时就要保证它是可序列化的不能夹带函数和循环引用否则用postMessage传数据的时候会被结构化克隆算法掐断。4.2 样式系统与主题用 token 而不是魔法数字样式管理是从手工画图转向程序化生成时最容易被忽略的一环。最偷懒的做法是在每个节点的 IR 里写死颜色值但这样做的代价很快就会显现想整体换一个暗色主题要改几百个节点想调整某个类型的统一颜色要像大海捞针一样去改数据。样式系统在diagram-design里被抽象成一组主题 token。一个主题 token 对象包含背景色、节点填充色、边框色、文字色、字体、字号、圆角、内边距、连线宽度这些基础值。亮色主题和暗色主题分别是两组 token。节点类型会映射到默认样式用户端组件用什么色、服务端组件用什么色、数据库用什么色都集中在类型映射表里维护。单个节点如果要特殊化可以用 DSL 里的样式覆盖语法单独标记。图标也是样式系统的一部分。所有类型图标统一用矢量符号管理颜色可以继承当前主题导出到任意分辨率都不会变成糊边马赛克。这个做法避免了维护一堆大小尺寸的位图图标也保证了导出和屏幕显示的一致性。4.3 导出与回读PNG、SVG、JSON 三件套一个图表工具如果只能看不能导出基本没有生产力。diagram-design的导出功能从一开始就是必备项。导出 SVG 时要特别注意字体问题如果 SVG 里用了自定义字体而打开 SVG 的系统没有这个字体文字就会错位、溢出甚至变成豆腐块。稳妥的做法是导出时把文字转成路径或者在 SVG 里声明一套跨平台的安全字体。转成路径之后文件会变大但观感绝对可靠。导出 PNG 的常规做法是把 SVG 数据放进图片元素再绘制到离屏 Canvas最后用toBlob或toDataURL生成位图。要保证高清输出绘制时按devicePixelRatio或者直接按 2 倍、3 倍缩放。这里有一个隐藏的坑浏览器的 Canvas 有最大尺寸限制超大的图不能一次性导出需要分段绘制再拼接。导出 JSON 的作用是保真回读。我要求序列化的 JSON 必须包含节点坐标、分组层级、样式覆盖和边的路径回读时直接恢复到上次编辑的状态不做重新布局。这样手动微调过的位置不会被自动布局冲掉也方便工具之间交换数据。回读的格式就是 IR 的序列化版本平时调试和单元测试也复用了同一套数据。5. 真实排障记录布局和渲染踩过的坑5.1 节点重叠同一份宽度两个地方各算各的有一次我生成一张带分组的架构图局部节点叠在一起文字挤成一团。一开始我怀疑是布局算法参数的问题调大间距也无济于事。后来从布局引擎输出的坐标开始排查发现布局层给节点分配的坐标是正常的间距也存在但渲染层画出来却重叠了。继续往下查根因浮出水面布局阶段估算节点宽度时用了一个简化的测量函数而渲染阶段用文本测量函数算了另一个宽度。两个函数的取值不一样布局以为节点只有 120 像素宽渲染实际画出来却是 150 像素。间距参数被这个宽度差吃掉了节点自然就叠在一起。修复方案是把测量函数统一成一个模块布局和渲染都调用同一个方法并且把测量结果缓存起来。同时给节点设置了最小宽度避免极端窄文本时节点太扁。这个坑的本质是“同一份几何语义被两份代码分别实现”。在布局和渲染共享的数据结构里节点宽高应该只有一个来源而不是各算各的。凡是要维护布局相关参数的地方都必须和渲染层保持一致。5.2 连线交叉严重跨层长边没有虚拟节点第二个典型问题是连线交叉过度。现象很直观一条从第 1 层节点连到第 5 层节点的边绕了整整半张图路径弯弯扭扭和其他边搅成一团。第一反应是层内排序没做好于是把重心排序的迭代次数从 3 轮加到 10 轮结果改善并不明显。后来查资料复盘才发现问题不只是排序而是跨层长边缺少虚拟节点。分层布局算法严格要求边只连接相邻两层第 1 层连到第 5 层的边如果不拆就会被当作一个普通的长边对象处理。它在坐标分配阶段没有中间锚点路径只能按直线连接结果就是直接穿过其他层级视觉上表现为“绕路”和“交叉”。修复方案分两步。第一步是在预处理阶段把跨层长边拆成链每跨一层插入一个虚拟节点气泡式的路径变成了贴着层级的竖直通道。第二步才是实现真正的重心排序调整同一层内的节点顺序进一步减少边交叉。修复之后我顺手加了一个统计指标总边交叉数、总边长、最大拐点数用数据来评估布局效果。判断布局好不好看不能靠感觉至少要用交叉数这种客观指标说话。5.3 滚动卡顿一帧里渲染了不该渲染的东西大图试渲染时明显掉帧滚动画布像放慢动作。打开性能面板看主线程渲染耗时飙到几百毫秒而且每次滚动都触发全量重绘。检查了一下可见范围和全量节点发现视口外的节点占了六成以上也就是说每次平移都白白画了大量看不见的元素。修复动作有两个。第一是真正落地视口裁剪滚动和缩放时只绘制与视口矩形相交的节点和边。第二是分层渲染把静态底图、交互热点、临时标注分成三层静态层的内容在平移时通过整体矩阵变换更新不逐元素重绘只有变化区域才做局部擦除和重绘。这两个动作落地后帧率稳定回到 60fps。顺带分享一个经验Canvas 每次绘制节点时如果都调用一次setTransform开销会非常惊人。更聪明的做法是统一设置好矩阵然后逐个绘制节点的时候只调用fillRect、stroke这类基础绘制方法把状态切换控制在最小范围。5.4 导出 PNG 文字错位字体加载时序导出 PNG 时遇到过文字偏移和文本溢出的情况。现象看起来像坐标算错了其实和坐标无关。问题出在字体加载时序自定义字体还没加载完成时文本测量用的回退字体宽度和最终渲染字体的宽度不一致导致布局阶段测出的宽度偏小导出后文字溢出节点边框。修复办法是在导出前等待字体加载完成调用字体加载 API确保所需字体可用后再进行文本测量和绘制。如果项目里使用了自定义展示字体还需要在导出流程里明确预加载这套字体文件。经过这次问题我加深了一个印象在 Web 图表里字体加载时序是“隐形杀手”它不报错只让结果看起来莫名其妙。写在最后项目做到这个阶段我最大的体会是图表设计工具的价值不在单个功能多强而在“数据模型够稳定、图可复用”。把 DSL、IR、布局、渲染四层拆干净之后后面加交互、换渲染、接插件都是自然生长出来的事。如果你也要做类似的事我建议先守住边界把解析器和布局算法做到位渲染先做朴素一点让图先稳定生成再考虑样式和动效。最后分享两个小技巧节点图标统一用矢量符号管理导出任何分辨率都不会出现糊边文本测量结果一定要做成缓存 Map这个看似不起眼的优化能省掉大量重复计算。