☰
用文本化图表革命文档工作流:DSL、布局引擎与SVG实践
2026/10/17 8:06:06 网站建设 项目流程

做技术文档和方案梳理这些年,我最大的体会是:画图这件事,一旦开始用文本描述,就再也回不去了。日常写架构说明、接口梳理、模块依赖关系,总离不开各种图表。以前我习惯打开画图工具拖拽方框、连线、调颜色,刚开始觉得挺直观,等到图越来越多、改版越来越勤,痛点就被放大了——节点一多就乱,样式统一全靠自律,图片文件进了版本库也没法对比,别人改动之后你根本不知道哪里变了。

后来我自研了一个代号叫diagram-design的文本化图表设计小工具,把“画图”变成“写图”:你只需要用一段描述性的文本声明节点和连线,剩下的布局、渲染、导出全部交给程序。这篇文章就把我从需求分析、技术选型到核心实现、实战用法、排坑经验的完整过程拆开讲一遍,适合日常需要维护大量图表的研发、技术文档维护者,也适合对“可编程文档”这类工作流感兴趣的朋友参考。

1. 为什么自己动手做一个图表设计工具

1.1 工作中遇到的图表设计痛点

先说说我最初是怎么被逼到想自研工具的。日常工作中,需要图表的场景远比想象中多:

  • 架构图:服务之间的调用关系、数据流向、部署分层。
  • 流程图:业务审批链路、异常处理分支、状态机流转。
  • 时序图:接口调用顺序、消息队列的生产消费关系。
  • ER 图:数据模型设计、表格关系梳理。

这些图的特点是:结构大于审美、变化频繁、需要多人协作维护。而用传统画图工具时,每次需求变更都意味着手动拖动、对齐、重调样式。更麻烦的是,一旦图被导出成图片,原始源文件常常散落在个人电脑里,换一个人维护就找不到可编辑的源文件。这种“图已定型、源已丢失”的情况,我碰到不止一次。

还有一个隐性成本是风格不统一。团队里每个人都按自己的习惯配色、选线型,同一套系统在不同文档里画出来长得完全不像一个体系。代码有 lint 和格式化约束,图却没有任何机制保证风格一致。

1.2 核心设计理念:文本即图表

diagram-design最初的设计目标非常简单:用文本作为图表的唯一源文件。图里有什么节点、谁和谁相连、节点属于哪个分组、用什么样式,全部用一段结构化文本表达。画完图之后,源文件入库、进版本管理、可 diff、可 review、可自动生成。

这套理念本质上和“代码即基础设施”同源:把图表当作代码来管理。文本方案的天然优势有:

  • 可追溯:每一处改动都能在版本历史里看到。
  • 可协作:多人改同一个文件,出现冲突时能像代码冲突一样解决。
  • 可自动化:可以从接口定义、配置清单自动生成图,避免手工维护。
  • 可注释:可以在图源文件里写维护说明、标注注意事项,这些不会渲染到最终图里。

与其说我在写工具,不如说我在建立一套“图形化思维的文本表达规范”。

1.3 方案选型时对比过的三类路线

动手之前,我认真梳理过市面上主流的做图方式,大致分成三类:

方案类型代表性方向优点硬伤
拖拽式画图工具通用绘图软件上手快,所见即所得源文件私有、样式难统一、自动化能力弱
白板式协作工具自由画布类产品构思随意,适合头脑风暴结构不规范,难转成正式文档
文本化图表方案DSL 描述语言加渲染引擎可版本管理、可自动化、可复用有学习成本,布局可控性弱一些

对比之后结论很清楚:对“长期维护、频繁变更、多人协作”的文档型图表,文本化方案是唯一能把成本压下来的路线。拖拽式和白板式适合一次性表达和早期构思,不适合作为长期资产沉淀。diagram-design选型的核心逻辑,就是把图表的“描述层”和“渲染层”彻底分离。

2. 图表设计的技术骨架:从语法到布局再到渲染

2.1 DSL 语法怎么设计

