☰
用代码定义架构图:diagram-design 的设计与落地实践
2026/10/17 8:44:09 网站建设 项目流程

有没有遇到过这种场景:方案评审会上,四五个人嘴里各自长出一张完全不同的架构图。产品说数据先到网关再进服务,前端说页面渲染依赖三个接口,后端一直强调他心里的那条调用链。没人说得清到底谁对,因为根本没有一张所有人都能看同一版本的图。我后来在团队里做了个内部项目,名字就叫 diagram-design。它做的事情很简单:把架构图、流程图、状态机这类图形,从手工拖拽画布,改成用结构化代码定义、再用渲染引擎自动生成。这篇文章就把 diagram-design 从设计到落地的完整思路、关键取舍和踩过的坑记录下来,希望能给正在纠结“图怎么画、怎么维护、怎么和代码同步”的人一些参考。

1. 为什么我最终选择用代码定义图表

1.1 手动画图的三个隐性成本

很多人觉得画架构图不就是拖几个框、拉几条线吗,能有多难。真在项目里长期维护过的人会明白,手工拖拽的成本不在“画出来的那一刻”,而在后续所有的修改、对齐、评审和交接。

第一个成本是版本不可控。文件放在本地,改了没存就丢失;传到聊天工具里,又被另一个人改一版再传回来。同一张架构图,一天之后会出现三四个版本,没人知道哪个是最新的。第二个成本是修改成本,加一个中间件,需要手动调整所有相关连线,拖完框还得重新对齐,稍微复杂一点的图,一次修改能花掉半小时。第三个成本是评审效率低,图里的信息全靠画图人心里默认,看的人只能靠猜,节点之间的依赖关系、请求流向、失败路径,这些关键信息在图上经常是缺失的。

diagram-design 本质上是在解决这三个成本。它把图的定义变成了一段结构化描述,图和代码存放在一起,每一次修改都有记录,评审时可以对着原始定义逐条讨论。图的生成完全由程序完成,不涉及手工对齐。

1.2 diagram-design 的定位

我给它定的定位是“面向开发者的轻量图表定义与渲染方案”,不是又一个大型绘图软件。它只解决一件事:把静态的、结构化的图做成可持续维护的产物。

做成这一件事需要三个能力:第一,声明式定义,用户用 JSON 或 YAML 描述节点、连线、分组关系,不关心坐标;第二,自动布局,程序根据关系自动计算节点位置,保证尽量不重叠、不交叉;第三,多端渲染,生成结果既能嵌入网页,也能导出成图片放进文档和幻灯片里。

这个定位明确之后,很多设计决策就顺理成章了。我没有去实现复杂的自由画布、画笔工具,也不需要模拟纸张上的随意手绘效果,所有能力都围绕“结构化图形”来组织。后来团队里有人用它来描述定时任务流程图,有人用它画系统模块关系图,还有人把部署拓扑图写成了配置,效果都超出预期。

2. 核心数据模型,一次选对就少改半年

2.1 节点、连线、分组三件套

diagram-design 的数据模型最核心的部分就是三个概念:节点、连线和分组。

节点是图里的基本元素,对应真实世界里的服务、模块、步骤或状态。连线表达节点之间的关联,可以带方向,也可以不带方向。分组则表达“这些节点属于同一个逻辑区域”,比如同一个子网、同一个模块、同一个泳道。

这三个概念的划分我纠结过很长一段时间。最初考虑过增加“端口”“锚点”“子图”这些概念,后来发现没必要。把节点、连线、分组设计成三种独立的基础元素,用户可以组合出绝大多数场景。端口属于节点的增强属性,子图其实就是分组加内部布局,基础概念越少,学习和排错成本越低。

数据格式上,我采用了嵌套结构,分组可以包含分组,节点可以挂在任意分组下。连线不直接挂在分组里,而是通过两端的节点 ID 隐式归属,这样计算布局时会简单很多。

2.2 端口:从“点对点连线”到“结构清晰”

