☰
图表即代码:用DSL与SVG实现架构图自动化渲染与版本管理
2026/10/12 19:34:19 网站建设 项目流程

做技术方案评审和文档沉淀的时候,图往往是最后才补的东西。需求沟通、架构评审、接口梳理,画来画去都是同一批图:系统架构图、调用关系图、流程状态图。可每回画图都有一种“一次性消耗品”的感觉,改一个节点名称,整张连线都要重排;图在文档里,代码在仓库里,版本一更新两者就分家。diagram-design 就是在这个背景下冒出来的一个偏门实践:把图表本身当作代码模块来设计,用一套可读、可提交、可自动渲染的方式替代手工拖拽。

这个项目说白了解决三件事:第一,图表以文本形式定义,可以进 Git 做版本管理;第二,渲染规则和业务逻辑分离,换一套主题样式不需要动内容;第三,结构化和自动化程度高一点,后续能接入自己的前端系统或文档流水线。如果你平时要画架构图、UML、流程图,又被工具锁定、文档漂移、协作混乱这几件事折磨过,这篇内容应该能直接帮到你。我把它从立项思路、技术选型到核心模块的实现细节完整梳理一遍,最后还有踩坑实录。

1. 项目动机与整体设计思路

1.1 传统画图方式的痛点是“不可追踪”

画图这个动作本身不贵,贵的是后续的维护成本。以前用可视化工具拖节点、拉连线,看似五分钟完事,实际上一个多月后要改的时候,定位一个被继承的样式选项都要翻好几个菜单。更致命的是,图表文件通常是私有格式,或者以二进制或专用格式存在,团队里的其他人打开要么缺字体、要么版本不兼容,最后就是把截图贴在文档里,失去了所有可编辑性。

diagram-design 的出发点不是替代所有可视化工具,而是解决一类特定场景:技术设计文档中的图。这类图的特点是结构稳定、变更频繁、需要多人维护。暴露出的问题也最典型:

  • 图表内容和业务代码互相脱离,改了代码忘了改图
  • 图本身没有历史记录,没法知道谁在什么时候改了什么
  • 协作时需要互相发文件,文件流转几轮后就是一笔糊涂账
  • 同样的架构想法,换一个人画出来的风格就完全不一样

我在项目启动前做了一个简单的需求矩阵,把“必须满足”的项列出来:文本可 diff、渲染可复现、样式可换肤、节点可复用、导出格式标准。这五个诉求直接决定了后面所有技术选型的走向。

1.2 设计方案:图表描述与渲染分离

核心设计思路用一个通俗类比来说:把画图拆成“写剧本”和“排戏”两个阶段。写剧本的人只需要关注角色和故事情节,排戏的人负责舞台调度和灯光。对应到技术上就是 DSL(领域描述语言)负责表达“图上有什么”,渲染引擎负责处理“图怎么摆、怎么画”。

DSL 层不依赖任何可视化概念,它只描述节点、连线、分组和约束关系。例如我需要描述一个登录流程,不会写“在坐标 (400, 300) 画一个圆角矩形”,而是声明“有一个名为‘校验账号’的节点,它由‘开始’节点指向,并在通过时连接到‘登录成功’”。坐标、形状、颜色这些决定权全部上交到渲染层。

这样做最大的好处,是图表内容可以在 Any 编辑器里编写,不依赖 GUI。同时文本天然支持对比、审查、合并。某团队在使用这类方案后对几十张设计图做了历史追溯,哪次版本发布改动了哪个模块的连接关系,一眼就能 grep 出来,这在之前是做不到的。

1.3 目标用户与项目边界

diagram-design 的目标用户是内部工程师团队。设计之初就砍掉了几个听起来很酷但实际价值不高的功能,比如多人实时协同编辑、自动布局的美学优化、套用大而全的图形标准库。保留的是单用户高效编辑、即时预览、命令行渲染、结构化导出。

定位是“够用就行”的轻量级基础设施,不是庞然大物。在此基础上,花了很多精力在接口稳定性和语义清晰度上,保证后续上层的自动化能力有地方挂。如果你也需要在文档流程、代码生成、结构化分析等方向做集成,这个项目边界有参考价值。

2. 技术方案选型与关键决策

2.1 为什么没有直接使用现成图表库

第一反应往往是看知名开源图表工具,比如 Mermaid、Graphviz、PlantUML。这些库确实很好,但实际测试一圈,发现它们覆盖的场景和我们的核心诉求错位明显。

