写开发文档、做方案汇报、梳理系统架构时,最折磨人的往往不是内容本身,而是“怎么把关系画清楚”。我去年试着做了一个轻量级的图表设计工具,核心思路是把“画图”这件事变成“写一段结构化文本”,再用程序自动生成对应的 SVG 图表。这个项目本身取名叫diagram-design,本质上是一个面向开发者和文档写作者的文本图表引擎:你用类似代码的语法描述节点和连线,工具负责排版、渲染、导出,最终得到一张可以直接放进文档或 PPT 的架构图、流程图、时序图。这篇文章就把我从需求拆解、架构设计、语法定义到踩坑修复的完整过程整理出来,给同样想自己做一套“图即代码”方案的读者做个参考。
1. 为什么要把“画图”变成“写代码”:diagram-design 的项目定位
1.1 图是需求,代码是手段
先说一个很普遍的痛点:很多团队的技术文档里,架构图是最难维护的部分。用拖拽式绘图工具画一张系统调用链,当初画的时候花了一个小时,等到系统迭代三个版本之后,这张图就彻底失真了。因为没人愿意再花一个小时去手工挪框、拉线、调对齐。我更希望的工作方式是这样的:架构图的核心信息存在于一段可读的文本里,谁都能通过修改几个节点名称、增删几行依赖关系,再重新渲染一次,就得到更新后的图。diagram-design要解决的,正是“让图片状态跟上真实系统状态”的问题。
这个项目并不是要做成一个所见即所得的在线白板,那类工具已经很多了,做得也很好。它更像是一个“图表的源代码编译器”:输入是一段可读性很强的 DSL 文本,输出是一张排版合格的 SVG 图。你可以把这段文本提交到代码仓库里做版本管理,也可以在持续集成流水线里自动生成文档配图。核心价值在于三点:第一,文本天生适合 Diff,图变成文本之后,改动范围一目了然;第二,文本可以复用,模板化、参数化都容易得多;第三,整个渲染流程完全可编程,不依赖鼠标手动操作。
1.2 文本 DSL 的三个核心优势
对比拖拽式绘图工具,文本 DSL 最直观的优势是版本管理。手工画的图在版本控制系统里基本就是一张二进制图片,改了什么全靠肉眼比对,工程规范无从谈起。而 DSL 描述文件本质是纯文本,每次提交都能看到精确的变化:新增了哪个节点、调整了哪条连线、修改了什么标签。对团队协作来说,这意味着“图”也纳入了代码评审的流程。
第二个优势是自动化能力。当图的数据源是文本时,它可以被动态生成。比如从数据库表结构逆向生成实体关系图,从接口定义生成服务依赖图,从部署清单生成拓扑图。这些场景下,每一次构建都可能产生完全不同的图,人力绘图根本跟不上。文本 DSL 天然适合程序来生成程序,渲染变成一条命令的事情。
第三个优势是可移植性和稳定性。文本文件不依赖某个特定软件,你用记事本打开也能阅读和修改,即使多年后原项目停更,只要解析规则还在,数据就永远不会丢失。图形软件的私有格式则随时可能因为版本升级出现兼容问题。从这个角度说,用文本承载图表信息,本质上是给图表做了一次“数字化归档”。
1.3 目标用户与适用场景
这个项目最适合三类人群:需要频繁产出技术方案的开发者、维护大型技术文档的文档工程师、以及希望把“系统现状”和“文档描述”绑定在一起的架构师。适用场景包括系统架构图、业务流程图、服务调用链图、简单的状态转移图。它不适合做高精度的商业演示配图,也不适合用来画美术意义上的“信息图”,因为自动布局能保证的是“清晰、规范、不出错”,而不是“视觉惊艳”。
我在设计定位时明确做了一版能力边界:第一版只做有向图,也就是节点之间有明确方向的依赖关系;支持分层布局和简单分组;支持的线型包括实线箭头、虚线箭头、双向箭头;暂不支持自由绘制、交互编辑、动画效果。这个边界非常重要,后面所有解析、布局、渲染的工作量都是基于这个边界来估算的。
2. 动手之前先想清楚:架构与方案选型
2.1 整体架构:一条单向数据流管道
diagram-design的整体架构是一条单向数据流:DSL 文本进入解析器,经过词法分析和语法分析,产出中间模型;中间模型进入布局引擎,计算每个节点的坐标位置和每条边的路径;带坐标的模型进入渲染器,生成 SVG 字符串;最后按需导出为 SVG 文件或转换为 PNG 图片。整个过程从输入到输出没有回头路,每一步都只依赖前一步的结果,调试时也容易定位:图错了,先看是解析错了、布局错了,还是渲染错了。
这样的管道式架构,最大好处是每一层都能独立测试。我可以单独跑解析器,验证 AST 是否正确;单独跑布局引擎,输入固定的坐标,看输出是否合理;单独跑渲染器,不用关心数据来源。如果你准备自己实现一个类似工具,建议也按这个思路分层,不要在解析阶段顺手做排版,否则后面修改排版规则会非常痛苦。
2.2 为什么选 SVG 而不是 Canvas
渲染方案我几乎没有犹豫,直接选了 SVG。当时对比过 Canvas 和 SVG 两条路线:Canvas 偏向游戏渲染那一类高性能场景,适合上千节点且需要帧动画的大图;但文档配图不需要高频重绘,倒是需要每一步都能被浏览器选中、被屏幕阅读器读取、被 CSS 定制样式。SVG 的 DOM 节点天然支持事件绑定,后期想做点击高亮、折叠展开这类交互,只要给元素加监听器就行,完全不用另外实现“点击命中检测”。
SVG 还有一个隐藏优点:文本是真实文本,不是像素。导出后的图片放到网页里,文字可以选中、可以复制、可以被搜索引擎索引;打印时依然清晰,缩放不失真。而 Canvas 一旦渲染成位图,文字就固化了。对文档场景来说,SVG 的优势几乎是碾压性的。唯一的顾虑是大图性能,我实测下来,SVG 承载 500 个节点以内的图完全流畅,超过后需要做分层懒渲染,但这已经超出了第一版的边界。
2.3 技术选型中的几个具体决策
DSL 语法没有选择兼容任何一种现有的成熟文本图表语言,因为我想控制解析器的复杂度。借鉴了这类语言的常用套路,但只保留最核心的骨架:用node声明节点,用->声明连接,用冒号附带标签。语法宁可少而精,也不要多而杂,后续需要再加。解析器也特意写成手写状态机,没有引入语法分析器生成框架。小语法的解析用手写实现反而更可控,而且不依赖额外运行时。
节点尺寸测量这个细节,我在调研时发现很多人会忽略。布局引擎必须知道“这个节点框要多大才能放下当前文本”,因此测量文本宽度是布局的前置步骤。浏览器环境里我用隐藏的canvas上下文调用measureText,服务端渲染时则用近似等宽公式估算。中英文混合场景下,中文宽度大约是英文字符的 1.6 倍,估算时我会给足余量,宁可框大一点,也不要文字溢出。
3. 核心细节:DSL 语法设计与解析器实现
3.1 语法设计原则:少关键词、多约定
diagram-design的语法在设计时定了三条原则:关键词越少越好、结构尽量扁平、错误要能被友好提示。完整语法其实只有两条核心语句。声明节点用node 名称;声明关系用名称A -> 名称B: 标签。图表的全局标题用大括号块,比如:
architecture "订单创建链路" { node 用户 node 网关服务 node 订单服务 node 消息队列 node 数据库 用户 -> 网关服务: 发起下单 网关服务 -> 订单服务: 鉴权通过 订单服务 -> 消息队列: 写入事件 订单服务 -> 数据库: 落表 消息队列 --> 订单服务: 异步回调 }这里->表示实线箭头,-->表示虚线箭头,用来区分同步调用和异步消息。节点可以不预先声明,直接在关系里出现,解析器会自动补齐节点模型。为了让图更紧凑,我也约定了一种“简写模式”:如果一行里只写了用户 -> 网关服务,没有冒号标签,就表示一条无标签边。这样看代码的人可以快速扫一遍关系,不用被标签干扰。声明node的作用主要是两点:一是给节点设置可读的显示名,二是调整节点在布局里的先后顺序。
3.2 分词与解析:从文本到中间模型
解析器实现是非常典型的按行扫描逻辑。先按换行符切分文本,去掉空行和注释行,然后逐行做模式匹配。每一行首先判断是不是architecture开头,如果是,就进入块模式,直到遇见闭合的大括号;如果在块外,就只允许node声明和关系声明。这里有个容易踩的坑:标签内容里可能包含冒号,比如网关服务 -> 订单服务: 返回结果: success。如果直接按冒号切分,就会把标签误切成两段。我的处理方式是只在第一个冒号切分,后面部分整体作为标签文本:
const colonIndex = line.indexOf(':'); const label = colonIndex > -1 ? line.slice(colonIndex + 1).trim() : '';解析完成后,会得到一个中间模型,包含节点集合和边集合。节点对象至少要有id、label、layer三个字段,layer在布局阶段赋值;边对象至少要有from、to、label、style四个字段,style区分实线和虚线。中间模型是把“用户意图”和“具体排版”隔离开的关键,后续加新功能只需扩展模型字段,不用改动解析主流程。
解析器还要做一项很重要的工作:错误定位。用户输入不合法时,报错不能是冷冰冰的“syntax error”,而要指出具体是第几行、出了什么问题。我维护一个行号计数器,每当一行模式匹配失败,就记录第 {n} 行的格式无法识别。如果是node关键字后面没有名称,就提示节点名称不能为空。这些错误消息虽然简单,却能极大降低使用门槛。
3.3 布局引擎:分层、排序与坐标计算
所有文本图表工具的核心难点都在布局。我的布局算法走的是一条经典路线:先对节点分层,再在层内排序,最后计算坐标。
第一步,确定每个节点的层号。层号反映的是“它在依赖链里处于第几层”。入口节点层号定为 0,下游节点的层号等于所有上游节点层号的最大值加 1。如果节点同时有多个上游,必须取最大值,否则连线会往回折,形成难看的回边。这一步我用的是深度优先遍历加记忆化搜索,避免重复计算:
function computeLayer(nodeId, visited) { if (visited.has(nodeId)) return visited.get(nodeId); const deps = model.edges.filter(e => e.from === nodeId); const depLayers = deps.map(d => computeLayer(d.to, visited) + 1); const layer = depLayers.length ? Math.max(...depLayers) : 0; visited.set(nodeId, layer); return layer; }第二步,层内排序。只按出现顺序排在一个复杂图里往往会出现大量交叉线。我采用的是一种启发式算法,思路简单但效果不错。第一轮从上到下遍历每一层,取当前节点的所有上游节点的平均横向位置,作为排序依据;第二轮再从下到上反向遍历一次,取下游节点的平均位置。重复两轮后,交叉数量能减少一大半。它不保证全局最优,但作为文本渲染工具已经足够。
第三步,计算坐标。层间间距固定为 160,层内节点间距固定为 120,层内节点按照排序结果从左到右依次落位。每个节点坐标记录其中心点的x和y,渲染时再根据节点实际尺寸换算框的左上角坐标。这里有一个细节:如果一层里只有一个节点,算法会把它的位置往该层横向范围的中心吸一下,这样整张图会更平衡。
边路径用的是三次贝塞尔曲线,控制点的选取规则是:起点的水平偏移量取两端点水平距离的一半。这个偏移方向默认朝右,如果目标节点位于源节点左侧,则自动调整偏移方向,确保曲线不会绕一个大弯。虚线边和实线边共用同一套路径计算逻辑,只是渲染时设置不同的stroke-dasharray。
3.4 节点尺寸与文本测量:细节决定成图质量
节点尺寸不能拍脑袋固定,因为文字长短差异巨大。“数据库”三个字和“对外提供统一订单查询接口”十四个字,显然不应该塞进同一个大小的框里。我的方案是:渲染前先按当前主题的字体样式测量文本宽度,然后用“文本宽度 + 水平内边距 24pt”确定节点宽度,节点高度固定为 44pt。如果节点设置了副标签,高度按行数累加。
前端测量使用隐藏的 canvas 上下文,因为measureText同时支持中文和英文,返回的是精确的像素宽度。服务端环境就没有这个便利,我用一个宽度近似公式:宽度 ≈ 中文字符数 × 16 + 英文字符数 × 9 + 内边距,其中 16 和 9 是按主流字号估算出的平均字符宽度。这个近似值虽然不算特别精确,但配合富余内边距,几乎不会出现文字溢出。
实测量的坑主要在字体加载上。如果目标字体的字体文件还没有加载完成,measureText会先按默认字体测量,得到的结果偏窄,渲染时就可能溢出。我用了一个简单粗暴的解决方案:在入口处通过document.fonts.load('14px "PingFang SC"')预制字体,等字体加载完再开始布局流程。另外,如果用户手动把节点标签改成全大写英文字符,实际宽度会比测量值略大一点,我也在公式里增加了一个 10% 的保险系数。
4. 实操实录:从一段 DSL 到一张架构图
4.1 用一个真实场景走通全流程
为了验证整个管线,我用一个典型的订单创建链路作为示例。这个场景包含五个节点、五条边,同时涉及同步调用和异步回调,覆盖了第一版的主要能力。DSL 内容如下:
architecture "订单创建链路" { node 用户 node 网关服务 node 订单服务 node 消息队列 node 数据库 用户 -> 网关服务: 发起下单 网关服务 -> 订单服务: 鉴权通过 订单服务 -> 消息队列: 写入下单事件 订单服务 -> 数据库: 落表保存 消息队列 --> 订单服务: 处理结果回调 }在这个例子中,节点“用户”没有上游,层号为 0;“网关服务”的唯一上游是“用户”,层号是 1;订单服务的上游有网关服务,层号是 2;消息队列和数据库的上游是订单服务,层号都是 3。注意消息队列到订单服务这条虚线边是“反向依赖”——订单服务在上游,消息队列在下游,但消息队列需要异步回调给订单服务。布局算法会计算出这条虚线需要从层 3 折返指向层 2。技术上,我允许边的起点层号大于终点层号,但渲染时会把这类边统一放置在节点上方,避免穿过节点框。
4.2 渲染管线的实现细节
解析完成后,渲染器接收的是带坐标的节点列表和带路径的边列表。渲染器不关心布局逻辑,只做一件事:把模型写成 SVG 字符串。整个渲染流程分成三个步骤。
第一步是绘制边。遍历边列表,为每条边生成一个path元素。实线边的stroke使用主题色,虚线边把stroke-dasharray设为6 4。箭头是 SVG 里最容易出错的地方,我使用marker定义箭头形状,并把它挂在defs区域。这样每条边只要设置marker-end属性即可,不用重复绘制箭头路径:
<defs> <marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <path d="M 0 0 L 8 4 L 0 8 z" fill="#667"/> </marker> </defs> <path d="M x1 y1 C cx1 y1 cx2 y2 x2 y2" stroke="#889" fill="none" stroke-width="1.2" marker-end="url(#arrow)"/>第二步是绘制节点。每个节点生成一个g分组,包含一个圆角矩形和两行文本。矩形使用主题的背景色和描边色,圆角半径统一设置为 6。文本位于矩形中央,第一行是节点标签,第二行是可选的副标签,副标签字号比主标签小一号,颜色也浅一号。生成文本时要注意 XML 转义,标签里如果出现<、>、&等字符,不转义会导致整个 SVG 文件解析失败。我写了一个简单的转义函数,对所有文本字段统一过一遍。
第三步是生成根元素。根svg的width和height由布局引擎计算出的画布大小决定,计算方式是最右节点的右边界加 40 边距、最下节点的下边界加 40 边距。一个图如果只有空节点,画布大小就是默认的 600 x 400,避免出现零尺寸的无效 SVG。
4.3 主题系统:一套样式,多处复用
样式与数据分离是我一开始就坚持的设计原则。每个图的视觉风格由“主题配置”控制,默认主题提供一套适合文档的白底蓝边样式。主题配置是一个对象,包含节点背景色、节点边框色、主文本色、副文本色、边颜色、箭头颜色、背景色、圆角半径、字号、字重、行高等字段。有了主题,用户可以只改样式配置,不碰 DSL 文本,就能让整套文档配图风格统一。
我额外内置了一个适合深色背景汇报场景的主题,切换时只要在渲染参数里传不同的主题名。主题切换还顺带验证了管道架构的扩展性:因为渲染器只依赖“节点坐标 + 主题配置”,新增主题几乎不涉及逻辑改动,只需要调整颜色常量。以后如果要支持自定义字体,也只要在主题配置里加一个字体族字段。
4.4 导出 SVG 与 PNG:文档发布的最后一公里
渲染得到的是 SVG 字符串,直接写入以.svg结尾的文件即可。如果文档系统只接受位图,就得做一次格式转换。我的方案是用 canvas 把 SVG 画成位图,再导出为 PNG。这里有两个关键参数:导出倍率和背景设置。倍率建议设为 2,因为屏幕和打印设备都有像素密度换算,1 倍导出在高分屏上会显得发虚。背景色必须用主题配置里的背景色填充,直接铺到 canvas 上,否则透明背景在部分文档系统里会显示成黑色。
另一个细节是 SVG 到 canvas 的异步陷阱。drawImage并不保证 SVG 加载完成后才绘制,所以在执行转换前需要等待svgString被解析成一个可用的图片源。我个人习惯是先把 SVG 转成一个Blob,再用URL.createObjectURL生成临时地址,然后加载到Image对象里,在onload回调中执行绘制。这套流程虽然代码量略多,但稳定性很好,跨浏览器表现也一致。
5. 踩坑实录:常见问题与排查方案
5.1 问题速查表与解决方案
我把开发过程中实际遇到过的高频问题整理成一张表,方便排查时对照。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 节点文本溢出边框 | 字体未加载完成导致测量宽度偏窄 | 先用document.fonts.load预载字体,测量后再布局 |
| 中文显示为方块乱码 | SVG 文件缺失字体回退 | 设置font-family为系统常见中文字体,并保留通用字体 |
| 导出 PNG 文字模糊 | 使用 1 倍像素导出 | 导出倍率设为 2,canvas 尺寸同步放大 |
| 大量连线交叉 | 层内排序未做启发式优化 | 引入两轮重心法排序,先向下再向上 |
| 反向依赖边穿过节点框 | 边路径缺少避让处理 | 回边统一抬高,控制点向上偏移固定值 |
| 标签含特殊字符导致 SVG 解析失败 | 未做 XML 转义 | 所有文本字段统一转义后再拼字符串 |
| 节点坐标出现 NaN | 存在孤立节点或回环导致层号无法计算 | 计算层号前先做环路检测,孤立节点默认放第 0 层 |
这里特别说一下环路检测。布局算法依赖“层号 = 上游最大层号 + 1”,如果 DSL 中出现了A -> B和B -> A这种循环,递归计算会陷入死循环。我在computeLayer的入口处加了一个“访问中”标记,如果遇到仍在访问中的节点,就判定存在环,然后强制给该节点分配一个当前最大层号加 1,并输出警告。这个方案不算优雅,但配合文字提示,基本能保证图能画出来,坏路径也只是视觉上绕一点,不会崩溃。
5.2 案例复盘:一次文字溢出问题的完整排查过程
有一次同事反馈,某张图的节点文字明显溢出到边框外面,换了中文字体也没解决。我当时第一反应是测量函数出了问题,于是在浏览器控制台分别调用了measureText,发现返回的宽度确实小于实际渲染宽度。后来仔细看才发现,那次测量发生在字体加载之前,页面还没有加载完中文字体文件,系统临时用了一种宽度较小的替代字体。
这个案例的教训是:测量和渲染必须使用同一套字体环境。现在我在入口处加了一个 Promise 链,先等待document.fonts.ready,再执行整个测量和布局流程。如果是服务端场景,需要在生成 SVG 时就写好字体的完整font-family回退列表:优先指定目标字体,然后是系统默认的中文黑体,最后是通用 sans-serif。这样即使目标字体缺失,渲染端也会用替代字体,避免窄字体导致的溢出。
5.3 关于大图和性能的优化预案
第一版虽然没有重点优化性能,但我在设计时预留了处理大图的思路。当节点数超过 300 时,SVG 的直接渲染会让浏览器 DOM 节点大量增加,页面交互可能出现卡顿。我的预案是开启“简化模式”:关闭阴影和圆角效果,节点边框用最细的 1px,文本字号减小一档,这样同一张图能减少约 30% 的 DOM 体积。如果节点数超过 1000,则建议直接切换到 Canvas 渲染通道,不过那已经是另一种架构形态了。
另一个性能相关的点是边路径的重复计算。布局引擎会为每条边计算路径,如果同一个节点对之间有多条边,它们的曲线会完全重叠。我增加了一条简单的散开策略:按同一起止点边的序号,给控制点的 y 坐标增加一个偏移量,偏移量按(index - (count - 1) / 2) * 10计算。这样多条边会像扇子一样散开,不会糊成一团,而且代码量很小,实测效果提升明显。
6. 还可以往哪走:后续扩展与我的实操经验
6.1 从“能用”到“好用”的三个扩展方向
第一版只支持最基本的矩形节点。扩展方向上,我觉得最值得做的是形状与图标体系:在node声明里增加一个icon字段,比如node 数据库 @database,渲染时在节点左侧绘制一个小图标,能显著增强图的辨识度。这个改动对布局引擎没有影响,因为图标占用的宽度计入节点宽度即可。
第二个高价值扩展是分组与泳道。很多业务流程图需要表达“哪些节点属于哪个子系统”,文本 DSL 里可以增加group块,块内节点在布局时会被强制分配到同一个横向区域,并且渲染时在这个区域外面画一个浅色背景框和组标题。泳道图则可以把分组改成纵向排布,只不过布局算法需要增加一维约束,复杂度会上一个台阶,但收益非常明确。
第三个方向是交互式预览。现在项目的输出是静态 SVG,如果提供一个网页入口,把解析、布局、渲染跑在浏览器里,用户改一行文本立刻看到新图,开发和调试体验会好很多。这个方向还可以叠加“点击节点高亮相关链路”“拖拽微调节点位置后回写 DSL 坐标”这类能力。我的设想是保留文本作为唯一真源,手动拖动的结果作为一种“偏移量”叠加写入 DSL 注释,这样既不破坏文本可读性,又给了用户微调的自由度。
6.2 做完这个项目后,我最大的几个体会
第一,文本图表工具最难的从来不是解析文本,而是布局。文本解析是纯逻辑问题,测试用例写够之后基本就稳定了;布局则天生带有一定的主观性,同样的依赖关系,不同的人期望的视觉排列可能完全不同。所以做这类工具,建议先确定“自动布局要做到什么程度”,设计好规则边界,不要贪多变。
第二,一个足够好的默认主题能省掉 80% 的调参时间。早期我给每张图单独调样式参数,后来发现用户真正在意的只是“图上不要有刺眼的颜色”和“打印出来清晰”。现在所有渲染统一走主题配置,新增样式只需要复制默认主题改几个字段,整个项目维护成本下降了一个量级。
第三,空和边界条件一定要从第一天就处理。我遇到过 DSL 文件为空、节点名重复、没有边、只有一条边连自己等一堆边界情况,如果解析器和布局引擎不兼容“空模型”,每次都会崩溃。后来我把“空模型也能输出一张合法 SVG”当作基本验收标准,任何功能演进都不能破坏这个底线。
这个项目到目前为止已经足够支撑我日常写文档画图的需求,我也开始把同一个渲染引擎应用到内部的小工具里。如果你也有类似“文档里的图总是一夜之间就过期”的困扰,不妨试试自己搭一套文本图表方案,哪怕只是先支持最简语法,带来的长期收益都会比你想象的大。