实际项目里经常出现一个服务对接多个服务的情况,如果所有连线都从节点中心点出发,图上会变成一堆乱线。为了让连线的起点和终点落在节点边界的具体位置,我给节点增加了端口的概念。

端口就是节点上可连线的位置。比如一个数据库节点,可以把“读写连接”的端口放在上边界,把“备份连接”的端口放在下边界。连线必须引用端口的 ID,布局引擎根据端口位置计算连线的起终点坐标。

这个设计的收益在规模大的时候非常明显。某次画一个包含三十多个服务的调用关系图,如果没有端口系统,连中心点会乱成一团,有了端口之后,同一类连线可以自动归到节点的同一边,阅读体验完全不同。

2.3 数据模型与 JSON Schema

整个模型我用 JSON 承载,对外暴露的格式大致是这个样子:

{ "nodes": [ { "id": "gateway", "label": "网关服务", "group": "proxy" }, { "id": "user-svc", "label": "用户服务", "group": "backend" }, { "id": "order-svc", "label": "订单服务", "group": "backend" } ], "edges": [ { "source": "gateway", "target": "user-svc", "label": "HTTP", "bidi": true }, { "source": "gateway", "target": "order-svc", "label": "HTTP", "bidi": true } ], "groups": [ { "id": "proxy", "label": "接入层" }, { "id": "backend", "label": "业务层" } ] }

每个节点最少需要 id 和 label,连线最少需要 source 和 target,分组最少需要 id 和 label。这个 Schema 看起来简单,但支撑了一个重要能力:数据可以来自任何地方。手写者直接维护 JSON,有代码基础可以从接口动态生成,甚至可以从配置中心读取,只要最终输出符合 Schema 即可。

我当时特意写了格式校验器,任何非法数据在渲染前都会被拦截,带着清晰的错误信息提示具体缺少哪个字段。这一点在多人协作时特别重要,避免一个人写错导致所有人出图失败。

3. 布局、渲染与交互的关键取舍

3.1 布局:分层、力导向还是手写规则

自动布局是 diagram-design 里最难也最值得投入的部分。布局决定了一张图最终长什么样,也决定了用户是否愿意继续用。

我实际用到的布局策略有三种,各有各的使用场景:

布局策略适合场景典型表现
分层布局流程、管道、层级依赖上下分层,边尽量向下或向右
力导向布局关系网络、集群结构节点自动散开,连线最短化
固定坐标布局拓扑图、地图、手动精排数据里直接给坐标,程序不干预

分层布局是最常用的。它要求数据里不能有环,如果有环要先做环检测,把环内的节点单独处理。力导向布局适合拿给关系型数据用,但结果稳定性一般,同一个数据每次生成的位置可能不同,我不太推荐用在需要长期维护的正式文档中。固定坐标布局则需要数据提供坐标字段,适合完全手工控制的需求。

diagram-design 默认先走分层布局,检测到有环时自动降级或提示。布局算法内部按“源点数量优先”的原则排序,同一层内部再按序号排列,尽量减少连线跨越两个层以上,这一步对最终视觉效果影响很大。

3.2 渲染:SVG为主,Canvas为辅的理由

图形渲染可以选的技术路线很多,我最后确定的原则是:默认用 SVG 渲染,只有图特别大时才切到 Canvas,两者共用同一份布局数据。

选择 SVG 的理由很实际。第一,矢量输出,导出到任何分辨率都清晰;第二,每个节点和连线都是独立的 DOM 元素,天然支持点击、悬浮、右键菜单这些交互能力,不需要额外做命中测试;第三,CSS 可以直接控制样式,主题切换非常容易。

Canvas 对我而言只是兜底方案。节点超过一千个时,SVG 的 DOM 数量会让页面明显卡顿,Canvas 绘制则几乎没有这种压力。但 Canvas 的代价是交互和样式控制都得手写,开发成本高出一截。我在项目里做了一个阈值开关,默认节点数量超过八百时建议切 Canvas,其余情况下全走 SVG。