一个文本化图表工具最核心的部分就是它的描述语言。我在设计diagram-design语法时遵循三个原则:

  1. 最小关键字:能少一个符号就少一个,让使用者把注意力放在内容上。
  2. 结构优先于样式:默认先描述节点和关系,样式作为可选项。
  3. 可渐进式增强:简单图几行写完,复杂图也能通过分组、样式声明表达清楚。

一段最基本的语法长这样:

订单服务 支付服务 订单服务 -> 支付服务

第一行声明一个名为“订单服务”的节点,第二行声明“支付服务”,第三行声明一条从前者指向后者的连接线。没有多余的括号、分号、引号,所见即所得。

当图复杂了,需要表达分组和层级,语法这样扩展:

订单域 { 订单服务 订单数据库 订单服务 -> 订单数据库 } 用户域 { 用户服务 用户数据库 } 订单服务 -> 用户服务

花括号表示一个容器分组,容器里可以嵌套节点、子容器,也可以定义内部连线。这个设计灵感来自文件目录结构的直觉——图里的分组,本质上就是命名空间的视觉表现。通过缩进和花括号,我不用额外引入“框、层、泳道”等概念,用户已经天然理解目录的层级关系。

样式声明则用独立的样式块:

订单服务 { style: { fill: "#e8f0fe" stroke: "#1a73e8" } } 订单服务 -> 支付服务 { style: { stroke-dash: 4 } }

语法设计阶段我有一个明确的禁忌:不要让语法承担过多职责。有不少 DSL 工具喜欢把布局参数、字体大小、坐标偏移全部塞进语法里,表面看功能强大,实际用起来写出来的图源又臭又长,维护性直线下降。diagram-design的做法是:语法只描述结构和基本样式,布局、间距等渲染参数统一放到配置文件和主题里控制。

2.2 布局引擎的取舍与实现

文本画图最大的难点不在解析,而在自动布局。SVG 渲染本身不难,难的是把“谁连谁”变成一张让人看着舒服的坐标图。

布局引擎我分三档实现:

  • 层次布局:适用于流程图、架构图。算法会把所有节点按依赖关系分层,再在同一层内排序,最后消除连线交叉。
  • 力导向布局:适用于关系网络图、知识图谱。通过模拟物理作用力(节点之间的斥力、连线的拉力)迭代出分布坐标。
  • 网格布局:适用于 ER 图、表格类图。按固定行数列数排布,保证整齐划一。

diagram-design默认对不同类型的图自动选择合适的布局策略:方向性强的图走层次布局,关系杂糅的图走力导向,结构规整的图走网格。

布局引擎里最让我头疼的问题是连线交叉。层次布局中,如果同一层节点顺序排得不好,交叉线会非常难看。常规解法是先按“重心”方法迭代排序,再通过“冒泡”方式尝试消除局部交叉,这个过程本质上是一个优化问题。我实现的策略是:

  1. 为每个节点计算一个初始权重(基于和相邻层的连接数量)。
  2. 按权重排序,把连接多的节点尽量排在中间。
  3. 反复交换相邻节点,若交叉数减少则保留交换。
  4. 迭代若干轮后输出坐标。

实际效果是:大多数情况下交叉数能控制在可接受范围内,极端情况再靠手动 hint 微调。

2.3 渲染链路:从文本到图片

整个渲染链路是标准的分阶段流水线:

DSL 文本 -> 词法分析 -> 语法解析 -> 图数据模型 -> 布局计算 -> 画布描述 -> 导出

每一步的输入输出都明确,方便单独测试和替换。我刻意把“图数据模型”设计成中间协议,不绑定任何渲染实现。这样以后如果想增加 Canvas 渲染、PDF 导出或者交互式网页预览,只需要在“画布描述”阶段做适配,不需要动前面的解析和布局。

为什么最终选择 SVG 作为默认输出格式?有三个原因:

  • 可缩放不模糊:矢量格式在任何分辨率下都清晰。
  • 文本可搜索可复制:嵌入网页后用户可以选中图中的文字,这对文档体验帮助很大。
  • 样式可控性强:可以用 CSS 变量、主题文件统一控制颜色和字体。

