上个月,团队要交付一期系统重构方案,后端同事在群里喊:“架构图帮我加两个服务,改三条链路,顺便导出高清版本。”这个请求听起来很常规,但如果你维护过一套频繁变动的架构图,就知道麻烦在哪:源文件不知道扔到哪个目录了,打开可视化画图工具拉了半天节点,线叠线、字压字,好不容易改完,隔两天又要调。我当时的应对方式是,把一直维护的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 的项目,节点一上千就卡成幻灯片。这里没有银弹,只有适用边界。
| 渲染方案 | 核心优势 | 核心短板 | 适用规模 |
|---|---|---|---|
| SVG | DOM 元素天然支持事件、样式、调试直观 | 节点多时 DOM 开销大,布局和重绘慢 | 节点数几百以内、交互要求高 |
| Canvas | 绘制性能高,适合频繁重绘 | 没有事件系统,需要自研命中检测和增量绘制 | 上千节点、无限画布、滚动缩放场景 |
| WebGL | GPU 加速,能扛几万节点 | 开发成本高,文本渲染麻烦,兼容性细节多 | 超大规模图或特殊可视化场景 |
常规图表工具,节点数在几百以内,用 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,这个看似不起眼的优化能省掉大量重复计算。