聊到图表设计,我之前折腾了一个内部项目,名字就叫diagram-design。严格来说,这不是一个现成工具,而是一套面向团队内部的图表设计方案和代码库集合。起因很简单,团队里做技术方案评审、系统架构梳理的时候,大家用的画图工具五花八门:有人用ProcessOn,有人用draw.io,有人直接在代码仓库里画ASCII图,还有人用PPT硬画。出来的图风格完全不统一,线条粗细不一样,配色千奇百怪,放在文档里又没法直接复用。更麻烦的是,一旦架构调整,原图找不到了,或者画图的同事休假了,改图就变成一场灾难。
所以我就想,能不能把“画图”这件事抽象成一套规范流程:用结构化数据描述图的内容,交给统一引擎去排布、渲染和导出,链接进文档系统就能直接更新。听起来有点像一个简化版的Mermaid,但又不完全是——因为我们的场景不仅是流程图,还有网络拓扑图、微服务调用关系图、系统架构分层图,甚至类图和数据模型ER图。这些图的画法各有讲究,硬套一套通用模板很容易变成四不像。这篇我就把整个项目的思路拆开讲讲,包括DSL怎么设计、渲染引擎怎么选、布局算法踩过什么坑、交互层面怎么补,以及几个真实上线后才会遇到的典型问题。如果你也在搞类似的图表工具、想给自己团队做一套规范的图表示例体系,这篇应该有不少参考价值。
1. 项目整体设计与思路拆解
1.1 为什么不自研引擎,而是先定规范
做这个项目之前,我认真比较过三条路线:一是直接用现成的开源画布工具(比如Draw.io、Excalidraw、AntV X6);二是封装Mermaid这类基于文本的图表DSL;三就是从零开始自研一套“DSL定义 + 引擎渲染 + 布局算法 + 交互扩展”的完整方案。
先说结论,我在实际项目里选了第三条路,但不是全盘自研,而是自研核心DSL和布局引擎,渲染层复用SVG。原因是前两条路各有硬伤。现成工具最大的问题不是功能不够,而是“风格不统一”和“数据难回收”。Draw.io和Excalidraw这类产品能力很强,但用户自由度太高,A同事画出来的图和B同事画的完全是两种风格,而且图形文件基本上是二进制或私有格式,没法在代码评审里直接diff。Mermaid虽然解决了文本化和统一渲染的问题,但表达力有限,画复杂拓扑和嵌套架构时很吃力,而且内置的布局算法对一些我不想要的场景调整起来很麻烦。
所以最终确定的核心思路是:自己定义一套JSON风格的DSL来描述图,再由渲染引擎统一排布。图的所有信息都是纯数据结构,可以存在Git仓库里参与Code Review,也可以动态生成,更可以在不同文档系统之间无缝流转。整条链路是:“DSL描述 → 抽象语法树 → 布局计算 → SVG渲染 → 交互/导出”。这个分层方式在后面扩展新图型时帮了大忙,每层只处理一件事情,边界很清楚。
1.2 核心需求画像与目标场景
这个项目从第一天起就框定了四个必须覆盖的场景,不是通用画板,而是“专图专用”。
第一个场景是微服务间调用关系图。这类图的特点是节点多、边多、还有熔断降级的标志,除了把服务和接口画出来,还得表达调用链路上依赖健康状态。第二个场景是系统部署架构图。需要分机房、分可用区、分服务实例,每一层是嵌套的盒子结构,节点内部还要塞IP、实例ID、镜像版本号这些元信息。第三个场景是云原生或大数据任务链的DAG调度图。这种图对分层排布要求比较高,不能有环,层级必须清晰,上下游怎么看都不能乱。第四个场景是数据库ER图和数据流向图,要求能把表实体、字段、主外键关系表达清楚。
说实话,这四个场景用通用画板都能画,但每类图的布局逻辑差异很大:拓扑图重力导向,部署架构图重嵌套分组,DAG图重分层排序,ER图重实体间距均匀。如果我搞一个通用引擎硬适配所有场景,代码复杂度会爆炸。所以我把底层做成通用,但上层拆成了四种“图型方案”,每种方案有自己的默认布局策略和节点样式。用户声明“type: 'topology'”就命中拓扑自动布局,声明“type: 'deploy'”就命中分层嵌套布局。
1.3 技术选型的取舍逻辑
技术选型是项目早期最纠结的部分,我花了两天时间反复比对。最终确定的组合是:DSL层用JSON Schema做校验,渲染层用原生SVG,绘图交互层用Pixi.js在做高性能方案之前先没接,实测中大部分场景SVG已经足够,画布实现直接基于SVG DOM,布局算法自研核心、参考dagre的启发式思路。
选SVG而不是Canvas,核心原因是节点数量级。团队实际的图,节点一般在50到300之间,SVG在这个量级下无论是渲染性能还是事件绑定都很稳。Canvas的优势在成千上万节点时才明显,但那种场景在我们内部基建图中很少出现。另一个重要因素是SVG天然支持样式继承和CSS覆盖,想改主题色、描边样式,团队成员自己就能在浏览器里调,不需要重新走一遍渲染逻辑。SVG还有一个杀手级优势是导出矢量图:文档里贴的架构图放大缩小都清晰,这一点Canvas做不到。
选JSON而不是YAML,是为了省错。JSON的格式校验生态太成熟了,任何语言都有库可以直接解析,而且前端拿到JSON以后可以直接做diff,这是项目里最实用的一个特性。后文我会专门举例怎么用JSON Diff做架构变更的图形化评审。
2. 核心细节解析与实操要点
2.1 DSL设计:怎么定义一张图的结构
DSL是整个项目的地基,它决定了上层所有能力的上限。我在第一版只设计了三个核心概念:nodes、edges和group,这是几乎所有图表的通用抽象。
先看一个最简示例,这是一张三个服务之间的调用链:
{ "type": "topology", "nodes": [ { "id": "gateway", "label": "API 网关", "kind": "gateway" }, { "id": "order-svc", "label": "订单服务", "kind": "service" }, { "id": "user-svc", "label": "用户服务", "kind": "service" } ], "edges": [ { "source": "gateway", "target": "order-svc", "label": "HTTP/gRPC" }, { "source": "order-svc", "target": "user-svc", "label": "RPC" } ] }这个结构看起来很简单,但实际项目里我给它加了不少约束。id必须全局唯一,而且建议使用有业务含义的字符串而不是数字编号,后面做引用和diff都方便。kind字段很关键,它决定了节点渲染成什么风格,比如网关节点是特殊的蓝色菱形,服务节点是圆角矩形,数据库节点是圆柱体。样式不用在节点里写死,而是由主题系统根据kind自动映射,这样才保证全团队画出来的图风格一致。
edges除了source和target,可以携带label表示边上的文字,也可以带type表示线的样式,比如虚线、加粗实线、双向箭头。这里我踩过一个坑:Edge的方向语义非常重要,在拓扑图里,source指向target意味着“调用方向”还是“依赖方向”,团队内部必须统一,否则画出来的图和实际架构正好反过来,误导性极强。我们最终的约定是箭头方向表示“依赖方向”,订单服务依赖用户服务,所以source是订单服务,target是用户服务,线上渲染时箭头从依赖方指向被依赖方。这个约定在代码评审里立了大功,因为条目化的表达比人眼在图上找线清晰得多。
2.2 自动布局算法:为什么需要“不让人手动拖”
布局引擎是整个项目里技术难度最高的一部分,也是投入收益最不成比例的部分,因为它太容易被低估。大多数人以为画图的核心是“画得好看”,但实际操作中最大的工作量在“摆放位置”。
为什么不能都靠拖拽?原因有两个。第一,文档里的图要支持刷新后重新生成,如果每次微服务数量变了,靠人手动拖一遍位置,维护成本太高。第二,自动布局能保证一定的秩序感——相同层级的节点排在同一列,交叉线尽量少,这比人眼拖出来的图更稳定。
我实现的布局分为三类:DAG分层布局、力导向布局、分组嵌套布局。
DAG布局用于调度链路图。核心思路是:先对节点做拓扑排序,分配层级,再在同一层级内做排序,尽量减少边交叉。这一步参考了Sugiyama算法的思路,但我没有全部照搬,只取了分层和降交叉两个核心阶段。实际步骤如下:
- 将有向图按拓扑排序分解成若干层级,每个节点得到一个
layerIndex。 - 对每个层级内的节点排序,迭代降低相邻层级之间的边交叉数。
- 在同层级内做“居中”处理,让节点在垂直方向尽量对齐。
力导向布局用于拓扑图。原理是模拟物理系统:节点之间有弹簧力,边相当于弹簧,并把所有节点向图中心吸引,反复迭代计算位置直到稳定。算法不复杂,但要注意迭代次数和冷却系数,否则图会一直在抖动。我在项目里设置初始温度0.1,每轮衰减0.99,最多迭代500次,实测下来300个节点以内基本稳定在两三秒内收敛。
分组嵌套布局用于部署架构图。核心是把节点按“分组”拆成树结构,每个分组内部独立布局,再整体拼装到父分组里。这里最大的坑是分组层级过深时,子布局算完了父布局一改,子图又得重算。后来我采用了“自底向上逐层布局”的方式,先布局最内层,再根据内层组的边界尺寸来决定外层组的大小,避免循环依赖。
2.3 渲染引擎的伪代码与核心实现
渲染层我全部用原生SVG,没有引入额外库。对我个人而言,这个选择降低了调试成本。直接操作SVG DOM的好处是,任何一个元素的最终样式和位置都能在浏览器开发工具里看到,团队里的前端同事接手也快。
我先定义了一个简单的坐标结构:
class RenderNode { constructor(id, label, x, y, width, height, kind) { this.id = id; this.label = label; this.x = x; this.y = y; this.width = width; this.height = height; this.kind = kind; } }然后写渲染函数,把节点映射成SVG元素:
function renderNode(node, theme) { const ns = 'http://www.w3.org/2000/svg'; const g = document.createElementNS(ns, 'g'); g.setAttribute('transform', `translate(${node.x}, ${node.y})`); const rect = document.createElementNS(ns, 'rect'); rect.setAttribute('width', node.width); rect.setAttribute('height', node.height); rect.setAttribute('rx', 6); rect.setAttribute('fill', theme[node.kind].fill); rect.setAttribute('stroke', theme[node.kind].stroke); g.appendChild(rect); const text = document.createElementNS(ns, 'text'); text.setAttribute('x', node.width / 2); text.setAttribute('y', node.height / 2 + 5); text.setAttribute('text-anchor', 'middle'); text.textContent = node.label; g.appendChild(text); return g; }这段代码的关键点在于:节点坐标存储的是translate的偏移量,而不是直接操作x、y属性。这样做的好处是,后续要做拖拽或动画,只需要改transform,不会触发整个SVG的重排。
边的渲染稍微复杂一些,因为需要处理箭头、弯曲和标签。我的实现是:默认用直线,但如果有弯曲需求,就在中间插入两个贝塞尔控制点,生成<path>元素;箭头用<marker>定义一次,全局复用。渲染一条边的函数长这样:
function renderEdge(edge, points, markerId) { const ns = 'http://www.w3.org/2000/svg'; const path = document.createElementNS(ns, 'path'); const d = `M ${points[0].x} ${points[0].y} ` + `C ${points[1].x} ${points[1].y}, ` + `${points[2].x} ${points[2].y}, ` + `${points[3].x} ${points[3].y}`; path.setAttribute('d', d); path.setAttribute('marker-end', `url(#${markerId})`); return path; }2.4 主题与样式的统一设计
样式这块,我单独总结出一条经验:对“类型”定义样式,而不是对“节点实例”定义样式。一开始我也允许在每个节点里写死颜色,但后来发现只要有一两个节点是特殊颜色,团队其他人就会觉得可以随意改颜色,整体风格就失控了。所以最终版的主题系统,完全基于kind字段映射。
我用一个主题对象控制全局样式,类似这样:
const theme = { background: '#ffffff', node: { gateway: { fill: '#e8f0fe', stroke: '#4285f4', shape: 'diamond' }, service: { fill: '#f3f6fc', stroke: '#5f6368', shape: 'rect' }, database: { fill: '#fef9e7', stroke: '#f9a825', shape: 'cylinder' } }, edge: { solid: { stroke: '#dadce0', strokeWidth: 1 }, thick: { stroke: '#333333', strokeWidth: 2 } } };主题文件和数据文件彻底分离,数据和样式解耦的好处是:不用改数据就能一键切换整套视觉风格,浅色、深色、打印友好、投影仪友好都不是问题。团队只需要约定好kind的命名规范,新节点类型可以由各业务线自行扩展,而不用改渲染引擎代码。
3. 实操过程与核心环节实现
3.1 手把手:从零构建一个最小可用的图表引擎
这一步我来完整演示一下,如何在一个空目录里,从零构建一个最小可用的图表引擎。假设我们已经有了上面定义的DSL结构,接下来要做的是解析、布局、渲染三步。
环境准备很简单,只需要一个现代浏览器,不需要框架。我先创建一个HTML页面引入JavaScript,然后定义核心入口。核心入口的调用方式是这样的:
const spec = { type: 'topology', nodes: [...], edges: [...] }; const diagram = new DiagramEngine(spec); diagram.layout(); diagram.render(document.getElementById('container'));这里的DiagramEngine是一个类,内部持有三个子系统:layout负责算位置,render负责生成SVG DOM,interaction负责挂载事件。在布局阶段,我先根据type选择不同的策略:
class DiagramEngine { constructor(spec) { this.spec = spec; this.nodes = spec.nodes; this.edges = spec.edges; } layout() { if (this.spec.type === 'topology') { this.layoutManager = new ForceDirectedLayout(); } else if (this.spec.type === 'dag') { this.layoutManager = new DAGLayout(); } else if (this.spec.type === 'deploy') { this.layoutManager = new NestedLayout(); } this.layoutManager.compute(this.nodes, this.edges); } }然后,把计算出来的坐标存回每个节点的x、y字段。这样渲染函数只要读取坐标即可,不需要关心布局细节。
最后的渲染环节,需要注意一个性能细节:当节点数量较多时,创建一个新的SVG根元素比反复在旧SVG上追加更干净。我每次渲染都把容器内的旧元素清空,然后重新构建整个SVG结构。虽然看起来有些“暴力”,但实测在500个节点以内,重建一次的耗时都在几十毫秒以内,完全可接受。
3.2 交互能力增强:拖拽、缩放、连线编辑
画图工具只有渲染显然不够,团队用的时候肯定想微调布局。所以我在第二迭代加入了几个常用交互:节点拖拽、画布缩放、新增连线。
节点拖拽的实现思路很简单:SVG元素上绑定pointerdown,记录起始坐标,pointermove时更新transform。但有一个坑,拖拽后布局引擎计算出来的坐标已经变了,节点的新位置必须同步回DSL数据里,否则下次重渲染又会回到布局算法算出来的位置,用户会感觉“白拖了”。所以我加了onNodeMoved回调,每次拖拽结束都更新节点的x、y。
画布缩放用的是SVG的viewBox机制。需求是“以鼠标为中心缩放”,不是简单的修改viewBox宽高,而是要先计算鼠标相对于画布的位置,再调整缩放比例:
function zoomAt(factor, mouseX, mouseY) { const vb = svg.viewBox.baseVal; const newWidth = vb.width / factor; const newHeight = vb.height / factor; // 保持鼠标下的点不变 const scaleX = mouseX / svg.clientWidth; const scaleY = mouseY / svg.clientHeight; vb.x = mouseX * (newWidth / svg.clientWidth) - (vb.x - mouseX * (vb.width / svg.clientWidth)); ... }这个实现比较绕,但核心逻辑就是等比缩放的同时平移视口,让鼠标悬停的那个业务坐标点不动。
3.3 导出能力:SVG、PNG、Markdown
图表画完总要沉淀到文档里,所以导出能力必不可少。我最常做的是导出SVG和PNG两种。SVG导出很简单,把渲染好的SVG DOM转成字符串,加一个Blob下载即可。PNG导出则要用到canvas绘制SVG内容,这一步最大的坑是跨域问题,如果SVG里嵌入了外部字体或图片,canvas会报安全异常,导出内容变成空白。
我的解决方式是,在导出前把SVG里所有引用的图片都转为Base64内联,并且将所有<style>标签里的规则直接内联到元素上。这样导出的SVG就是一个完全独立的文件,不会再依赖任何外部资源。
另外还有一项很有用的导出能力是Markdown代码块。团队写技术方案的时候,经常直接把图表的DSL以JSON形式贴在Markdown里,这样看代码的人能直接看懂结构,看文档的人能看到渲染图。我封装了toMarkdown()方法,把它输出成标准的代码块。这个功能一开始觉得很朴素,但后来发现是团队使用频率最高的功能之一。
3.4 真实案例:用这个引擎重画老架构图
这里分享一个我实际做过的迁移案例。团队里有一份微服务架构图,原本是用某在线画图工具手绘的,图里大概有40多个节点,并且很多节点名称是中文缩写,互相之间的连线也没标注协议。我拿到这个机会后,把这幅图转成了diagram-design的DSL描述,做了几步关键处理。
先把所有节点整理成了规范的kind类型:网关、服务、中间件、数据库。再把边全部补上方向语义,有几个依赖关系在旧图里画反了,迁移过程中直接被找出来了。接着我删掉了所有坐标信息,完全交给布局引擎自动排布,出来的图和旧图一比,节点排列整齐很多,几乎没有边交叉。最让团队惊喜的是,我把DSL提交进Git仓库后,可以在MR里直观看到每一次架构变更是增删了哪些节点、哪些依赖关系变了,这个体验比看图找差异高效太多了。
4. 常见问题与排查技巧实录
4.1 布局错乱:节点全叠在一起怎么办
这个问题发生频率最高,而且场景千奇百怪。最常见的原因是节点尺寸没设置,或者尺寸设置为零。布局算法在每个节点初始化时都会读取width和height,如果这两个字段缺失,算法会认为节点没有占用空间,于是所有节点都被排到同一个坐标上。
排查方法:在布局完成后,打印每个节点的坐标和尺寸,看看是否有x、y相同、width为0的情况。如果是尺寸缺失,可以设置一个默认值,或者在解析阶段给所有节点兜底赋值。
另一个导致叠在一起的场景是和分组嵌套布局相关。当一个父分组没有设置最小宽度,而子节点很多时,父分组宽度会被计算成0,导致所有子节点跑出可视区域。我的解决方法是给分组节点设置一个minWidth和minHeight,默认值分别是父节点内边距的两倍加子节点布局后的最大宽高。
4.2 文本溢出:节点小但文字很长
这是图表类工具永远避不开的问题。方案无非三种:截断、换行、缩放。我选择的是“自动换行 + 自适应节点宽度”。
自动换行的实现逻辑是:根据字体大小和节点宽度,计算出每行最多能放多少字,然后按词或按字符切分。英文按空格切,中文按单字切。切完之后,再根据行数重新计算节点高度,并动态调整相邻节点位置。这个功能放在布局算法内部,因为节点尺寸会直接影响布局结果。
如果开启自动换行后节点依然放不下,我的兜底方案是“省略号+悬浮提示”,鼠标悬停时显示全名。这个方法简单有效,不需要重排整个图。
4.3 渲染性能瓶颈:从800个节点降到60ms
一开始我贸然追求“帧率”,想着有没有可能在浏览器里流畅绘制上千节点的图。最终测试发现,纯SVG渲染2000个节点时确实会有卡顿,但把节点级别控制在500以下,渲染耗时基本在80ms以内,完全不影响使用。真正拖慢性能的是大量独立事件监听器。
我一开始给每个节点单独绑定了click、mousemove、mouseleave事件,节点一多,页面立刻卡。后来改成事件委托:只在SVG根元素上绑定一次事件,通过event.target.closest('[data-node-id]')判断点击到了哪个节点。这个改动让渲染和交互性能都提升了一截。类似的经验也适用于边和箭头,不需要每一条边都单独创建marker,复用同一个箭头定义即可。
4.4 协同编辑:多人同时改同一张图的冲突策略
迭代后期,很多团队会提出多人协同编辑的需求。这个需求的设计难点不在于如何广播数据,而在于冲突策略。我在这一块做了一个很轻量的方案:编辑锁。
当用户开始拖拽节点时,前端向后端申请该图表的编辑锁,其他用户拿到锁之前只能只读,不能编辑。拖拽结束时,图表的所有DSL数据以全量方式推送一遍给其他在线成员。这个方案简单直接,避免了复杂的CRDT协同算法,对内部工具来说足够可靠,也不用担心数据冲突。
4.5 常见问题速查表
| 问题 | 常见原因 | 快速解决方案 |
|---|---|---|
| 节点全部叠在一起 | 节点尺寸缺失或为0 | 解析阶段兜底设置默认宽高 |
| 边方向不对 | source/target约定不一致 | 统一定义“箭头=依赖方向”并写入文档 |
| 导出的PNG内容空白 | 外部图片/字体跨域限制 | 导出前将图片转为Base64内联,样式内联 |
| 缩放后文字模糊 | SVG导出被转成位图 | 优先导出SVG,或在导出PNG时设高倍率dpr |
| 节点拖不动 | 事件被遮挡或监听目标错误 | 检查是否加了pointer-events: none的遮罩节点 |
| 布局结果不稳定,每次刷新位置都变 | 力导向初始随机性太强 | 固定随机种子,或先按层级分配初始位置 |
4.6 一个独家避坑技巧:永远先把“数据合法性”校验放在第一步
这个项目的教训是:很多布局异常和渲染异常,根源都不在布局算法,而在数据本身。所以我后来写了一个严格的数据校验函数,放在引擎入口处,非法数据直接抛错并提示具体字段。
校验内容包括:node的id是否重复、edges里的source和target是否都能在nodes中找到、节点kind是否被主题支持、字段类型是否匹配。这套校验在开发期就跑出来很多潜在问题,帮团队省了大量查bug的时间。
5. 个人经验小结
这个项目做到第三周的时候,我一度觉得自研布局算法是不是走偏了,因为直接引入一个开源图算法库似乎更省事。但后来复盘,发现自研带来的收益远大于成本:一是对特殊需求的适配速度更快,比如分组嵌套布局的细节控制;二是对异常情况的可诊断性,出问题时我可以直接打印中间计算过程,而不是在黑盒库里反复猜。
如果你也要做类似的东西,我的建议是先别急于写代码,花一天时间把DSL设计好。DSL就是你的数据契约,契约稳定了,后面引擎、布局、渲染都是围绕契约打工。DSL改来改去才是整个项目最大的成本源头。
另外一个小技巧:在开发调试时,把布局结果实时可视化输出到画布,再叠加一个“显示轮廓”的开关,这样能快速看出布局算法是在哪个环节产生了错位。这个工具我至今还在用,每加一个新图型都能省下不少调试时间。