diagram-design:用代码定义架构图的可编程图表设计引擎

发布时间:2026/10/11 8:55:13
diagram-design:用代码定义架构图的可编程图表设计引擎 做技术文档的人应该都有同感画架构图、流程图、拓扑图这件事看着不难真做起来能把人逼疯。手动拖框、连线、调对齐改一个节点位置后面的连线全部乱套又得重新排一遍。所以当我决定自己动手做 “diagram-design” 这个项目时心里只有一个执念——让“画图”这件事彻底变成写代码。用文本描述图结构用代码控制布局和样式改图就跟改配置一样轻松。diagram-design 本质上是一个面向开发者的图表设计引擎。它接收一段结构化的图描述输入经过语法解析、布局计算、样式渲染这几个环节最终输出一张可以直接用的 SVG/PNG 图片或 JSON 图数据。它解决的核心问题是传统可视化工具中“手动排布”带来的低效和不可复用同时给团队沉淀一套可版本化、可审查的图表资产。这篇文章我会把这套系统的核心思路、关键技术模块、实现链路里的实操细节以及我踩过的一些坑完整拆开讲一遍。无论你是想自己做一套类似的图工具还是打算在项目里接入可编程绘图能力这篇内容应该都能直接给你一些参考。1. 项目背景与整体设计思路1.1 痛点分析与项目目标先说说我为什么要做这个项目。日常写技术方案、做系统设计说明、整理汇报材料图表用量非常大。架构图、模块依赖图、时序图、数据流向图几乎每周都要产出好几张。市面上现有的工具大致分两类一类是像画布类的图形编辑器灵活但费手全手动调整另一类是特定图表库针对度高但扩展受限想画一张自定义风格的分层架构图得费很大劲。除此之外更大的问题在于图的内容和文档是割裂的。需求一变文档改了图忘改最后图比代码更先失真。所以 diagram-design 立项时我定了几条硬性指标描述即图表用一小段结构化声明直接生成完整图表不需要手动拖拽。版本可追踪图源文件是纯文本可以进 Git 仓库diff 一眼看清楚谁改了哪里。布局自动化节点排布、连线路径自动计算不出现重叠和交叉混乱。样式可定制主题化默认样式开箱即用同时允许全局主题覆盖。可嵌入任意端能导成图片也可能作为组件挂到现有文档系统里。这些目标最终都落到了架构设计里。可以说这个项目从一开始就不是奔着“给画图工具添一个功能”去的而是想把“图表”这件事从手工活改成代码活。1.2 整体架构设计与分层原则diagram-design 的整体架构分三层语法层DSL、模型层Graph Model、渲染层Renderer。这个分层思路和编译器领域的“前端 中间表示 后端”非常相似。语法层负责把用户输入的文本解析成结构化的数据。模型层维护节点、连线、分组、锚点等核心概念并承担布局计算它不关心最终画成什么样。渲染层把模型对象映射为具体的图形指令输出到不同目标比如 SVG 元素、Canvas 绘制序列或 JSON 序列化结构。这样做的好处非常明显。第一语法和渲染解耦以后想加一种新的输出格式只需新增一个渲染适配器第二模型层的布局算法可以被不同语法前端复用不管用户是用短语法、JSON 还是可视化编辑器作图最终都走同一套布局逻辑第三测试更容易每层都可以独立做单元验证。实际项目中我参考了很多成熟图编辑器的做法但最终没有直接采用某一套开源渲染方案而是基于渲染层的需求自己封装了一个轻量级的绘制内核。核心原因在于通用渲染库为了适配各种场景抽象层级复杂体积大而我的项目只需要处理有限几种形状、连线和文本排布自己封装反而更直接可控。对于同样的需求场景我建议优先考虑自研轻量层而不是一上来就引入重型依赖。2. 核心模块设计与关键细节拆解2.1 核心图元模型Node、Edge、Port、Group整个引擎运行的根基是图元模型。我的设计里核心抽象是Node节点和Edge连线再加上辅助的Port锚点和Group分组。Node 代表图中的矩形块、圆形块、菱形等实体元素每个 Node 有唯一的 id、坐标、宽高、样式引用和附加数据。Edge 代表节点之间有向或无向的连线它不直接存路径坐标而是存起点和终点的 id 及锚点位置真正的路径要由布局引擎根据两端坐标计算。Port 是边上挂载的接入点定义了一条连线的具体接在节点的哪个方位上、下、左、右、中心。为什么需要 Port因为如果不指定锚点布局引擎只能用默认规则猜边界情况一旦多起来连线对不上、线穿盒的现象就会频繁发生。Group 则用来表达节点的从属关系在画系统边界、子模块聚合时特别有用。实践经验在最早期版本里我不太重视 Port 的设计觉得连线接哪都行自动算就好了。后来当图里出现分组嵌套、跨组连线时我发现连线经常绕出分组的视觉边界非常难看。加上了 Port 之后连线的起终点可控排布稳定很多。所以如果你也要做类似的东西建议从一开始就把 Port 纳入核心模型。节点和边配套的还有样式引用机制。Node 本身不存完整的颜色、圆角、边框配置而是挂一个样式主题的 key比如style: server实际颜色值从主题定义里取。这样做使整套系统保持了一致的外观避免了每加一个节点都要重复写样式。2.2 布局引擎设计逻辑布局与物理布局布局引擎是整个系统里最花时间、也最考验细节的部分。我把它拆成两类算法固定布局和自动布局。固定布局就是用户显式指定每个节点的坐标引擎只做合法化校验不干预位置。适合手动精调、对排布有严格要求的场景。自动布局则适合快速出图。自动布局分两种一种是分层布局Hierarchical Layout常用于流程图、依赖图思路是把图按照层级结构逐层摆放在竖向或横向空间里排层尽量让连线朝一个方向走。另一种是力导向布局Force-directed Layout用于拓扑图、关系图思路是把每个节点当成一个带电粒子节点之间有引力也有斥力通过迭代计算让整个图稳定下来。我在 design 里实现的是分层布局为主、力导向为辅的组合方案。分层布局的执行过程分四步对图进行分层划分通过拓扑排序确定每个节点的层序号。遍历每一层的节点计算层内的顺序尽量降低连线交叉数量。分配纵坐标同一层的节点中心对齐。计算横坐标结合节点宽度和层间距确定最终坐标。这里有几个计算的考量点。层间距我默认设为 60 像素同层节点间距默认 40 像素这些值是基于常见阅读体验总结出来的太密容易视觉粘连太松则浪费空间。对于宽节点例如超过 200px 的区块间距需要自适应扩大否则两个节点必然重叠。力导向布局就是经典的有斥力的物理模拟每次迭代计算所有节点对之间的斥力连线的两个端点之间施加弹簧引力把力的变化转换为位移。这个算法我做了性能优化只对距离阈值之内的节点对计算斥力迭代次数控制在 300 次以内对 100 个节点的图而言在普通电脑上能在几百毫秒内完成布局体验已经很顺。2.3 渲染层设计SVG 还是 Canvas在渲染方案选型上我做了很认真的对比。当时存在三条路线纯 SVG、Canvas 2D、WebGL。WebGL 直接排除因为图表引擎不需要 3D 渲染引入 WebGL 会把复杂度推高一个级别。最实际的权衡在 SVG 和 Canvas 之间。我的选择是“默认 SVG、大数据量场景切换 Canvas”。原因我在下表里整理出来了。对比维度SVGCanvas 2D渲染机制矢量图形DOM 元素位图画布像素绘制节点数量适配适合几百个以内交互灵活几千个节点也能保持稳定帧率交互能力天然支持事件绑定、CSS 样式需要自己做命中检测和事件管理样式定制可以直接操作元素属性和 CSS需要代码绘制样式修改要重绘开发维护成本较低调试直观中等需要额外封装一些能力导出图片直接序列化 SVG 内容需要转成数据 URL 再导出具体到我的场景四五十个节点以内的架构图SVG 完全够用交互还特别好做给节点加点击、悬停事件就像给 DOM 加事件一样自然。但当图膨胀到几百上千个节点比如可视化监控系统里的拓扑SVG 的 DOM 节点过多会导致页面明显卡顿这时改用 Canvas 绘制的优势就非常明显。所以最终渲染层实现了一个抽象接口输出端分了两套适配器。接口定义得非常简单interface IRenderer { drawRect(rect: Rect, style: StyleOption): void drawText(text: string, pos: Point, style: TextOption): void drawPath(points: Point[], style: PathOption): void drawEllipse(center: Point, rx: number, ry: number, style: StyleOption): void mount(container: HTMLElement): void clear(): void }SVG 适配器把这些方法映射到创建 SVG 元素Canvas 适配器则把同样的参数映射到绘图指令。这层抽象让后续扩展其他渲染方式比如打印到 PDF、输出到图片变得非常顺。2.4 样式系统与主题可定制化样式系统的设计哲学是“默认优雅覆盖容易”。我设计了一套基于Token令牌的样式链基础颜色、边框宽度、圆角大小、字体字号全部抽成 Token主题是一组 Token 的集合组件样式如“服务节点”“数据库节点”再引用主题里的 Token。举例来说一个默认主题大致长这样{ name: light-default, token: { colorBackground: #ffffff, colorPrimary: #2563eb, colorText: #1e293b, colorBorder: #94a3b8, colorNodeServer: #e0f2fe, radiusDefault: 6, strokeWidth: 1.5, fontFamily: \PingFang SC\, \Microsoft YaHei\, sans-serif } }用户要自定义主题只需提供一份 JSON 覆盖掉默认 Token。这比给每个节点单独传颜色的方式高明不少——全局样式修改只动一处图表内所有节点自动生效。另外我还实现了深色模式适配。做法不是另外做一整套配色而是对颜色 Token 做亮度映射当检测到页面处于深色环境时基础背景反转为深色文字反转为浅色节点颜色自动降低亮度饱和度。这个能力在很多文档类工具里非常实用。3. 实操过程与核心功能实现3.1 项目搭建与基础工具链工欲善其事必先利其器。diagram-design 的技术栈我选的是 TypeScript Vite。TypeScript 保证图元模型这种强数据结构不会在项目变大之后失控Vite 让开发时的热更新非常爽。整个项目打包成 ES Module 格式同时也输出一份 UMD 格式供直接在浏览器里 script 引入使用。项目结构如下diagram-design/ ├── src/ │ ├── parser/ # 语法解析器 │ ├── model/ # 图元数据模型 │ ├── layout/ # 布局算法 │ ├── renderer/ # 渲染适配器 │ ├── style/ # 主题与令牌 │ └── index.ts # 对外入口 ├── example/ # 示例 Demo └── test/ # 单元测试搭建过程中我犯过一个典型错误一开始把单元测试放在最后写结果布局算法迭代到第二版时旧的解析器行为被改挂了好几次每次都要手工点页面去发现问题效率非常低。后来我把所有纯函数解析、布局计算、样式合并都抽成无副作用的函数用单元测试锁定行为开发速度立刻上来了。这一点强烈建议凡是涉及计算的模块测试一定要同步写不要拖。3.2 DSL 语法解析器实现DSL 设计是 diagram-design 的门面。用户看到的第一个东西不是界面而是语法。所以语法设计的第一原则是“像读句子一样直观”。我设计了一套 JSON 之外的简化格式支持三种核心声明节点、连线和分组。实际的 DSL 长这样node 用户端 { style: client, x: 20, y: 40 } node 服务端 { style: server, x: 260, y: 40 } node 数据库 { style: database, x: 500, y: 40 } edge 用户端 - 服务端 edge 服务端 - 数据库 group 核心系统 { node 服务端 node 数据库 }解析器的职责是把这段文本变成内部模型。我用的是手写递归下降解析器没有引入庞大的编译工具。理由很实际语法规模有限总共就四类语句手写解析器代码量可控而且调试时每一步都能精确掌握。解析过程分成两层词法分析把字符串按空格、换行、括号等分隔符切分成 Token 流同时识别出关键字node、edge、group取值字符串和数字常量。语法分析按语法规则逐词消费 Token遇到node就解析出一个节点定义遇到edge就解析出连线关联。这里有一个关键的解析细节解析器必须对“未知字段”宽容但必须对“缺失必填字段”报错。比如节点缺少 id直接抛错提示“节点缺少唯一标识”。但如果用户多写了两个自定义字段则忽略并提示警告而不是中断。这个设计兼顾了容错和严谨实际使用体验比“一刀切报错”友好得多。3.3 布局计算与自动排布实现布局引擎的接口设计很简单输入是一份模型对象输出是每个节点最终坐标的模型对象。中间经过分层、排序、坐标分配三步。我贴一段简化后的分层布局核心代码方便理解整体思路interface LayoutOptions { layerGap: number nodeGap: number direction: horizontal | vertical } function layout(graph: GraphModel, opts: LayoutOptions): LayoutResult { // 1. 按拓扑排序划分层 const layers: string[][] splitIntoLayers(graph) // 2. 计算层内节点顺序降低交叉 reduceCrossings(graph, layers) // 3. 分配坐标 const positions: Recordstring, Point {} if (opts.direction vertical) { let y 0 for (const layer of layers) { let x 0 let maxHeight 0 for (const nodeId of layer) { const node graph.getNode(nodeId) positions[nodeId] { x, y } x node.width opts.nodeGap maxHeight Math.max(maxHeight, node.height) } y maxHeight opts.layerGap } } else { // 水平方向的布局逻辑与纵向对称 } return { positions, layers } }拓扑排序的细节值得提一下。它本质上是广度优先遍历从没有任何入边的节点开始逐层剥离。如果图里有循环依赖拓扑排序会卡住所以我在这个环节做了循环检测一旦发现有环就把环路中的某条边设为“虚线提示边”并降级设为非约束关系保证布局流程能继续走完。排查时用户能看到哪条边形成了环路比整个布局直接崩溃要友好太多了。连线路径的计算我用了正交折线算法也就是常见的“横平竖直”走线。先根据两端锚点位置确定折线方向然后计算中间的拐点坐标。为了避免连线从节点中间穿过拐点都做了偏移计算确保拐点离节点边缘有一定间距。一开始我偷懒直接画直线结果连线从别的节点身上穿过去视觉上惨不忍睹改成折线并带安全偏移之后整体观感好了一个档次。3.4 渲染输出与导出能力渲染输出这块我把重点放在“可见即可得”和“可复用”上。SVG 渲染的流程并不复杂拿到布局后的模型遍历节点调用drawRect和drawText遍历边集调用drawPath最后把生成元素挂载到容器里。文本对齐方面居中文本的锚点需要调整否则文字会偏离区块中心一点这个细微差距用肉眼看得很明显。导出能力做了三种SVG 文件导出直接把当前渲染生成的 SVG 字符串序列化加一个声明头变成一个独立文件。这样放到任何矢量工具里都能二次编辑。PNG 图片导出新建一个 Image 元素把 SVG 转成 Blob URL然后绘制到 Canvas 上再导出为 PNG。需要注意 Canvas 的尺寸要按实际像素计算否则在高分屏上会模糊。JSON 图数据导出把所有模型数据序列化方便其他程序读取或者后续做图数据的自动化渲染。PNG 导出踩过一个典型的坑字体没嵌入会导致部分机型显示缺字体。解决方案是把关键文本提前转成路径Path再参与导出。这样虽然导出文件体积变大一些但确保在别人的电脑上打开时显示效果完全一致。4. 常见问题与排查技巧实录4.1 高频问题速查表整个开发过程中我总结了下面这份高频问题速查表都是实操里很容易遇到的典型问题按图索骥就能省掉不少排查时间。问题现象可能原因排查与解决思路节点全部堆叠在左上角布局函数未执行或坐标字段丢失检查 parse 后的模型里坐标字段是否存在确认是否调用了布局函数连线从节点身体中间穿过锚点 Port 未指定为 Node 添加默认 Port 坐标计算连线时先获取端点大量节点渲染卡顿严重SVG 节点数过多切换 Canvas 渲染模式或启用分区域虚拟渲染导出的 PNG 文字模糊导出分辨率不足按 devicePixelRatio 加倍 Canvas 尺寸后导出深色模式下节点颜色刺眼主题里硬编码了浅色背景使用 Token 系统深色模式映射亮度与饱和度图里出现一条莫名的长连线两个节点 id 回车换行导致解析合并异常解析器按行严格切分空行要跳过浏览器控制台报“循环导入”错误模块间相互引用拆掉循环依赖把公共类型抽到独立模块4.2 踩坑记录与处理方案最大的坑出现在想要的“文本即图”体验和实际布局效果之间的差距。早期的布局算法太“直男”——完全按树的层次硬排导致某些字段描述很自然的图呈现出来头重脚轻。后来我加了一种权重参数在节点语法上允许写weight: 2布局时权重大的节点占用更多空间可以适当跨层。这个能力看似微小却解决了 90% 的“差一点点就完美”的问题。另一个印象深刻的坑是 SVG 的坐标精度。当节点坐标是小数比如 90.3333px时渲染出的线边缘会有“毛刺”看起来像没对齐。解决方案很粗暴但在实践中有效布局完成后对所有坐标做一次四舍五入取整。这样图片边缘锐利干净彻底解决了毛边问题。还有一次某个系统图在多个浏览器上显示的字体宽度差异很大导致文本溢出节点边框。后来我把节点宽度策略改成根据文本长度动态估算默认每个中文字符宽度按 14px 计算再叠加 16px 的左右内边距同时支持手动指定宽度。这之后文本溢出基本没有再出现过。4.3 性能优化与大数据量处理经验图表引擎面对大数据量时性能优化是绕不开的话题。实测下来200 个节点以内 SVG 模式毫无压力500 个以上明显开始掉帧一千以上必须走 Canvas。在 Canvas 模式里有几个更细节的东西值得注意。渲染循环用 requestAnimationFrame 控制不一次绘制完分帧进行。这样即使一帧没画完页面也不会出现长时间卡死用户能感觉到“图在逐步出现”体验柔和很多。事件命中检测也要自己做Canvas 不像 SVG 有 DOM 事件需要记录每个节点在画布上的坐标矩形点击时做反向空间查找。节点多的时候线性遍历几百次已经有点慢我采用了以网格为单位的空间索引把画布切成若干格子记录每个格子内有哪些节点命中检测时就近查找那一格的数据速度提升非常明显。我还做了增量渲染能力——移动单个节点时只重绘受影响区域不是全画布重绘。这个优化主要靠脏矩形机制实现记录需要更新的矩形范围下一帧只刷这部分内容。5. 扩展方向与后续玩法这套系统目前已经能够稳定生成架构图、流程图、拓扑图但它的演化潜力比当前做出来的还要大不少。我展望了几个值得继续深挖的方向。一个是交互式编辑。目前 DSL 是单向的文本描述到图。但反过来如果图在画布上被拖动应该也能反向同步 DSL 文本。这就是所谓的双向绑定。实现思路并不复杂节点拖动结束时把最新的坐标写回模型再重新序列化 DSL 文本。这对“文本派”和“鼠标派”用户都有价值两边可以无缝切换。另一个是AI 辅助生成图表。既然图本质上是结构化数据完全可以让语言模型根据一段自然语言描述直接生成 DSL 源码再渲染出图。比如用户说“帮我画一个用户登录到订单系统的流程图”语言模型就能产出对应的 node/edge 结构。这意味着图表的门槛将进一步降低使用者不需要懂任何语法只描述业务关系就够了。还有一个方向是跨文档联动。把 DSL 文件直接嵌入到 Markdown 或文档网站里构建时自动渲染成图片或可交互组件。这个过程和前端构建工具结合甚至可以做到代码更新、图表同步更新彻底解决文档和图示脱节的顽疾。我个人在实际开发中的体会是做这类项目最大的收获不在于最后那一张张漂亮的图而在于你被迫把“视觉表达”这件事抽象成数据结构和算法。抽象能力一上去后续做任何可视化相关的东西都会顺手很多。如果你正准备在团队里引入类似能力我建议从小场景起步先支持几十个节点的自动布局把 DSL 设计和渲染链路跑通再慢慢扩展交互和自定义能力。最后再分享一个小技巧所有的图数据尽量保留一份中间状态 JSON调试布局或排查渲染问题时这份 JSON 就是最可靠的事实依据比截图有用一百倍。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询