☰
用声明式配置自动生成SVG架构图:diagram-design实践
2026/10/15 8:15:21 网站建设 项目流程

把“画图”这件事,从天天手动拖拽、改到崩溃的体力活,变成一份写配置、跑脚本、自动输出的工程化流程——这就是我最近在做的“diagram-design”项目。

简单说,diagram-design是一个用声明式配置描述架构图、流程图、拓扑图,再通过渲染工具自动生成SVG图片的设计方案。它最核心的价值不是“画得好看”,而是把图表的逻辑从视觉绘制中剥离出来:图里有哪些节点、节点之间什么关系、整体是分层还是平级,这些信息是结构化数据,而不是某个画布上的像素堆叠。你改需求时不需要用鼠标小心翼翼地挪方块,只需要改几行JSON配置,重新渲染一次即可。

这套方案特别适合两类人:一类是频繁在文档里插架构图、流程图的开发者或技术写作者,另一类是需要在不同项目中反复复用多种图表的团队。它解决的核心痛点是如何让图表内容可版本化、可自动生成、可嵌入持续交付流程。如果你已经受够了手动对齐连线和截图接缝,这篇文章值得看完。

我会按自己的实际踩坑顺序,把一个图表设计工具从目标拆解、核心模块划分,到渲染引擎实现、常见问题排查,完整走一遍。内容偏实践,部分细节基于我当时设计方案的常见做法补全,你完全可以照着落地一份自己的实现。

1. 内容整体设计与思路拆解

1.1 为什么放弃拖拽式画图

先说说这个项目出现的背景。最初我在某实验室做一个跨平台组件的架构梳理,文档里要放的系统关系图变得越来越复杂。最初的方案是用绘图软件手动绘制,但很快发现两个问题:第一,图只有图片格式,没法做diff,没法纳入版本管理,团队里谁改了配置别人根本不知道;第二,一旦系统结构调整,比如加了一层网关、拆了一个服务,整张图的对齐、连线、文案位置全部要重新拖一遍,修一张图能消耗掉多半天。

后来我尝试直接在文档里手写SVG,虽然能保证每个元素可控,但可维护性极差。一个超过50个节点的系统图,SVG源码上千行,改一个坐标要找半天。这让我重新思考:画图这个动作,本质上应该是对“数据关系”的编排,而不是对“坐标和像素”的编排。如果能把图表的语义信息结构化,让程序负责布局,问题就从“每次手工画一张图”变成了“改一行配置重新生成”。

diagram-design就是在这个思路下立项的。它的目标非常明确:输入一份描述图内容的JSON或YAML配置,输出一张规范的SVG图,并尽可能保证布局稳定、风格统一、支持嵌套分组。

1.2 方案选型的关键取舍

技术选型是第一步,也是最容易拍脑袋的部分。当时我对比了三条路线:

第一种,直接用成熟的开源图表库,比如常见的关系图库、流程图库。优点是上手快,内置布局算法和交互能力;缺点是对于带泳道、嵌套分组、特定图标体系的架构图,定制成本很高,而且这类库通常偏交互展示,不太适合批量导出静态图。

第二种,采用前端自动布局加SVG导出的组合:在浏览器里做自动布局,再用canvas或SVG API导出。这个方案的灵活性确实好,但把“画图工具”绑定到了浏览器环境,后续要用命令行批处理或接入CI就麻烦一些。

第三种,也是我最终选择的路线:自研一个轻量级的渲染框架,配置格式用JSON,核心流程是解析配置、构建语义模型、计算布局、生成SVG字符串。这样所有步骤都是纯文本处理,可以轻松地做成命令行工具,也方便嵌入到文档生成流程里。这是一个基于实际经验的合理补充——如果你从一开始就要支持命令行和自动生成,这种设计会让你少走很多弯路。

为什么选JSON而不是XML或YAML?坦白说XML写起来太啰嗦,嵌套标签阅读成本很高;YAML虽然简洁,但解析库版本差异容易带来一些边界问题,比如特殊字符缩进;JSON生态成熟、序列化反序列化几乎没有歧义,还天然支持JSON Schema做配置校验,这是它在工程实践中的决定性优势。

整个项目的核心思想概括起来就是:把图表当作一种结构化数据来设计,布局交给算法,样式交给主题系统,人类只负责描述关系——这也正是diagram-design在后续扩展中一直坚持的分层原则。

2. 核心细节解析与实操要点

2.1 基础数据结构与配置规范