3.3 交互:拖拽、缩放和选中

考虑到使用场景大多是文档、评审和开发辅助,交互不需要做得太重,但拖拽、缩放、选中这三件事必须有。

拖拽分成两种模式。第一种是自由拖拽,用户可以在最终渲染结果上手动微调某个节点的位置,拖完之后把新坐标回写进数据层。第二种是吸附拖拽,在流程图场景里会猜测用户想把当前节点放到哪个分组,并提供对齐辅助线。

缩放我用的是以视口中心点为基准的缩放方式,滚轮向上放大,向下缩小。缩放到较小时自动隐藏节点里的文字,只保留边框,避免视觉噪音。选中节点时右侧会显示属性面板,可以直接改名称、颜色、所属分组,改完重新布局并渲染。

这套交互做完之后,我发现它变成了一个“半自动工具”:初始布局靠程序,精细调整靠人工。两者互相补充,使用体验比纯自动或纯手绘都顺。

3.4 导出:SVG、PNG 和嵌入场景

图最终要被人看,所以导出能力不能将就。diagram-design 导出支持三种格式,分别对应不同场景。

SVG 导出最简单,直接把渲染时生成的 SVG 字符串序列化下来。它保留所有图层和文字,适合后续在专业工具里继续编辑。PNG 导出需要把 SVG 画到离屏 canvas 上,再转成 PNG 像素数据,这里要特别注意跨域字体问题,后面第五节我会专门讲。第三种是 HTML 片段导出,把图和一份内联样式打包成一个片段,可以直接嵌进文档站或者内部维基页面。

我在实际使用中发现,导出 PNG 的默认尺寸和文档布局经常不匹配,所以额外加了一个“适应内容”选项,让程序根据节点边界自动裁剪画布,只保留有效区域,避免导出一张四周大片空白的图。

4. 实操:手写一个最小可用的核心流程

4.1 定义一份极简数据文件

如果完全从零实现,最核心的流程其实由三部分组成:解析数据、计算布局、渲染输出。我们先拿一个最简单的部署关系来走一遍。

新建一个deploy.json,内容如下:

{ "groups": [ { "id": "edge", "label": "边缘层" }, { "id": "core", "label": "核心层" } ], "nodes": [ { "id": "lb", "label": "负载均衡", "group": "edge" }, { "id": "app-1", "label": "应用实例1", "group": "core" }, { "id": "app-2", "label": "应用实例2", "group": "core" }, { "id": "db", "label": "主数据库", "group": "core" } ], "edges": [ { "source": "lb", "target": "app-1" }, { "source": "lb", "target": "app-2" }, { "source": "app-1", "target": "db" }, { "source": "app-2", "target": "db" } ] }

这份数据描述的是“负载均衡把请求分发给两个应用实例,两个实例共用一个数据库”。先不手动指定任何坐标,让布局引擎来算。

4.2 用拓扑排序实现分层布局

最简单的布局逻辑可以从拓扑排序开始。我先把节点按依赖关系分层:没有任何入边的节点排在第一层,随后移除这些节点后、再次没有入边的节点排在第二层,依此类推。

以下是示意代码,用 TypeScript 风格写核心思路:

interface NodeData { id: string; label: string; group?: string; } interface EdgeData { source: string; target: string; } function layering(nodes: NodeData[], edges: EdgeData[]): string[][] { const indegree = new Map<string, number>(); const adjacency = new Map<string, string[]>(); for (const node of nodes) { indegree.set(node.id, 0); adjacency.set(node.id, []); } for (const edge of edges) { indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1); adjacency.get(edge.source)!.push(edge.target); } const layers: string[][] = []; let queue = nodes .filter((node) => indegree.get(node.id) === 0) .map((node) => node.id); while (queue.length > 0) { layers.push(queue); const nextQueue: string[] = []; for (const id of queue) { for (const nextId of adjacency.get(id) || []) { indegree.set(nextId, (indegree.get(nextId) || 0) - 1); if (indegree.get(nextId) === 0) nextQueue.push(nextId); } } queue = nextQueue; } return layers; }