Mermaid 上手快、渲染能力强,但它设计的初衷是“快速生成”,对通用 DSL 的定制程度不够深。有次想实现一种非标准箭头——带数据流匹配标记的连线,Mermaid 支持不了,只能退回图片编辑。Graphviz 的自动布局算法很强,但它的 DOT 语言描述方式和业务领域的语义差距太大,让工程师直接写 DOT 的心智负担很高。PlantUML 更适合 UML 场景,往构建流程里集成不错,但对自定义图形和交互模式比较封闭。

这不是说这些库不好,而是它们帮我们解决的问题域不同。它们的存在可以证明“文本生成图表”的路线是正确的,但“设计一套自己的数据结构、渲染流程、扩展规范”才是这个项目的核心价值。

2.2 技术栈选型依据

渲染层选择了 SVG 作为基础,几乎没有犹豫。SVG 的优势在于文本格式、可脚本控制、支持 CSS 样式、缩放不失真,并且天然适合 Markdown 文档嵌入和浏览器展示。Canvas 虽在重交互场景有性能优势,但考虑到图表以静态呈现为主、需要良好的样式继承和 DOM 事件绑定,Canvas 带来的是复杂度而非收益。

DSL 解析器没有用现有的 ABNF 框架或 PEG 生成器,而是手写了递归下降解析器。原因是 DSL 规模很小,手写解析器可以精确控制错误信息,格式灵活,不用引入额外的编译构建依赖,在调试跟扩展时更可控。

布局引擎是另一个独立模块,因为“如何放置节点”和“如何绘制节点”是完全不同的关注点。布局引擎只管拓扑结构和几何约束,渲染层从布局引擎拿到的是一棵已计算坐标的节点树。二者之间的接口定义为一个纯数据对象,序列化后就是 JSON,前端可以拿这个对象做任意二次渲染。

2.3 选型避险与替代方案对照

实际上在早期也考虑过用 YAML 作为描述格式,这样可以省掉自研 DSL 的工作量。但测试后放弃了,原因在于 YAML 对“图结构”描述不太自然。节点之间的多重连接需要靠锚点和别名技巧处理,缩进错误直接导致解析失败,一个语法严格但信息表达密度低的 DSL 反而更合适。

下表是当时的评估对照,直接放出来供参考:

方案学习成本可定制性渲染质量集成难度结论
Mermaid低中较高中场景不符
Graphviz/DOT中中高中语义隔阂
PlantUML中低中中场景偏 UML
YAML + 自研渲染低高可控低描述别扭
自研 DSL + SVG中高可控低采纳

自研 DSL 在前期需要一定的设计投入,但进入稳定期后,边际成本非常低。每增加一个新图形类型或者样式规则,不需要改动 DSL 语法,只需在渲染端同步扩展。这就是“描述”和“表现”两层分离的长期红利。

3. 系统架构与模块划分

3.1 从输入文本到最终图形的数据流

diagram-design 的整体结构可以拆成五个功能模块:文本定义层、词法语法解析层、语义模型层、布局计算层、渲染输出层。数据流是单向的。

文本定义层接收用户输入的 DSL 源码,交由解析器做词法分析、语法分析和语义校验。产出物是中间表示,即一张由节点、边、分组、样式引用构成的图数据模型。这个模型不涉及任何像素坐标,只保留“关系”和“属性”。

布局计算层的输入就是这个中间表示。它负责分配坐标、计算尺寸、计算连线路径。产出物是几何描述对象,包含每个节点的中心点坐标、宽高、每条连线的起止锚点和路径段。

渲染输出层负责将几何描述对象映射到最终 SVG 元素。这里做样式解析、颜色主题继承、字体字号确定,最终输出完整的 SVG 文档。值得一提是 SVG 文档可以被保存为 .svg 文件、直接内嵌到 HTML,也可以转成 PNG。

这种单向数据流的好处是每个模块可以独立测试。布局引擎的纯函数输入输出很容易做单元测试,解析器也可以脱离 UI 跑完整的语法校验流程。

3.2 核心数据模型设计细节

语义模型层的节点数据结构和普通流程图工具里的“框+文字”有本质区别。每个节点带有类型标识、名称空间、端口定义和样式引用。类型的定义决定了它在渲染时拥有哪些默认属性,以及它允许连接什么类型的端口。这个机制解决了两个常见问题:一个是“连线连错位置”的结构约束,另一个是“节点类型一变,渲染风格跟着变”的样式复用。