同时我做了 PNG 导出作为备用方案,用于需要位图的场景。

2.4 主题系统与可扩展性

配色和样式是图的“脸面”,但也是最容易失控的地方。diagram-design引入主题系统后,样式管理变成了纯粹的配置工作:

theme: { background: "#ffffff" node: { fill: "#f8f9fa" stroke: "#ced4da" radius: 6 font: "sans-serif" } line: { color: "#adb5bd" width: 1.5 } }

主题文件一旦定义,所有图共用统一视觉语言。新项目接手时不需要“照着以前的图猜配色”,所有图源文件里没有一处硬编码的色值,全部走主题变量。这一点对于团队规范的价值,比功能本身还大。

3. 从零搭起diagram-design的实操过程

3.1 工程结构与核心模块划分

我按单一职责原则把工程拆成了几个清晰模块:

diagram-design/ ├── src/ │ ├── core/ # 核心逻辑 │ │ ├── lexer.ts # 词法分析 │ │ ├── parser.ts # 语法解析 │ │ ├── model.ts # 图数据模型 │ │ ├── layout/ # 布局算法 │ │ ├── render/ # 画布描述生成 │ │ └── export/ # SVG / PNG 输出 │ ├── cli.ts # 命令行入口 │ ├── config.ts # 主题与全局配置 │ └── utils/ ├── themes/ # 内置主题 ├── examples/ # 示例图源 └── tests/ # 单元测试与快照测试

模块划分的决策点在于:所有模块之间通过数据模型通信,不互相引用内部实现。词法/语法解析产出的DiagramModel,布局引擎消费DiagramModel产出带坐标的LayoutModel,渲染阶段只读LayoutModel。这样任何一个阶段都可以独立替换,也为后续做交互式编辑器留好了接口。

3.2 语法解析器的实现要点

解析器是整个工具最容易出错的部分。我实现的思路是:先用词法分析器把文本切成 token 流,再用一个按行扫描的状态机完成语法解析。

核心解析逻辑可以概括为:

function parseLine(line: string, ctx: ParseContext) { const trimmed = line.trim(); if (trimmed === "" || trimmed.startsWith("//")) return; // 容器结束 if (trimmed === "}") { ctx.stack.pop(); return; } // 普通节点声明 const isContainer = trimmed.includes("{"); if (isContainer) { const name = trimmed.replace("{", "").trim(); const node = createContainer(name, ctx.currentGroup()); ctx.stack.push(node); return; } // 连线:A -> B const arrowIndex = trimmed.indexOf("->"); if (arrowIndex >= 0) { const from = trimmed.slice(0, arrowIndex).trim(); const to = trimmed.slice(arrowIndex + 2).trim(); createEdge(from, to, ctx.currentGroup()); return; } // 普通节点 createNode(trimmed, ctx.currentGroup()); }

解析时维护一个容器栈ctx.stack,遇到{就压栈,遇到}就弹栈,所有节点都属于栈顶容器。这个实现简单可靠,但有个边界情况必须处理好:花括号出现在节点名旁边和单独成行,两种写法都要兼容。最开始我只支持单独成行,结果用户写出服务 {这种形式就解析失败,后来增加了行尾{的处理逻辑才适配了常见习惯。

错误提示对开发体验影响极大。我在语法出错时输出行号、列号、期望的 token 和实际的内容,并且让 CLI 进程返回非零退出码,这样在 CI 环境里图源文件写错了可以立刻被流水线发现。

3.3 一个最小例子的完整流转

我用一个最小例子演示从文本到图片的完整流转过程。假设源文件simple.dd内容为:

登录服务 用户服务 账号数据库 登录服务 -> 用户服务 用户服务 -> 账号数据库

执行命令后,内部依次发生:

  1. 词法分析:将文本拆成 token,识别出节点名、箭头符号、缩进上下文。
  2. 语法解析:构建出包含三个节点、两条边的DiagramModel。
  3. 布局计算:识别出这是一个“线性依赖”结构,自动采用层次布局,将登录服务排在第一行,用户服务在第二行,账号数据库在第三行,连线自动生成折线路径。
  4. 画布渲染:根据主题配置生成 SVG 结构,节点是圆角矩形,内部居中显示文字,连线带箭头标记。
  5. 导出文件:将 SVG 写入指定路径,完成整个流程。

整个流程在本地跑一遍只需几十毫秒,作为文档构建链路的一个环节完全够用。

3.4 命令行接口与批量导出配置

命令行是日常使用的主入口,diagram-design的 CLI 设计参考了常见编译器的习惯:

diagram-design input/simple.dd -o output/simple.svg --theme light diagram-design input/simple.dd -o output/simple.png --scale 2

需要批量导出整个目录时,我封装了一个简单的批量模式:

diagram-design docs/diagrams/ -o docs/assets/ --format svg

这个命令会递归扫描源文件目录,把所有.dd文件编译成同名 SVG,输出到目标目录。配合文档站点的构建脚本,只要把这一步挂在编译流程前,就能做到“改图源 -> 刷新文档 -> 图表自动更新”。我在实际项目里甚至实现了更激进的做法:文档构建时不提交生成好的图片,而是在 CI 阶段实时渲染,从源到产物全程自动化。

4. 实战用法与图表设计规范

4.1 四类常用图的具体写法

工具做完后,我自己持续用了大半年,逐步沉淀出几类高频图的写图模板。

架构图是使用频率最高的类型。典型写法是容器套服务,服务连依赖:

用户端 { 前端应用 } 服务端 { 网关服务 订单服务 库存服务 用户服务 } 数据层 { 订单库 库存库 用户库 } 用户端 -> 服务端 前端应用 -> 网关服务 网关服务 -> 订单服务 订单服务 -> 库存服务 订单服务 -> 用户服务 用户服务 -> 用户库 库存服务 -> 库存库 用户服务 -> 用户库

流程图强调分支和状态转换,我会把判断条件直接写进节点文本:

开始 下单请求 库存校验 库存充足 库存不足 开始 -> 下单请求 下单请求 -> 库存校验 库存校验 -> 库存充足 库存校验 -> 库存不足 库存充足 -> 创建订单 库存不足 -> 返回错误

时序图在纯节点连线模式下需要一定的约定:参与者用节点表示,消息用箭头线表示,顺序靠布局方向体现。我的习惯是把坐标方向固定为自上而下,消息注释写在节点文本里:

客户端 网关 订单服务 客户端 -> 网关: 创建订单 网关 -> 订单服务: 校验并落库 订单服务 -> 网关: 返回单号 网关 -> 客户端: 返回成功

ER 图则可以借助容器节点展示实体字段,再通过连线表达外键关系:

用户 { id name email } 订单 { id user_id amount } 订单 -> 用户

4.2 可直接抄作业的设计规范

用文本画图很容易陷入“能画出就行”的误区,完全不考虑可读性。我在实践中总结出一套默认规范,新图默认遵守,效果稳定:

  • 配色语义化:主链路用品牌主色,外部依赖用灰色,异常/拒绝路径用警示色,全局只用这三个色系,绝不追加第四种强调色。
  • 连线类型:默认实线表示强依赖,虚线表示异步或弱依赖,粗线表示关键链路。一张图里实线应占绝对多数,超过两成是虚线就该反思画的连接是否冗余。
  • 节点命名:名称控制在 6 个字以内,超过就换行缩写。图上的文字不是用来读文章的,是用于定位的,详细解释放进正文。
  • 方向统一:流程类图严格从上到下,架构类图从左到右分层,禁止同一张图里混用方向。
  • 规模红线:单张图超过 20 个节点就应该拆图。拆成多张图后,通过“总览图 + 局部图”的方式组织,阅读体验远好于一张巨型图。

4.3 与文档工作流的结合

文本化图表最大的收益不在“画图那一刻”,而在后续的维护链路。我把所有图源文件放在文档仓库的diagrams/目录下,与文档正文一起提交。每次文档变更,图和正文同步走评审,review 的时候可以直接看文本 diff,精确到某个节点名被改动。

有个细节很多人忽略:图片产物是否入库要想清楚。我的方案是产物不入库,构建时实时生成。这样避免了两份文件失同步的问题,也能保证线上文档展示的永远是当前图源的最新结果。如果构建环境没法装渲染工具,退一步的选择是入库但写入 CI 检查逻辑,每次提交都重新渲染比对,发现不一致就打回。

5. 踩过的坑与排查技巧实录

5.1 中文字体与渲染乱码

最早遇到的问题是中文节点名在部分环境里渲染成方块。排查半天发现不是解析的问题,而是目标环境缺少对应字体。后来我在渲染时显式声明字体栈,把系统中文字体优先排在前面,同时允许通过主题配置指定字体文件路径,问题才算解决。

经验是:跨环境渲染一定要显式声明字体,绝不能依赖运行环境的默认配置。另外源文件统一保存为 UTF-8 编码,避免 Windows 环境下默认 GBK 编码导致的乱码。我在 CLI 里增加了编码自动检测和转换,遇到非 UTF-8 文件会提示用户转码。

5.2 容器重叠与连线穿节点

自动布局在复杂容器图中可能会产生两种视觉问题:容器与容器重叠、连线穿过无关节点。这类问题没有一劳永逸的解法,我的处理是:

  1. 全局加大节点之间的距离参数,牺牲一点紧凑度换取可读性。
  2. 容器节点预留足够的内边距,避免内部节点贴边。
  3. 提供手动 hint 机制,允许在文本里声明节点坐标偏移:
订单服务 { layout: { x: 120 y: 80 } }

坐标 hint 只作为布局引擎的“建议值”,最终位置仍由引擎根据约束做小幅度调整,这样既保留手动修正能力,又不会因为一个人的 hack 破坏整体布局逻辑。

5.3 大图性能与文件体积

当节点数量超过百个,Svg 导出文件体积会快速膨胀,浏览器渲染也开始吃力。我实测下来,问题通常出在过度细分的连接路径上。有些跨多层容器的连线会生成大量折线点,每一段都带上完整的坐标信息,体积就上去了。

优化手段有三板斧:一是合并同路径的多条连线,二是对长连线使用直线而非折线,三是导出去掉所有空白分组和冗余样式属性。配合内容层面的约束——单图尽量控制在 20 节点以内,性能问题基本不会碰到。

5.4 语法解析的边界情况

解析器跑一段时间后暴露了不少边界问题:

  • 注释里包含了->,结果被误判成连线。解决方式是注释优先过滤。
  • 节点名称包含空格时,订单 服务会被拆成两个节点。解决方式是支持双引号声明节点名:"订单 服务"。
  • 空行和纯注释文件被用户执行导出,原来会报一个很迷惑的错误,后来统一降级为“生成空图”。

这些边界问题每个看起来都不起眼,但正是它们决定了工具的手感。我给解析器补了一大批边界用例作为单测,确保后续改动不会引入回归。实测下来,单测覆盖边界情况,比覆盖功能主流程价值更大。

使用diagram-design这么久,我最深的体会是:图表设计和代码设计共享同一套底层逻辑——重视源文件、做好分层抽象、强调可维护性。当你把图当作代码来管理,很多旧问题会自然消失:风格不统一靠主题解决,源文件丢失靠入库解决,自动化困难靠构建脚本解决。这个过程带给我的不仅是效率提升,更是写文档时心态的变化:不用再担心“画好的图又要改”,因为每一张图都随时可以低成本地重新生成。最后给同样想自建工具的朋友一个建议:如果只想快速解决问题,直接找一款成熟的文本化图表工具就好;如果你也想像我一样彻底掌控从语法到布局的每个环节,那么把布局引擎做成可插拔的、保持模块边界清晰,会是一个让你长期受益的决定。

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

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

立即咨询