做技术文档的人应该都有同感:画架构图、流程图、拓扑图这件事,看着不难,真做起来能把人逼疯。手动拖框、连线、调对齐,改一个节点位置,后面的连线全部乱套,又得重新排一遍。所以当我决定自己动手做 “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”。原因我在下表里整理出来了。
| 对比维度 | SVG | Canvas 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: Record<string, 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 就是最可靠的事实依据,比截图有用一百倍。