端口是节点上对外暴露的连接点。普通流程图中连接线只粘在矩形边缘的中间或四角,但实际技术架构图里,一个模块常有上行连线和下行连线,端口通过命名区分语义,比如 input/user 和 output/event,这样可以在布局时自动选择更优的锚点位置。

边数据模型也相对复杂一点,不只保存 from 和 to,还包括了逻辑分组、线型、路由策略、标签位置。路由策略有三种:直线、正交折线、曲线。正交折线用于架构图和流程图最多,所以默认路由使用简单的左到右分层算法,只有特殊指定的边才使用曲线。

细看这些设计,基本回到一个原则:数据模型强于代码逻辑。所有功能能通过数据结构表达的,就不在渲染代码里写 if 分支。这是保证项目在持续扩展时不会腐化的关键。

3.3 模块之间的接口约定

接口层面,所有模块传递的数据结构都定义为 TypeScript 类型,并通过一个无依赖的共享包维护。这个共享包作为定义数据契约的单一事实来源。布局引擎和渲染引擎是独立的 npm 包,DSL 解析器也是,但它们都是 peer 依赖,使用时可以单独替换某个实现。

实践中的感受是,把这些模块拆成独立包的好处不止复用层面。更实际的是某个模块变更时,可以独立升级而不影响其他模块。比如有一次优化了连线路径算法,只重发了布局引擎的版本,上层 DSL 和渲染模块完全不用动。

接口具体定义为一个标准图数据对象,包含:

interface DiagramDocument { version: string; nodes: DiagramNode[]; edges: DiagramEdge[]; groups: Group[]; styles?: StyleDefinition[]; }

所有模块只需要理解这个对象。解析器负责生产它,布局引擎消费它并生产几何信息,渲染模块消费几何信息并生产 SVG。接口友好到可以用一段脚本拼一个图数据结构,直接丢给渲染模块输出,这也为后续 API 化做了铺垫。

4. 核心模块实操与关键环节实现

4.1 DSL 语法设计:如何在简洁与表达力之间取舍

DSL 的每一处设计都需要在“好写”和“丰富”之间权衡。早期版本想塞进太多功能,比如支持表达式计算、函数调用,结果测试时发现异常出现频率高,基本反人类。后来做减法,最终语法只保留四个核心语句:节点声明、连线声明、分组声明、样式引用。

语法设计刻意向“矮胖”方向发展:关键词少,缩进不敏感,一行一种含义。为了照顾习惯,节点的写法借鉴了工具里常见的别名方式。

@node 用户服务 [service] { 端口: api/rest, api/rpc } @node 订单中心 [service] { 端口: consume/order, api/admin } @edge 用户服务.api/rest -> 订单中心.consume/order { label: "创建订单" }

这里[service]表示节点类型,{ 端口: ... }是节点属性,@edge的 from 和 to 是“节点名.端口名”格式。阅读时从左往右就能看懂拓扑关系。每个端口名可以自带语义,这样连线的起点和终点都明确到了能力层面,而不是一个矩形边。

为了让刚上手的同事减少对语法的恐惧,还设计了一个“就近定义”的约束:一个节点只能接入已声明过的端口。解析器在语义检查阶段会做严格的端口匹配校验。错误提示直接指出“端口 A 未在节点 B 上定义”,避免定位半天。

4.2 递归下降解析器的实现要点

解析器表面上是处理文本,实际上要做的事是“把人的表达翻译成结构化描述”。用递归下降是因为语法规模小且可预测。核心结构是parseTokenList和parseStatement两个函数的互相调用。

具体实现上,先做词法分割,把输入按空白和换行切块,标识出关键词、标识符、字符串、数字、@和{}等符号。随后进入语法解析阶段,通过“前看”一个 token 判断当前语句的类型。整个实现大概四百行,不存在复杂的回溯逻辑。

有个细节值得分享:词法分析时不直接丢弃换行符,而是作为 statement 边界的标记。这样既支持一行一个语句的写法规格,也允许用显式续行符\做长属性的断行。后续如果做错误提示,行号信息也方便。

代码架构形如:

// parser.ts 核心示意 export class DiagramParser { private tokens: Token[]; private index = 0; parse(source: string): DiagramDocument { this.tokens = tokenize(source); this.index = 0; const statements: Statement[] = []; while (!this.isAtEnd()) { statements.push(this.parseStatement()); } return this.buildDocument(statements); } }

语义校验是解析之后的独立阶段,单独放在一个validator.ts文件中。校验规则可以配置,最常见的是“不允许未定义节点边”“不允许悬空端口”“强制要求节点名唯一”。这些规则都不放在解析循环里,因为解析器的职责只是“格式是否正确”,语义校验负责“含义是否合法”。

4.3 布局引擎:从关系拓扑到几何坐标

布局引擎是全项目中最有挑战性的部分。它要把输入的无序节点和连线,整理成一张不重叠、连线清晰、整体紧凑的图。基于分层的思想做实现:先把所有节点按照拓扑排序划分为层级,然后在一层内分配水平位置,最后压缩整体面积。

拓扑分层的思路理解起来很简单:找到图中所有入度为 0 的节点,它们必然属于第一层;删掉它们出发的边,再次找入度为 0 的节点属于第二层。反复执行直到所有节点都被分层。遇到环时做特殊处理,默认将环的最后一条边反转打破,并在结果中标注警告。

分层完成后,同一层的节点会纵向排列。宽度分配采用的是 Bourgeois 风格的贪心策略:尽量让有直接连线的节点在水平方向上靠近。如果两个同层节点被同一个上游节点引用,它们会自动靠近,减少连线交叉的概率。

连线路径使用正交折线。折线生成的关键是计算控制点。常见做法是曼哈顿路由:先在源端口和目标端口之间创建一条水平/垂直的基础路径,然后用可选的中间点避让节点包围盒。处理起来比较麻烦的是目标节点在源节点左边时,折线必须走“绕行路线”。

这里分享一个大概过了两年才补上的经验:任何自动布局算法都替代不了人工微调。所以最终在语义模型里加了一个“锚点覆盖”字段,允许使用者手动指定节点的固定坐标。布局引擎如果发现坐标已存在,就跳过该节点的重新分配,只用它做参考点调整其他节点。灵活性和自动化的平衡就在这里。

4.4 渲染后端与 SVG 输出细节

渲染模块接收的是已经完成坐标计算的几何描述对象,工作重点转换为 SVG 元素组装。主要做三件事:创建节点组元素、创建连线路径、生成文本标签。同时处理样式继承。

样式系统没有做成 CSS 变量,而是做成一层“样式定义对象”,结构类似:

interface StyleRule { selector: string; // "node[type=service]" 或 "edge[arrow=default]" properties: { fill: string; stroke: string; strokeWidth: number; fontSize: number; }; }

渲染时对每个节点/边做选择器匹配,匹配规则来自简单的属性选择器。这样做的好处是“内容”里不混入“样式”,但又不需要像 CSS 那样依赖外部文件,导出 SVG 时自带样式信息,方便跨端使用。

文本标签的处理有坑。图中文字无法直接放在 SVG 单个<text>里就完事——中英文混排、换行、宽度计算、自动缩写,都需要考虑。最终方案是在布局阶段就为每个标签预留尺寸,渲染时用 SVG<foreignObject>内嵌 HTML 来做排版。这一点不是最优但极其好用,尤其是中文标签和图标字体混排时,比原生 SVG text 灵活太多。

输出阶段支持两种格式。直接输出完整 SVG 文档,或通过第三方库转成 PNG。PNG 转换实际上是在 Node 端调用 headless 渲染完成的,但主路径始终是 SVG,因为压缩体积小、可读性高、可继续编辑。

4.5 完整示例:从 DSL 到 SVG 的关键路径

实际操作中,检查一个需求是否完成,最直观的方法是看一个最小示例的完整链路。下面这套就是在一开始就固定下来的“Hello World”用例。

DSL 输入:

@node 订单服务 [service] @node 库存服务 [service] @node 支付服务 [service] @edge 订单服务 -> 库存服务 { label: "扣减库存" } @edge 订单服务 -> 支付服务 { label: "发起支付" } @edge 支付服务 -> 订单服务 { label: "支付结果回调" }

按解析流程,第一步得到语义模型。第二步交给布局引擎,生成节点坐标。第三步经过渲染模块,产出如下骨架的 SVG 节点结构:

<g transform="translate(120, 80)">

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询