这个算法拿到数据后,会得到两层:第一层是["lb"],第二层是["app-1", "app-2"],第三层是["db"]。有了层号之后,再按同一层内节点的先后顺序分配纵向坐标,横向坐标则简单按“层数乘以固定间距”计算,一张基础的分层图就出来了。

这只是一个最小实现。真正生产环境中还需要考虑同一层内部如何排序、跨层边如何减少交叉、节点宽度不同时坐标如何校正。但拓扑分层是往后所有布局优化的地基,先把地基打通最重要。

4.3 生成 SVG 并渲染到页面

布局计算完成之后,每个节点都拥有了候选坐标。接下来要做的是把节点、连线和分组转成 SVG 元素。

function renderSvg(layers: string[][], nodes: NodeData[]): string { const yGap = 80; const xGap = 200; const nodeHeight = 40; const nodeWidth = 140; const parts: string[] = []; layers.forEach((layerIds, layerIndex) => { layerIds.forEach((id, itemIndex) => { const x = 40 + itemIndex * xGap; const y = 40 + layerIndex * yGap; const node = nodes.find((n) => n.id === id)!; parts.push( `<rect x="${x}" y="${y}" width="${nodeWidth}" height="${nodeHeight}" rx="6" />` ); parts.push( `<text x="${x + nodeWidth / 2}" y="${y + nodeHeight / 2}" text-anchor="middle">${node.label}</text>` ); }); }); return `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400">${parts.join("")}</svg>`; }

这个函数把节点渲染成圆角矩形加居中文字。连线部分同样根据 source 和 target 找到两端节点坐标,画一条带箭头的路径。把返回的 SVG 字符串插入到页面的某个容器节点中,第一版 diagram-design 就活了。

渲染时要注意一个细节:文字应该用dy属性做垂直居中微调,而不是直接依赖text-anchor。我在初版时没做这个调整,文字在梯形盒子里总偏上一点,后来统一加了dy="0.35em"才解决。

4.4 和真实项目的集成方式

最小流程跑通之后,就要考虑怎么真正嵌到项目里用。我建议把它封装成一个独立包,提供两个入口方法:parse(config)和render(config)。

parse负责把 JSON 转换成内部模型,做格式检查、补充默认值、构建索引。render接收内部模型,执行布局和渲染。两者分离的好处是方便写单元测试,也可以单独提供命令行工具,在 CI 流程里自动生成文档图片。

集成到文档站时,我是把渲染结果输出成 HTML 片段再嵌入页面。集成到 React 页面时,则封装了一个轻量组件,内部管理数据和重新渲染逻辑。整体代码量其实不大,但架构边界一旦明确,后面的迭代会轻松很多。

5. 我踩过的坑:问题与排查实录

5.1 节点重叠和连线交叉

第一个遇到的经典问题就是节点重叠。同一个分组里有三个节点,它们的标签长度不同,如果按照统一宽度计算坐标,短标签节点之间会空出大块空白,长标签节点又会溢出边界。

我的解决方案是布局前先做一次“预测量”:根据文字长度动态计算每个节点的实际宽度。计算宽度时不能用节点的 name 字段长度直接乘固定字号,因为中文字符和英文字符宽度不一样,我最后用的办法是 Canvas 测量字符串实际渲染宽度,加 padding 后作为节点宽度。这个改进直接消除了绝大部分重叠问题。

连线交叉则复杂一些。第一版布局把同一层节点从左到右按输入顺序排列,跨层连线很多都是交叉的。后来我增加了一个“同层内按目标节点的实际顺序排序”的策略,让大多数边尽量右向或下向,交叉线数量明显下降。对于无法完全消除的交叉,我只能退一步,允许用户手动拖动节点位置覆盖计算结果。

5.2 自动换行与中文字符宽度

