图表即代码:用DSL与SVG实现架构图自动化渲染与版本管理 做技术方案评审和文档沉淀的时候图往往是最后才补的东西。需求沟通、架构评审、接口梳理画来画去都是同一批图系统架构图、调用关系图、流程状态图。可每回画图都有一种“一次性消耗品”的感觉改一个节点名称整张连线都要重排图在文档里代码在仓库里版本一更新两者就分家。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中低中中场景偏 UMLYAML 自研渲染低高可控低描述别扭自研 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[typeservice] 或 edge[arrowdefault] properties: { fill: string; stroke: string; strokeWidth: number; fontSize: number; }; }渲染时对每个节点/边做选择器匹配匹配规则来自简单的属性选择器。这样做的好处是“内容”里不混入“样式”但又不需要像 CSS 那样依赖外部文件导出 SVG 时自带样式信息方便跨端使用。文本标签的处理有坑。图中文字无法直接放在 SVG 单个text里就完事——中英文混排、换行、宽度计算、自动缩写都需要考虑。最终方案是在布局阶段就为每个标签预留尺寸渲染时用 SVGforeignObject内嵌 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 transformtranslate(120, 80)>