任何自动绘图系统都需要先明确“图由什么组成”。diagram-design的数据模型围绕四类元素来设计:节点、分组、连接线、标注。下面是我当时第一版定义的核心结构,实际的字段说明基于常见实践补全:

{ "type": "architecture", "title": "订单系统架构", "nodes": [ { "id": "gateway", "label": "API网关", "group": "接入层" }, { "id": "order-service", "label": "订单服务", "group": "核心层" } ], "groups": [ { "id": "接入层", "label": "接入层", "direction": "horizontal" }, { "id": "核心层", "label": "核心服务层", "direction": "vertical" } ], "edges": [ { "source": "gateway", "target": "order-service", "label": "HTTP" } ] }

这里有几个值得展开的设计细节。

nodes里的group属性,决定了节点挂在哪个分组下面。分组可以嵌套,从根级分组到子分组本质上是一棵多叉树。这一点很关键:后续的布局引擎会递归遍历这棵树,先把子分组排好,再把子分组当作一个大“块”参与父分组的排列。如果不支持嵌套分组,画复杂架构图时就会遇到“画完系统间关系之后,发现子系统内部的层次结构完全没法体现”的尴尬。

direction字段用来控制组内元素的排布方向。横向分组表示组内的节点从左往右排列,适合展示一条调用链路;纵向分组表示节点从上往下排列,适合展示分层架构。这个字段的引入是为了让布局引擎能区分“层级从上到下”和“组件从左到右”这两种不同的视觉语义。

edges里的连接线字段,必须携带source和target两个节点ID。为了支持关系语义的扩展,实际配置中还增加了一个可选的relation字段,比如“依赖”“调用”“数据流”等等。关系类型不同,渲染出来的线条颜色和样式也不同——这个设计在画架构图时非常实用,能让人一眼看出哪些是网络调用、哪些是异步消息。

2.2 节点ID与样式继承机制

节点ID是整个数据结构的锚点,所有连接线、分组归属、坐标定位都依赖ID来引用。实际项目中,ID的定义建议遵循一个统一规范:要么用分层路径,比如core.service.order,要么用短横线命名,比如order-service。切忌直接使用中文或带空格的字符串,否则在生成SVG时会涉及URL转义问题,处理起来很麻烦。

样式的实现是最容易写坏的部分。如果每个节点都显式指定填充色、边框色、字体大小,配置体积会爆炸,而且视觉风格极难保持统一。diagram-design采用的方案是三级样式继承:

优先级样式来源说明
最高节点上的显式样式个别节点需要特殊颜色或边框时使用
中节点所属类型的默认样式比如服务节点统一用蓝色,数据库节点统一用橙色
最低全局主题配置定义画布背景、默认字体、默认边框宽度

这个继承顺序是我在实际调试中反复调出来的。曾有一版设计把节点显式样式的优先级放得过高,导致使用者复制粘贴了一堆带颜色的节点之后,整体风格变得七零八落。后来调整为“显式高于类型,类型高于主题”之后,至少能保证大多数节点只要选了类型,颜色就自动统一,只有需要强调的节点才会额外加样式。

三条关键实操经验:

  • 节点类型用nodeType字段而不是style字段来声明,这样样式系统知道去查哪个类型模板,而不是解析一个嵌套对象。
  • 主题配置只允许改全局变量,比如画布背景色、连线默认颜色、字体族,不要允许在主题里定义节点级样式,否则维护主题时会魔怔。
  • 公共的样式变量统一使用CSS变量形式注入到SVG根节点,这样后续可以方便地做在线主题切换。

3. 布局引擎原理与实现方案

3.1 三类布局策略的适用场景

布局是整个diagram-design最有挑战、也最见功底的部分。最开始我试图用一套通用算法解决所有图表的排布问题,结果很快就发现:同一套布局逻辑难以同时满足分层架构图、环形拓扑图和跨部门流程图的需求。

最终方案拆成了三类布局器,按配置中的layout字段自动选择:

第一种是层级布局,适用于有明确上下游关系的分层系统图。核心算法是经典的层次布局思路:先对节点做拓扑排序,按层分组,然后在每一层内调整节点顺序,尽量减少连线交叉。

第二种是网格布局,适用于并列展示的服务单元、模块清单。节点按行列整齐排开,同时支持分组作为“单元格”跨行或跨列。