如果节点的 label 很长,自动换行必须做。只做简单截断会导致信息丢失,全部换行又会撑高节点,影响整体布局。

我在渲染文字时做了两件事。第一,计算字符串的累计像素宽度,超过节点可用宽度的字符,在最近的空间处换行。第二,限制最大行数,超过四行后显示省略号,并把未展示的完整内容放在节点的 title 属性里,鼠标悬停可以查看。

这里最容易踩的坑是中文标点和英文字母的换行规则不同。中文文本每个字符宽度基本一致,按字数换行即可;英文单词不能随意拆开,需要按空格切词。我当时的兜底方案是:优先按空格切,没有空格的中文则按字符宽度切,实现起来简单,对绝大多数标签都够用。

5.3 大图性能与虚拟化

当图中节点超过五百个之后,SVG 的性能问题开始暴露:拖拽卡顿、缩放迟钝。一开始我以为是渲染框架的问题,后来定位到瓶颈在布局计算上。

原因在于我的布局算法里有一处嵌套循环,每次重新布局都要遍历所有节点和连线,而节点坐标更新又会触发重渲染。优化方式很简单,把布局计算拆成两阶段:第一阶段全量计算,只在数据变化时执行;第二阶段增量计算,只在拖拽节点时执行,只更新受影响的相邻节点。

此外,我还做了渲染层的虚拟化。视口外的节点和连线不生成 SVG 元素,等滚动或缩放进入可视区域时再补充创建。实测节点从五百增长到一千时,性能基本还能保持稳定流畅。

5.4 多人协作时的版本冲突

项目里引入了 git 管理 JSON 数据文件之后,版本冲突成为新问题。两个人同时编辑同一个架构图配置,合并时经常出现整段冲突。

我的经验是不要让人直接手写完整 JSON,而是提供两步操作:第一,提供可视化编辑器,所有修改都通过代码内部模型完成;第二,在生成 JSON 时对键名做稳定排序,尽量保证相同逻辑的修改产生相同文本,减少合并冲突面。

另外非常重要的一点是给每个节点和连线分配固定的 UUID,而不是用服务名当 ID。服务改名很常见,如果用服务名当 ID,一次改名会导致所有相关连线失效;用 UUID 则只需修改 label 字段,关系不会断。

5.5 导出遇到字体丢失

导出 PNG 时最容易出现的问题是字体丢失。浏览器页面里能正常显示的中文字体,到了 canvas 离屏渲染时可能变成方框或乱码。

排错的思路是先确认字体文件是否成功加载。如果字体是异步加载的,需要在document.fonts.ready完成之后再执行导出。如果字体来自跨域资源,还要额外设置字体资源的响应头,允许跨域读取。

我刚开始频繁遇到这个问题,后来干脆在导出前主动检测document.fonts.status,如果未加载完成就弹一个加载状态提示,加载完成后再走导出的 canvas 绘制流程。这才是真正稳妥的方案。

6. 一点经验之谈

如果让我重新做一遍 diagram-design,我会在第一天就把“数据模型优先”写进原则里。很多看似炫酷的功能都是需求驱动的,而一个简洁、稳定、可扩展的数据模型,才是功能做得快的底气。项目做久了会发现,图的布局算法可以换,渲染方式可以换,但节点、连线、分组这套“元语”一直没有变过,它成了整个项目最稳固的地基。

对于想在自己的项目里引入类似方案的人,我建议从最简单的分层布局开始,不要一上来就追求力导向和复杂的美化效果。先用一份 JSON、一个 SVG 渲染函数跑通全流程,把“定义到出图”的最小链路打通,再逐步迭代布局优化和交互体验。方向对了,速度慢一点没关系。

最后分享一个小技巧:除非你的图完全只是展示用,否则一定要保留“用户手动微调坐标”的出口。纯自动布局可以帮助解决 80% 的常规问题,但剩下 20% 的视觉强迫症,还是需要给人一只自由的手。

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

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

立即咨询