花了大概两个周末,我做了个叫 diagram-design 的图表设计工具。起因特别简单:当时要给某跨平台系统补一张调用链路图,我用图形工具拖了半天,箭头对不齐、文字框叠在一起,后面需求一改,整张图又要重画。最让我抓狂的是,改完第三版之后,我手上连一份能追溯到改动的原始文件都没有。这种痛苦,大概每个写过架构文档的人都懂。所以这次我决定换个思路,把"画图"变成"写图":用一段带结构的文本去描述图表,然后用命令行一键生成矢量的 SVG 文件。这篇文章就把 diagram-design 的完整设计思路、核心实现、以及我在实操中踩过的坑都摊开来聊,希望能帮到同样被图表设计折磨的人。
1. 为什么我要做 diagram-design 这个项目
1.1 从"画图5分钟,改图两小时"说起
以前我画图的标准姿势是:打开一个绘图工具,拖一个矩形出来,再拖一根线,把两端粘到矩形边缘,然后开始调颜色、调字号、调对齐。画一张简单的架构图,熟练的话可能十分钟就搞定。但问题从来不在第一版,而在第二版、第三版。只要业务方说"中间再加一个服务",我的整个版式就全乱了:新增的框放哪、线条往哪走、原来的层次关系要不要重构。改一次图,至少半小时起步,而且每次都是纯手工操作,鼠标点得越多,出错的概率越大。
这不是工具的问题,而是使用场景的问题。文档里的架构图、流程图、时序图,本质上不是"画出来"的,而是"推导出来"的。它由一组节点和一组连线构成,节点有哪些、连线的方向是什么,这些信息早就写在代码和业务逻辑里了。图表设计工具应该帮我把"节点和连线"翻译成"可视化的形状",而不是逼我一个一个去挪坐标。反复调整位置,是最没有价值的那部分工作量,画图时间几乎全耗在这里,我实在忍不了了。
这个痛点也是 diagram-design 的出发点:让图表的内容和图表的表现分离。内容用文本描述,表现交给自动布局和样式系统。文本是唯一的事实源,改图就变成改文字,版本管理也自然落到文本文件上。
1.2 选型:图表即代码,而不是拖拽画布
市面上有不少画图方案,我也认真对比过。一类是交互式画布工具,拖拽式操作,所见即所得,但对文档自动化不友好:没有 API、不能批量生成、不方便做差异比较,更没法在代码仓库里追踪一张图的演进历史。另一类是代码驱动方案,比如社区里知名的 Graphviz、Mermaid 这类文本绘图生态,用一个 DSL 描述图,然后交给引擎渲染。这类方案最大的优点是:图是文本,意味着可以放进 Git、可以 diff、可以在 CI 流程里自动生成。缺点也很明显,DSL 的表达能力和渲染风格往往绑定得很死,想要高度自定义风格,改动起来会很费劲。
我在设计 diagram-design 时没有直接套某个现成方案,而是借鉴了"图表即代码"这个核心思想,自己做一套轻量的 DSL。原因有两个。第一,我需要控制全链路:从语法解析、布局算法到最终 SVG 输出,每个环节出问题我都能定位、优化。第二,我希望用户写起来像写 Markdown 一样自然,而不是去学一套比较复杂的状态机规则。diagram-design 的面世,不是要替代那些成熟方案,而是想解决一个更聚焦的问题:在团队内部,怎样把图表设计和文档生成流程无缝衔接。
我追求的交互方式很简单:你在一个.dg文件里写图表定义,保存,然后命令行工具在几百毫秒内生成一张好看的 SVG 图。图不再是一次性的产物,而是和代码一样可以审查、可以测试、可以复用的资产。
1.3 这个项目适合谁来用
diagram-design 主要面向三类人。第一类是写技术文档的研发,尤其是需要频繁产出架构图、时序图、部署拓扑图的人。第二类是技术写作和知识管理相关的从业者,这类人通常有大量配图需求,但不想被复杂的绘图软件绑住。第三类是刚入门的新人,他们需要快速理解一个系统结构,而看文本版的图定义,比看一张歪歪扭扭的图片要清晰得多。
对于第一类人,diagram-design 可以直接替代他们日常画草图的环节,甚至可以作为代码注释的一部分写进仓库。对于第二类人,diagram-design 的 SVG 输出可以无缝嵌入 Markdown、HTML、Word 文档,改图时只需要重新执行一次命令。对于第三类人,这个项目的价值在于让图表变得可以检索、可以比较,"图"和"代码"之间没有断层。
我在设计时也刻意保持了学习成本:全部语法只有两个关键字和一个箭头。如果你会写列表,基本上五分钟就能上手。这也是整个项目最核心的设计原则:图表的表达力不是靠堆砌功能,而是靠恰到好处的抽象。
2. 核心设计:DSL 语法与渲染引擎
2.1 DSL 语法设计思路:不求全,但求顺手
初版语法我定义得极其克制。只有节点(node)和边(edge)两种实体,再加一个全局的样式区。我拿一个最常见的微服务调用图举例:
node order { label = "订单服务" shape = "rect" color = "#4A90D9" } node inventory { label = "库存服务" shape = "rect" color = "#50E3C2" } node payment { label = "支付服务" shape = "rect" color = "#F5A623" } edge order -> inventory { text = "扣减库存" } edge order -> payment { text = "发起支付" }这段文本读起来几乎就是一句句人话:"有一个叫 order 的节点,它的标签是订单服务;有一条从 order 到 inventory 的边,边上的文字是扣减库存。" 我不喜欢创造复杂的关键字,也不喜欢把样式和结构混在一起写。结构就是节点和关系,样式单独拎出来作为属性,这样阅读器一眼就能分辨"这张图里有什么"和"这张图长什么样"。
很多绘图 DSL 会引入"分组""泳道""子图"这些高级概念。我并不是不想支持,而是想让核心语法保持简单。复杂度应该通过"组合"去实现,而不是一股脑加进语法。比如分组,可以通过命名前缀加颜色去模拟;泳道,可以通过坐标区域约束去实现。这些扩展都在外围做,核心语法保持稳定。
2.2 渲染管线与布局算法:从文本到坐标再到像素
diagram-design 的渲染管线分成四个阶段:词法分析、语法分析、布局计算、SVG 生成。前两个阶段把文本解析成 AST,这是我所有工作的基础。AST 再转换成内部的图结构,也就是一组节点对象和一组边对象。真正的技术难点在第三阶段:布局计算。
布局算法决定了节点放在哪里。流程图和依赖图通常适合层次布局:按拓扑顺序,把无依赖的节点放在同一层,有依赖的放在下一层。我实现了一个简单的分层算法,核心思路是先对图做拓扑排序,如果图中存在环路,就通过标记回边的方式打破环路,把回边单独染色。然后对每一层的节点做顺序优化,减少边的交叉。这一步听起来简单,实际写起来要处理的情况很多:孤立节点怎么摆、多个入口怎么对齐、长文本节点如何处理。
关系图、领域模型图这类没有明确方向的图,用层次布局反而会牺牲可读性。我实现了两种布局策略:层次布局和网格布局。网格布局适合展示模块之间的平级关系,比如一张系统的模块划分图,横轴放子系统,纵轴放模块,每个格子就是一个模块卡片。将来如果引入力导向布局,就能覆盖更多场景,但现有的两种布局已经能满足绝大多数架构图、流程图的绘制需求。选布局不是越多越好,而是要让每一种布局都经过充分打磨,保证输出稳定、可预期。
2.3 样式系统:像 CSS 一样分层覆盖
图表设计最容易失控的是样式。同一套图,今天你改成蓝色系,明天另一个同事又改成绿色系,最后文档里的风格七零八落。diagram-design 引入了主题(theme)和局部样式(style)两层体系。
全局主题使用 CSS 变量的思路来定义:
theme { background = "#FFFFFF" node_fill = "#F0F4FF" node_stroke = "#3B82F6" node_font = "#111827" edge_stroke = "#9CA3AF" edge_font = "#4B5563" font_size = 14 }节点和边可以覆盖主题里的变量。比如某个告警节点的颜色要突出,就在节点上写一个color = "#FF0000"。主题负责整体风格,局部样式负责例外。这种分层关系让图表设计变得非常有条理:我要做暗色主题,只需要换一套主题变量,所有引用该主题的图全部自动更新,无需逐张修改。
为了保证默认输出就好用,我内置了亮色和暗色两套主题。亮色主题用于印刷和文档,暗色主题用于演示文稿和夜间阅读。默认主题经过多次调整,最终确定了一套低饱和、高对比度的配色。经验是:不要自己闭门造车去配颜色,选一套成熟的设计系统色板,在此基础上调两三个变量就足够。图表设计里,颜色不是越多越好,而是越克制越好。
3. 实操:从0到1搭建一个图表设计项目
3.1 技术栈与项目结构
技术栈选型上,我用了 TypeScript 做主要语言,因为它类型系统能很好地描述 AST 和节点模型,而且可以同时运行在 Node.js 和浏览器环境。CLI 用 Node.js 实现,解析器是手写的递归下降,尽量不引入外部依赖。布局和渲染模块是纯函数,输入是图数据,输出是坐标数据和 SVG 字符串,这样单元测试写起来非常舒服。
项目结构分成几个层:
diagram-design/ ├── packages/ │ ├── core/ # 解析、AST、图数据模型 │ ├── layout/ # 层次布局、网格布局 │ ├── render/ # SVG 生成、样式注入 │ └── cli/ # 命令行入口、文件监听 ├── examples/ # 示例图定义 ├── docs/ # 语法文档 └── tests/ # 快照测试和布局测试这个分层对应了渲染管线:core 负责理解文本,layout 负责决定位置,render 负责输出图形。每一层之间的接口都很薄,core 输出的是一个普通的 JSON 对象,layout 读这个对象,render 再读 layout 的结果。这样做的好处是,将来想换一个 SVG 渲染器,render 层可以整个替换,不影响上层。
3.2 核心模块实现的关键细节
解析模块是项目的灵魂。我写了一个递归下降解析器,先把文本切成 Token,再做语法分析。Token 类型只有IDENT、STRING、NUMBER、ARROW、LBRACE、RBRACE等十几种。语法分析的核心是一个parseNode函数和一个parseEdge函数,遇到node关键字就进入节点解析,遇到edge关键字就进入边解析。属性列表统一按key = value处理,允许用双引号或单引号包裹字符串。
布局模块是项目里最花时间的一部分。我实现层次布局时,参考了经典的分层绘图算法思路,但没有做得特别复杂。算法分为四步:第一步,对图做 DFS,标记节点层级;第二步,处理回边,把回边对应的源节点提升到下一层避免死循环;第三步,在每一层内部调整节点顺序,采用一种朴素的迭代方法减少交叉;第四步,按层计算坐标,每层内部节点做均匀分布,然后用中线对齐来确定边的路径点。这个实现可以处理几百个节点的图,如果有上千个节点,我会建议拆分,而不是硬画一张大图。
SVG 生成相对直接。每个节点渲染成一个<g>组,包含一个<rect>和一个<text>,每条边渲染成<path>,边上如果有文字就用<text>放在路径中点。需要特别注意的是文本宽度计算:如果不提前估算文本占的宽度,矩形框就会包不住文字。我在渲染前会针对每个 label 预计算宽度,然后动态调整节点矩形宽高,保证文字不溢出。
3.3 命令行与自动导出:让图表嵌入工作流
diagram-design 的 CLI 使用体验是整套项目里我最在意的部分。日常使用非常简单:
diagram-design docs/architecture.dg -o docs/architecture.svg --theme dark如果希望监听文件变化,就加一个--watch参数,保存后自动重新渲染。这个功能对写文档的人来说太实用了,边写边看效果,不用手动执行命令。我还提供了一个--pptx导出选项,内部会先把 SVG 转成高质量 PNG,并嵌入到 PPT 的占位符尺寸里。后面再细说这个导出踩过的坑。
在项目里,我通常会在package.json里加一条脚本:
{ "scripts": { "docs:diagram": "diagram-design docs/**/*.dg -o docs/assets --theme light" } }这样每次文档更新时,跑一条命令,所有图表重新生成。如果接入了 CI,还可以在流水线里检查.dg文件是否有未导出的变更,做到"图随代码走"。
4. 踩坑记录与排查思路
4.1 中文乱码问题:SVG 里的字体陷阱
我最早一版渲染出来的 SVG,在中文字符上几乎没法看:要么变成一个个方框,要么乱成一片。排查下来是两方面的原因。首先是解析问题,文件读取时没有强制使用 UTF-8 编码,遇到 Windows 下的文本文件就会乱码。解决方案是在读取文件时明确指定utf-8,并且在解析器入口对非法字符做过滤。
更隐蔽的是字体问题。SVG 在浏览器里可以用系统的任意中文字体,但如果我把这个 SVG 丢到其他环境渲染成 PNG,目标环境里如果没有对应字体,就会显示豆腐块。我的解决思路是:SVG 输出时设置多组字体回退,例如:
<text font-family="'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif">库存服务</text>同时,在转换 PNG 的机器上安装至少一款中文字体。这是图表设计工具做中文支持最容易被忽略的一环,也是我最想提醒大家的地方。
4.2 复杂图布局错乱:节点重叠与边交叉
大概画到两百个节点的规模时,布局算法的各种毛病就全冒出来了。典型的症状是节点大规模重叠、边从不相干的两个框之间穿过、层级错乱。我定位后发现根因在分层策略上:代码里把所有有依赖关系的边都当作下一步的方向,可实际图里存在跨模块的回调,形成逻辑上的环。处理方式是把回边单独标记为"虚线桥接",不参与层级排布,这样既保留环的关系,又不会破环整体排版。
另一个经验是:如果一张图超过五百个节点,再好的布局算法也会输出一张让人读不下去的图。更务实的做法是拆分。我在一次系统梳理时,把一张几百节点的微服务依赖图拆成了网关层、业务层、数据层三张子图,用一张总览图做入口,效果远好于一张巨无霸。所以后来我特意在文档里写了一句话:把一张图拆成多张图,是布局问题最大的优化手段。
4.3 导出 SVG 到 Word、PPT 的兼容方案
SVG 在 HTML 世界里非常完美,矢量、无损、可交互。但拿到 Word 和 PPT 里就不是这么回事了:老版本软件对 SVG 支持很烂,插入后要么不显示,要么样式错乱。我的经验是分场景处理:Word 文档优先导出高分辨率 PNG,设置 300 DPI 以上,避免印刷模糊;PPT 则用 PNG 插入,锁定纵横比;只有网页文档才直接用 SVG。
这里有一个坑:SVG 转 PNG 时,如果文字用了系统字体,且转换环境没有对应字体,渲染出来的 PNG 文字会走样。我后来用一个技巧解决:转换前把所有文字转成路径。也就是对 SVG 里的<text>做路径化处理,这样不管拿到什么环境,文字都能以矢量轮廓保持正确。代价是文件体积变大,且无法再编辑文字,但作为最终呈现是可靠的选择。在 diagram-design 里我保留了一个--embed-fonts选项,默认关闭,需要出印刷材料时再打开。
5. 图表设计的影响范围与下一步扩展
5.1 图表设计带来的实际改变
diagram-design 做出来之后,我做技术方案评审时的工作方式完全变了。以前评审材料里贴的是一张静态图,评审过程中任何讨论都基于"这张图",一旦有人建议加模块或改方向,我只能说"后面再更新一张图"。现在我把.dg文件直接放在评审文档旁边,现场改文字、重新生成、投屏展示,讨论到哪就改到哪,效率提升非常明显。
更让我意外的是协作场景。团队里不同人对同一架构的理解经常有分歧,而图表的文本版让分歧变得具体:你说这个模块在上一层,那就在.dg文件里把边改一下,大家看着差异就能讨论,而不是看着两张长得完全不同的图片去猜对方想表达什么。这就是图表设计真正的影响力:它把"表述的差异"变成了"可编辑的结构",把一拍脑袋的讨论变成了对文本的 review。
5.2 后续规划:从静态输出到准交互
我目前的版本只支持静态 SVG,接下来的方向是交互能力。比如给每个节点绑定点击事件,悬停时高亮周边路径。实现方式是在 SVG 生成时附带一份节点 ID 与坐标的映射表,前端拿到 SVG 后注入事件处理。另外一个方向是支持更多图形原语,比如数据库圆柱体、手机模型、云图标。这些原语本质上只是预设好的shape模板,不影响 DSL 核心。
我更看好的是和代码仓库的深度融合。如果.dg文件可以放在代码目录里,并且与源码文件放在一起,那么图表设计就不再是文档部门的事,而是研发日常的一部分。希望在后续版本里,能让 diagram-design 的渲染器直接作为 IDE 插件运行,编辑代码时图表实时更新。这个目标有些大,但路径已经比较清晰。
我个人在做了 diagram-design 之后的体会是:图表设计的痛点,从来都不是"不会画",而是"反复改"。把图表变成可执行、可版本管理的文本资产,一开始有人会觉得"多此一举",真正用起来之后,几乎没有人愿意退回旧的拖拽方式。最后分享一个小技巧:把图表的源文件顺手放在相关代码模块的注释图案旁边,每次改代码时顺手更新图,文档就永远跟得上代码,这份收益会越滚越大。