第三种是自由布局,适用于对位置有特殊要求的场景。但即使自由布局,diagram-design仍然支持“防重叠约束”——渲染前会做一次重叠检测,如果两个节点矩形区域有交集,会自动做微调错开,避免一张图上出现大面积的视觉干扰。

这里有个重要观点:不要试图让布局引擎自动解决所有定位问题。对于有明确约束的图,在配置里指定layout类型、按布局器规则编排已有的语义结构,远比让全自动算法模拟人脑来得稳定。

3.2 层级布局的坐标计算过程

具体看层级布局的运算逻辑。假设有一个这样的配置:A节点依赖B和C,B依赖D,C依赖D。布局步骤是:

第一步,确定层级关系。A在第0层,B和C在第1层,D在第2层。这里“层”指的是拓扑排序的结果,如果两个节点之间没有直接依赖关系,则它们可能被排到同一层。

第二步,计算每个节点的尺寸。节点尺寸由标签文字的长度决定。这一步不能简单按字数估算,要靠字体渲染测量。我当时用了一种折中的方法:默认每个中文字符宽度取字号大小,英文字符取半角宽度,整体乘一个1.15系数,再加上左右内边距。这样估算出来的节点宽度在绝大多数场景下误差在几像素以内,已经够用了。

第三步,确定同一层内节点的先后顺序。两层以上的图很容易出现连线交叉,为了减少交叉,我实现了一个简单的启发式交换算法:从最底层开始,相邻层之间反复交换同一层节点的顺序,每交换一次就统计连线交叉数,直到交叉数不再减少为止。这个算法不是最优解,但对绝大多数架构图来说,能减少80%的交叉,效果够用。

第四步,为所有节点分配坐标。横坐标依照层内顺序和节点宽度算出,纵坐标依照层序号递增。坐标算完后,连接线的路径生成就比较简单了,两个锚点之间的连线默认走正交直角路径,中间按曼哈顿连线规则计算拐点。

计算坐标时一个重要细节是分组嵌套。子分组内节点的坐标是相对坐标,子分组作为一个整体参与父分组的布局时,再根据子分组在父分组中的位置加上偏移量。这种递归式设计避免了全局坐标计算的复杂性,实现起来稳定得多。

3.3 连线路径与避让处理

连线部分我踩过的坑最多。最早期实现直接用SVG的<line>画直线,节点少的时候没问题,节点多了以后,线条穿过其他节点矩形的情况非常常见,图面立刻混成一团。

改进方案是采用正交路径,即一条连线只包含水平线段和垂直线段,俗称“走横竖线”。SVG里用<path>标签的d属性来描述。比如从A节点底部到B节点顶部,典型路径是:

M a.x a.y L a.x mid.y L b.x mid.y L b.x b.y

这个路径的关键逻辑是,先垂直向下,再水平移动,最后垂直向下到达目标节点中心。因为路径是整体呈直角折线,视觉上比斜线更容易追踪。

避让逻辑更复杂一些。我做了一个简化版的线段交叉检测:在生成连线路径之前,把所有节点矩形记录下来,当路径的某个拐点落在某个节点矩形内部时,就将这段路径向外偏移一个预设值,形成绕行效果。如果同时有多条线都要绕过同一个节点,系统会计算一个“干扰等级”,让每条线的绕行距离错开几个像素,避免绕行线段贴在一起。

这段代码的实现细节我以后单独写文分享,这里提醒一点:连线避让算法的复杂度会随节点数量增加而快速上升,50个节点以内可以接受,超过100个节点时建议考虑用成熟的图布局库来生成路径数据,而不是自己在SVG层反复计算。

4. 实操过程与关键环节实现

4.1 从JSON配置到SVG的完整流程

diagram-design的渲染主流程分五步,我用代码来还原整个处理管线。以下代码基于我当时项目的Node.js实现,结构完全可复用:

function render(config) { // 第一步:对输入的JS对象做结构校验,检查nodes/edges/groups的基础字段 const validated = validateConfig(config); // 第二步:将配置解析为内部语义模型,即带有父子关系的节点树 const model = buildModel(validated); // 第三步:依据model的根配置选择布局器,计算所有节点的坐标 const layoutResult = layout(model); // 第四步:根据坐标、样式树和连接关系,生成SVG字符串 const svg = generateSvg(layoutResult, config.theme); // 第五步:后处理,格式化SVG字符串并输出 return formatSvg(svg); }

validateConfig这一步非常值得花时间。你输入了一个不存在的target节点ID,布局引擎计算时会出现坐标为空,最终SVG里出现一堆残缺的连线。好的做法是用结构的Schema做校验,在渲染前把问题暴露出来:节点ID不能重复、连线两端引用的节点必须存在、分组ID不能与节点ID重名。这三个规则可以挡掉90%以上的输入错误。

buildModel的核心任务是建立“节点-分组”的层级关系。这一步处理的是配置里节点的group字段与分组定义之间的引用关系。当分组嵌套关系建立后,渲染器才能知道哪些节点属于同一个可视容器。

layout根据配置的layout字段分派给不同的布局器。层级布局和网格布局的输入都是语义模型节点树,内部处理方式差异较大,但对外暴露的接口是统一的:输入一颗节点树,输出一个Map,里面存每个节点ID对应的坐标与尺寸。

generateSvg相当于把布局结果翻译成SVG标签。这里最关键的是字符串拼接时的转义处理:标签文字里如果含有<、>、&这类字符,不转义会导致SVG解析错误。我会在主流程末尾统一对文本做XML转义,这个习惯强烈建议你直接抄。

4.2 一个可落地的配置示例

空讲流程太抽象,这里给出一份完整的配置,描述一个带三个层级的微服务架构图:

{ "layout": "hierarchical", "title": "商品服务架构图", "nodeTypes": { "gateway": { "color": "#2563eb", "shape": "pill" }, "service": { "color": "#16a34a", "shape": "rect" }, "database": { "color": "#ea580c", "shape": "cylinder" } }, "nodes": [ { "id": "gw", "label": "API网关", "nodeType": "gateway" }, { "id": "product", "label": "商品服务", "nodeType": "service", "group": "svc" }, { "id": "inventory", "label": "库存服务", "nodeType": "service", "group": "svc" }, { "id": "order", "label": "订单服务", "nodeType": "service", "group": "svc" }, { "id": "db-main", "label": "主数据库", "nodeType": "database", "group": "db" }, { "id": "db-cache", "label": "缓存", "nodeType": "database", "group": "db" } ], "groups": [ { "id": "svc", "label": "服务层", "direction": "horizontal" }, { "id": "db", "label": "存储层", "direction": "horizontal" } ], "edges": [ { "source": "gw", "target": "product", "label": "HTTP" }, { "source": "gw", "target": "inventory", "label": "HTTP" }, { "source": "gw", "target": "order", "label": "HTTP" }, { "source": "product", "target": "db-main", "label": "SQL" }, { "source": "product", "target": "db-cache", "label": "Redis" }, { "source": "inventory", "target": "db-main", "label": "SQL" }, { "source": "order", "target": "db-main", "label": "SQL" } ] }

渲染过程是这个样子:第一步,nodeTypes中定义了三类节点模板,渲染时节点会优先按nodeType查找样式;第二步,groups里的“服务层”和“存储层”会把相关节点纳入分组容器;第三步,层级布局对整张图做拓扑排序,网关在第0层,服务在第1层,数据库在第2层,然后计算好每个节点的矩形坐标;第四步,生成SVG,网关、服务层、存储层自上而下排列,分组的外边框会包裹住所有子节点。

这份配置对应的渲染结果,就是一张规范的分层架构图:有三层结构、三类不同颜色的节点、若干条带标注的连线。从写配置到见图像,时间在一秒以内。

4.3 导出与嵌入能力的实现

diagram-design的产出默认是SVG字符串。SVG的好处在它是文本格式,可以直接嵌入Markdown、HTML,也可以后续转换成PNG图片。

导出PNG我当时接了两条路径。第一条是运行时导出,在浏览器里把SVG字符串放入Image对象,再绘制到Canvas,最后调用toDataURL()导出。第二条是命令行导出,我封装了一个脚本,调用某个图形库的SVG渲染能力,把生成的SVG转成PNG。后者适合批量处理。

如果你在文档系统里使用,我建议直接内嵌SVG而不是转PNG。SVG有无限缩放优势,而且支持CSS变量,后续换主题只需改一段CSS,不用重新渲染图片。唯一的坑是部分老旧编辑器不支持直接在Markdown中渲染带样式的SVG,这种情况就老老实实输出PNG。

5. 常见问题与排查技巧实录

5.1 节点重叠与连线穿越

项目开发过程中我最常遇到的问题就是“明明配置关系很简单,渲染出来却乱成一团”。

大概有三分之一的情况是布局器选择错误。比如一个配置里既有严格的层次关系,又希望部分节点横向并排展示,用了"layout": "hierarchical"后,同一层的节点会被分配在同一水平线上,如果节点数量多,横向空间不够,就会上下行都挤在一起。解决办法是把这类图拆成多个分组,每个分组内设置不同的direction方向。

还有一类问题是连线穿越节点。即使主路径避让做得很完善,仍可能出现两条连线在拐角处交叉。排查的时候先把edge的sweep参数打开,开启轨迹调试模式,看每条连线的实际路径点,再手动微调绕行偏移量。别指望纯算法能处理所有交叉问题,必要的手工干预在架构图场景下是合理的。

5.2 中文字体宽度估算偏差

节点宽度计算时,中文字体测量不准会导致文字溢出边框或框太宽。最早期我用一个固定系数来估算,结果在部分渲染环境下“两个字符+内边距”算出来的宽度恰好比实际略小一点,标签边缘直接被裁掉。

后来改为在启动时预加载一次字体测量,把当前环境下的常见字符宽度缓存起来。如果是命令行环境,缺少字体渲染上下文,则用保守系数:每个中文字符按fontSize * 1.05计算,每个ASCII字符按fontSize * 0.55计算,整体再加16像素水平内边距。这个配置我一直在用,基本没有出现文字被截断的情况。

5.3 分组框与内部节点的间距

另一个高频问题:分组框的边框和内部第一个节点之间几乎没有间隙,视觉上非常挤。原因是布局器只计算了节点的坐标,没有考虑分组容器本身的内边距。解决方式是在计算分组尺寸时,额外加上paddingTop、paddingBottom、paddingLeft、paddingRight。这组值在主题配置里统一指定,推荐默认值16像素。

5.4 常见问题速查表

现象可能原因解决方案
节点重叠严重布局类型选择不适合当前图结构更换布局器,或拆分为多个分组分别设定方向
连线穿过节点路径避让算法关闭或偏移量不足检查避让开关,增大绕行偏移量
中文文字被截断字体宽度估算不足使用保守系数,加大水平内边距
分组框内节点贴边分组容器内边距未设置在主题中配置分组内边距
引用的节点ID不存在配置校验不严开启渲染前的校验规则
输出SVG在浏览器中样式错乱主题CSS变量未正确注入检查SVG根节点是否包含样式变量定义

6. 项目扩展与二次开发建议

6.1 接入持续集成流程

这个项目最成功的应用场景,是把它接入文档自动生成流程。以前更新系统结构图,需要有人手动处理图片。现在只要在仓库中改JSON配置,CI检测到变更后自动跑一次渲染脚本,把SVG和PNG输出到指定目录,文档系统直接引用,全程不需要人工干预。

接入CI的关键点是配置一个“图表文件变更触发任务”,任务里执行一行命令,比如:

diagram-render ./diagrams --theme=default --out=./assets/diagrams

这个命令读取./diagrams目录下的所有JSON配置,按文件名生成对应的SVG和PNG。构建脚本的退出码要非0即代表渲染失败,这样配置写错了会直接在流水线里暴露。

6.2 扩展更多图表类型

目前diagram-design支持的图类型包括架构图、流程图、分组拓扑图。如果你想扩展时序图或状态机图,核心思路是一样的:先定义新的语义模型,比如时间轴上的消息序列,再实现对应的布局器,最终生成SVG。新增一个图类型可以在一两天内完成,数据结构和渲染流程已经是现成的,不需要动框架。

6.3 反向编辑能力的设想

还有一个延伸方向值得考虑:做一个轻量级的在线编辑器,用户拖拽节点、连线,操作完成后把坐标位置和连接关系反向序列化为JSON配置。这个功能一旦实现,就同时拥有可视化编辑的直观性和配置驱动的可维护性。目前我只实现了将保存后的配置读回编辑器,反向序列化的工作量不小,但数据模型天然支持这个目标——所有坐标信息都在SVG元素上可以提取,关系信息在语义模型里也是完整的,难度主要在排版还原精度上。

我个人在实际操作中最深的体会是:任何画图工具的价值都不在于“画出来那一刻”,而在于“接下来每一次修改是否可控”。diagram-design把这种可控性建立在了结构化数据之上,使得图表可以像代码一样被维护和演进,这套思路放到静态站点生成、接口文档自动更新、乃至系统拓扑巡检等领域都有天然的延伸空间。如果你正好也在为大量重复性的绘图工作烦恼,不妨试一下这个方向。

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

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

立即咨询