改图改到怀疑人生,大概是每个要做架构评审、方案汇报的人都经历过的事。我最早被"diagram-design"这个词勾起兴趣,是因为团队里一张系统架构图在两周内改了七版,每次评审都要手动拖拽对齐、调颜色、掰连线,改完导出一张PNG丢进文档里,下一次需求变更又得重来一遍。那时我就在想,图表这件事能不能像代码一样版本化、参数化、自动化:只描述"有哪些节点、谁连谁、什么样式",剩下的布局、渲染、导出全部交给程序完成。带着这个念头,我花了几周业余时间做了一个叫 diagram-design 的声明式图表设计工具,把日常画架构图、流程图、部署拓扑图的流程彻底重写了一遍。这篇文章就把这套工具的完整设计思路、核心实现、以及我踩过的坑从头到尾梳理一遍,希望能给你做一个代码化画图系统提供一些可以直接抄作业的参考。
1. 被反复修改的架构图逼出来的小轮子
1.1 传统画图工具的三大痛点:评审即返工、版本管理难、复用不可能
先说清楚我做这个项目的动机,不然你不知道我为什么要折腾一个"自研画图工具"。
当时我们做系统设计评审,用的是主流的拖拽式画图软件。这类工具确实强大,画出来的图漂亮,但一旦进入评审阶段就非常痛苦:
第一,评审即返工。评审会上总会冒出各种新需求:"网关和鉴权服务之间加一条虚线依赖""这个数据库分组要移到左下角""两个模块的边框颜色换成品牌色"。看似小改动,实际上每一次都得手动调整所有相关元素的坐标、连线、颜色,牵一发动全身。架构图一复杂,节点之间的连线交叉、重叠、绕行,手动调一次至少半小时,还未必调得好看。
第二,版本管理几乎不可能。导出的图片是一张位图,变更历史完全丢失。你根本说不清这一版和上一版之间到底改了哪些节点,也无法像看代码diff一样回顾演进过程。如果评审被否,想回滚到三天前的版本,只能凭记忆重新画。
第三,复用本质上是复制粘贴。这个系统里画的支付链路图,换个项目想复用,除了复制粘贴图片,没有更好的办法。想抽取一个公用的"网关模块样式",想在另一个架构图里引用同一个子图,拖拽工具没有这种抽象能力。
我相信这不是我一个人遇到的问题。认真想了想,画图这个工作其实和写代码很像:我需要的不是一张"画好的图",而是一份可维护、可复用、可自动生成的图表定义。
1.2 diagram-design 要解决的问题边界
有了上面的痛点,我给这个项目划定了明确的能力边界。它不要做成一个全能型绘图软件,而是专注做好一件事:用声明式数据描述图表,自动完成布局和渲染输出。
具体来说,我需要它满足四个条件:
- 写的是数据不是坐标。我只需要声明"系统A调用服务B,B读数据库C",工具自动算出每个节点该摆在哪、连线怎么走。
- 输出的是矢量结果。最终能导出高清SVG和PNG,而不是糊成一团的位图,这样才能直接放进方案文档。
- 能融进代码工作流。图表定义是一份XML或JSON文件,可以提交到代码仓库,可以做版本管理、评审diff,这对我来说比任何漂亮界面都重要。
- 可以编程调用和二次开发。别的项目想集成图表能力,可以像我一样直接调用布局和渲染的核心函数。
这个定位让我砍掉了大量不必要的工作:不搞交互式编辑器,不做拖拽画布,不追求花哨的视觉特效。一个系统设计图,节点清晰、关系明确、输出干净,比什么都强。
1.3 对比其他代码化方案之后,我选了一条自己的路
其实"用代码画图"并不新鲜,市面上已经有不错的方案:有的用文本描述生成流程图,有的用点语法描述图形结构,还有的直接用编写代码的方式控制画布。但这些方案各有各的别扭之处。
用文本描述生成流程图的方案很成熟,但它的语法是为段落式文本设计的,做复杂系统架构图时,分层分组、端口描述、样式定制都要靠边角料语法实现,表达能力有限。用点语法写图,控制力确实强,但学习成本高,而且布局算法对架构图这种"必须保持层次感"的场景并不算友好,经常出现我需要它横平竖直、它偏要给一个网状扭曲的布局。至于用代码直接画图形,虽然灵活度高,可一旦图复杂起来,本质还是回到了"手写坐标"的老路上。
所以我最后决定自己做一个具备"声明式描述 + 自动分层布局 + SVG渲染"的最小可用内核,再把扩展能力以插件形式暴露出来。这也是diagram-design这个项目最核心的定位。下文我会把这个内核的每一层拆开给你看。
2. 整体管线设计:DSL、布局、渲染三层分离
2.1 为什么一定要分三层
一个合格的图表工具,第一件事想清楚的不是怎么画,而是如何组织处理流程。diagram-design 的整体架构我没走"输入数据直接渲染"的捷径,而是拆成三层:描述层、布局层、渲染层。
拆三层的理由很实际。如果我让数据直接流向渲染,第一版可能很快,但后续加功能会非常痛苦。比如我想把布局算法从"分层"换成"力导向",渲染层不应该受到任何影响;我想把SVG渲染换成Canvas渲染,也不应该需要改布局代码。三个环节各自独立、只通过定义好的数据契约沟通,后面每一次功能迭代都省了大量时间。
这就像做菜:描述层是菜谱,布局层是备菜摆盘,渲染层是上桌。菜谱写清楚了,不管谁掌勺、装什么盘,菜还是那个菜。一旦这三者混在一起,改菜谱可能莫名其妙把盘子改坏了。
2.2 DSL:一张图表的最小描述单元
diagram-design 的输入没有定义复杂的脚本语言,而是直接采用 JSON 作为描述结构。选 JSON 而不是自创语法,原因很简单:任何语言都能解析,跟代码仓库和工具链的亲和力最好,也容易做校验和版本对比。
我给你看一个最简的例子。假设我要画一个典型的服务调用链,前端调用网关,网关调用订单服务和用户服务,订单服务访问订单数据库:
{ "nodes": [ { "id": "web", "label": "Web前端", "type": "client" }, { "id": "gw", "label": "API网关", "type": "gateway" }, { "id": "order", "label": "订单服务", "type": "service" }, { "id": "user", "label": "用户服务", "type": "service" }, { "id": "order-db", "label": "订单库", "type": "database" } ], "edges": [ { "id": "e1", "source": "web", "target": "gw", "label": "HTTPS" }, { "id": "e2", "source": "gw", "target": "order", "label": "RPC" }, { "id": "e3", "source": "gw", "target": "user", "label": "RPC" }, { "id": "e4", "source": "order", "target": "order-db", "label": "SQL" } ] }注意,这个描述里完全没有坐标信息。布局算法会根据节点类型、边的指向关系自动把这张图梳理清楚。这种数据结构的价值就在于:它是纯粹的领域逻辑,节点和关系是内容本身,坐标和渲染样式是算法推导出来的。
2.3 各层之间的数据契约
三层之间我定义了两个很重要的中间数据结构,一个叫LayoutGraph,一个叫RenderTree。
LayoutGraph是布局层的输出,也是渲染层的输入。它在原始节点边上增加了x、y、width、height字段,把"逻辑关系"翻译成"几何位置"。RenderTree则是在LayoutGraph的基础上进一步生成绘图层需要的元素结构,包括节点形状、边框样式、文字位置、连线路径等。
这样做的好处是每一层都可以单独测试。我能为布局算法写一套单元测试,断言"给定这样一张图,节点A必须出现在节点B的上一层左侧";也能为渲染层写测试,断言"给定这样一个坐标布局,生成的SVG根节点尺寸是多少"。分层带来的可测试性,在后面整个工具变复杂之后救了我无数次。
3. 节点与边模型:图表数据的"骨架"该长什么样
3.1 节点模型:光有id和label是不够的
项目做到中期我意识到,一个画架构图的系统,节点模型绝对不能只有 id 和 label 两个字段,否则后面所有能力都会受限。我在 diagram-design 里给节点设计了这样一套数据结构:
interface DiagramNode { id: string; label: string; type: "client" | "gateway" | "service" | "database" | "external"; width?: number; height?: number; x?: number; y?: number; style?: { fillColor?: string; strokeColor?: string; strokeWidth?: number; rx?: number; icon?: string; }; meta?: Record<string, string>; }type字段非常关键,它不只是给节点换个颜色,还会直接影响布局行为。比如database类型的节点,我希望它在分层布局中尽量靠下;client类型的节点尽量靠上;同类型的节点在视觉上通常更适合被安排在同一水平带内。meta字段则是扩展良药,比如我做部署拓扑图时,会在里面塞ip、env这些业务属性,渲染的时候把它们显示为节点副标题,完全不需要改核心逻辑。
3.2 边的模型和锚点系统:连线不是一条简单直线
边模型同样需要仔细设计。架构图中的边,远不止"从A到B一条线"那么简单,它有标签、有虚线实线、有不同语义的颜色,最重要的是它有锚点。
我举个例子,订单服务同时被支付服务、库存服务调用,这时候"订单服务"这个矩形节点的四个方向侧都有连线。如果连线都从节点正中心引出,会挤成一团,非常难看。正确的做法是给节点设置锚点:左侧锚点left、右侧锚点right、顶部锚点top、底部锚点bottom,边在定义时指定从哪个锚点出发、进哪个锚点:
interface DiagramEdge { id: string; source: string; sourceAnchor?: "top" | "right" | "bottom" | "left"; target: string; targetAnchor?: "top" | "right" | "bottom" | "left"; label?: string; kind?: "solid" | "dashed" | "dotted"; }锚点系统看起来是一个非常小的设计,实际带来的收益非常大。它让布局引擎不再需要计算"从矩形中心引出的线要怎么躲避矩形边界",而是直接使用锚点坐标作为连线的起终点,路径计算简单了一个量级。我强烈建议任何想做图表工具的人,第一版就把锚点做进去,后面补这个能力远比一开始就做要麻烦。
3.3 分组与容器:让架构图有"域"
画系统架构图,另一个高频需求是分组。典型场景是:订单服务和用户服务都部署在"业务中台"这个逻辑域内,整个域外面有一个大虚线框,上面写着"业务中台"。
diagram-design 使用groups字段描述这种容器关系:
{ "groups": [ { "id": "mid-platform", "label": "业务中台", "children": ["order", "user"] }, { "id": "infra", "label": "基础设施层", "children": ["order-db"] } ] }有了分组之后,布局算法会做两件事:一是将子节点整体视为一个布局单元,在安排位置时优先保证同一个组内的节点彼此靠近;二是计算整个分组的外接矩形,渲染出一个圆角虚线框,并把label渲染在框的左上角。
这个能力让diagram-design从"能画图"进化为"能画出有层次感的架构图"。我后面最常做的一件事,就是把一张几十个节点的系统图按业务域拆成三四个分组,图的阅读体验立刻上了一个档次。
4. 布局引擎:分层布局与力导向布局的实现取舍
4.1 架构图最常用的分层布局,我是怎么实现的
布局引擎是整个diagram-design最核心也最让我头疼的部分。如果图表是让计算机自动布局,最常见的算法有三种:分层布局、力导向布局、网格布局。对我来说,分层布局是架构图的第一选择,它最贴合人阅读系统架构的习惯:流向从上到下或从左到右,层次分明,依赖清晰。
我实现了一个简化版的层次布局,主要分四步:
第一步,根据所有边的方向梳理层级关系。图的入口节点们是第一层,它们指向的节点是第二层,以此类推。梳理的过程本质上是一个拓扑排序,如果图里有环,我需要先做破环处理——找到环中某一条反向的边,将其暂时忽略。
第二步,处理跨层边。假设第2层的节点直接连到了第5层的节点,如果不处理,这条长连线会横穿中间的第三、四层,非常难看。我的处理方法是引入"虚拟节点":在连线经过的每一层里插入一个透明的占位节点,让长边被拆成若干短边,整体看起来就整齐了。
第三步,计算每一层内节点的顺序,尽量让同层节点之间的交叉边最少。这一步我用的是一种简单但有效的启发式算法:先给每层节点一个初始排序,然后反复调整次序,统计边交叉次数,保留交叉更少的排序。虽然不保证最优解,但工程上八九成的场景都能处理好。
第四步,根据每层最大节点高度计算每一层的Y坐标,根据每层节点数量和间隙计算X坐标,最终把抽象层级映射为具体坐标。
分层布局出来之后的效果,基本就是我写方案时想要的样子:流向明确、结构清晰。但也必须承认,它只适合"有明确上下游关系"的图,如果图中有大量双向依赖,分层效果就会退化,这时候需要力导向布局兜底。
4.2 力导向布局在最坏情况下的兜底
力导向布局的概念简单说就是"给节点之间添加弹簧和斥力":边连接的两个节点被弹簧拉近,所有节点之间互相排斥,多次迭代后系统进入稳定状态,节点自然散开,连线交叉也趋于减少。
我把力导向布局定位为"兜底方案",而不是首选,原因在于它有两个缺陷:一是迭代结果不稳定,每次跑出来的图可能不完全一样;二是它没有方向性,不适合强调层级关系。但它的优势也很明显,处理网状结构、复杂依赖关系时,手动布局要摆半天,力导向几轮迭代出来的初稿往往已经可以用。
我实现的力导向模型并不复杂,核心就是一个迭代循环:
function forceLayout(nodes: LayoutNode[], edges: LayoutEdge[], iterations = 300) { for (let i = 0; i < iterations; i++) { // 第一步:计算任意两节点之间的斥力 for (let j = 0; j < nodes.length; j++) { for (let k = j + 1; k < nodes.length; k++) { const dx = nodes[k].x - nodes[j].x; const dy = nodes[k].y - nodes[j].y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const force = (repulsion / dist / dist); nodes[j].x -= (dx / dist) * force; nodes[k].x += (dx / dist) * force; // y 方向同理 } } // 第二步:计算边两端的引力 for (let e = 0; e < edges.length; e++) { const s = nodes.find((n) => n.id === edges[e].source)!; const t = nodes.find((n) => n.id === edges[e].target)!; const dx = t.x - s.x; const dy = t.y - s.y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const force = (attraction * dist * dist) / 100; s.x += (dx / dist) * force; t.x -= (dx / dist) * force; } } }实际使用时我会把这两种布局暴露为同一个接口,用户在描述文件里声明"layout": "hierarchical"或"layout": "force"即可。对于大部分架构图,我用分层布局;对于微服务网状调用图,我用力导向先出初稿,如果节点不多,直接在初稿基础上微调。
4.3 固定坐标:自动布局解决不了时的手动锚点
布局引擎再智能,也有失灵的时候。一种常见场景是画部署拓扑图,网络分区、机柜位置是有物理约束的,自动布局强行给你排出一个抽象的美观布局,反而失真。还有一种场景是,某张图就是要严格按企业VI要求摆位置,比如左上角固定放Logo节点。
所以diagram-design的每个节点都支持显式指定x、y坐标。只要节点有坐标,布局引擎就跳过它,把它当作已经锚定的元素,其余节点围绕锚定节点进行布局。
这算是给自动布局打了个补丁,但它是必须存在的补丁。一个声称"自动布局"的工具,如果不给用户留手动余地,往往会在真实世界中很难用。自动和手动之间要有清晰的优先级:显式坐标 > 分组约束 > 自动布局,这一条规则被我写死在引擎里。
5. 渲染与交互:SVG坐标、缩放、高清导出
5.1 为什么选SVG而不是Canvas
布局引擎算好坐标之后,剩下的事情就是把图形画出来。diagram-design的渲染层一开始就选了SVG,原因很实际:
第一,SVG是矢量的,导出高清图天然占优势。方案文档、招标文件里需要的图分辨率一次比一次高,SVG转任意分辨率都不会糊。第二,SVG是DOM结构,浏览器天然支持事件绑定,想给某个节点加点击事件、 hover 态、tooltip,不需要额外引入交互框架。第三,调试太方便了,生成的SVG可以直接拿文本编辑器打开检查,哪个节点坐标错了直接定位。
Canvas 更适合高频动态更新的场景,比如实时监控大屏、上万节点的可视化,但画架构图没有那么高的帧率需求,所以我的原则非常简单:能用SVG的地方坚决用SVG。
渲染层我把图形的每个元素拆解为可组合的渲染子函数,节点形状和连线路径都做成独立的模块。后续如果某些用户确实需要Canvas渲染,我可以只替换渲染层的实现,前面的布局层完全不用动。
5.2 缩放平移的坐标换算:一个容易做错的细节
没有交互可以,但一个图表工具连缩放平移都没有就有点说不过去了。diagram-design支持最简单的缩放:鼠标滚轮缩放、按住空白处拖动画布。
这里有一个特别容易做错的细节:缩放后,鼠标的当前坐标需要从屏幕坐标换算成画布坐标。我一开始没换算,直接拿event.clientX当节点坐标用,结果是缩放倍率一变,点击和拖拽全部错位。正确的做法是这样的:
function screenToSvg(svg: SVGSVGElement, clientX: number, clientY: number) { const point = new DOMPoint(clientX, clientY); const ctm = svg.getScreenCTM(); if (!ctm) return null; const transformed = point.matrixTransform(ctm.inverse()); return { x: transformed.x, y: transformed.y }; }每次处理鼠标事件,一定先经过换算再操作节点坐标。这个坑我建议所有做SVG可视化的人提前规避,它一旦出错,整个交互全部完蛋,而且排查起来还很隐蔽。
5.3 高清导出的三个坑
导出PNG是图表工具的硬需求,方案文档里总要放一张位图。实测下来,这个过程容易踩的坑有三个:
第一个坑是导出空白图。原因是SVG里的字体、样式定义在导出到Canvas时没有正常加载。我的解决方案是先把SVG序列化为一个Blob对象,再用createImageBitmap或者new Image()+objectURL加载,等图片的onload事件触发后再绘制到Canvas,不要急着连续操作。
第二个坑是模糊。默认绘制1倍的Canvas,导出的图在Retina屏上看起来发虚。解决办法是设置Canvas的实际尺寸为要导出尺寸的高DPI版本,再用ctx.scale(ratio, ratio)缩放绘制。
第三个坑是样式丢失。很多内联样式在SVG里没问题,但放入Canvas就没了。一个比较省心的做法是,在导出流程开始前,遍历所有DOM元素,把它们计算后的样式显式设置到fill、stroke这些属性上,不要依赖CSS规则继承。这一步虽然笨,但有效解决了大部分样式丢失问题。
6. 真实使用中踩过的坑和性能优化
6.1 中文文字宽度测量的坑
图表里的文字,尤其是中文标签,宽度测量是所有渲染环节里最容易被低估的问题。英文和数字在等宽或比例字体下,可以用"字符数量乘平均宽度"来估算,但中文不行。中文是方块字,实际宽度跟字体、字号、是否加粗强相关,估算偏差稍微大一点,就会导致节点矩形框包不住文字,或者文字溢出边框。
我的做法是:布局阶段不估算,直接用Canvas的measureText测量精确文字宽度。具体是先创建一个离屏Canvas上下文,设置与SVG渲染相同的字体和字号,然后调用ctx.measureText(label).width。测量结果传给布局引擎,节点宽度就按文字宽度加固定边距计算。这个办法准确率非常高,代价可以忽略不计。
另外还有一个中文排版细节:SVG里使用<text>标签渲染中文时,如果希望文字在节点内部水平居中,绝对不能依赖单个<text>的text-anchor="middle"就完事,因为多行文字时需要分别处理每一行的y坐标,否则行与行之间会叠在一起。我在渲染文字时做了一个简单的换行和行高计算,传入lineHeight参数,这个细节让节点内的多行文案排布规整了很多。
6.2 连线交叉与绕行:永远比想象中难处理
连线交叉问题是我花时间最多的地方。分层布局解决了"长边贯穿"问题之后,同层之间的交叉、以及节点间距过近导致的连线重叠,又冒出来了。
第一版连线用的是直线,直线最简单,但在复杂图上看起来极其廉价。后来我改成正交连线:先水平走一段,再垂直走一段,最后再水平进入目标锚点。这种"横平竖直"的连线风格非常符合架构图的审美习惯。
正交连线有个附加问题:折线拐弯处如果直接相交,可读性差。我在路由算法里加入了绕行逻辑,简单说就是当检测到连线路径与其他节点边界框相交时,把这条路"顶"到节点的外侧绕过去,形成走外圈的路径。
绕行算法不必做得特别深,有两点做到就够用:一是路径点要落在节点矩形之外的网格上,避免从节点身上踩过去;二是折线的拐角间距保持一致,视觉上才整齐。图特别复杂时,我还会允许用户手动指定边的waypoints数组,相当于人工干预路由结果。
6.3 节点上千时的渲染性能优化
diagram-design最初的定位是画系统架构图,一般几十个节点,性能压力不大。但后来有同事拿它画一张全链路数据流向图,节点数直接逼近上千,就暴露出了问题:拖动缩放时明显卡顿,每秒帧率掉到了个位数。
性能瓶颈主要在两个地方。第一是DOM数量太多,上千个节点、上千条连线,加上每一条连线的多个路径占位元素,SVG里轻松超过四五千个DOM节点,浏览器重排重绘压力巨大。第二个是布局算法耗时,力导向布局里每次迭代都要对节点做两两配对,几千个节点就是百万级运算量单次迭代,几百次迭代就彻底卡死。
我的优化手段有三招。一是开启will-change和分层合成,把需要频繁变化的画布层级单独提出来。二是在布局上引入近似计算,把节点按区域划分成桶,只计算同桶和相邻桶节点之间的斥力,把O(n^2)降到了可接受的范围。三是运行时节流,缩放平移操作通过requestAnimationFrame合并成每帧最多一次重绘。
这三招联手后,上千节点的图虽然离丝滑还有距离,但至少不再卡到不可用。如果未来需要支撑万级节点,我会认真考虑换个渲染方案,用 Canvas 重写渲染层。架构上因为渲染层已经隔离好了,这个替换的代价是可控的。
6.4 这工具现在还缺什么,以及我的补全方向
说完了做出来的东西,也得承认diagram-design还不是一个完整的商业级图表产品。它目前最大的缺憾是没有可视化编辑器。虽然我享受"写JSON自动出图"的纯粹感,但让团队里非技术背景的同事通过文本定义图表,门槛确实偏高。下一步我打算做一个轻量级编辑器,核心是"左侧拖拽节点、画布实时渲染、右侧面板修改属性",本质上就是把DSL视觉化,但所有数据后端仍然走同一套布局和渲染管线。
第二个缺憾是布局算法对某些特定图仍然不够聪明。比如UML时序图,它对生命线和消息顺序有非常强的语义要求,通用分层布局很难直接满足。这块我的计划是以插件机制扩展布局算法,把时序图、ER图的布局作为独立插件注入,而不是把核心引擎无限做大。
第三个缺憾是协作能力。现在多人同时对同一份图表定义做修改,只能借助代码仓库的合并和冲突解决。后续如果有余力,我想基于操作日志做多人协作支持,让每个人改的是节点级的原子操作,而不是整文件覆盖。
说到底,这个项目从"改图改到吐"的个人需求出发,最后做成了一套有描述层、布局层、渲染层三层解耦的图表设计框架,最让我有成就感的是,团队现在的架构评审材料可以直接从代码生成,图表的每一次演进都留在提交记录里,可以回溯、可以对比。改图这件事终于不再是一场灾难,而是一个被数据和算法解决的问题。最后分享一个我自己的体会:任何可视化系统,数据模型永远是地基,布局引擎是骨架,渲染是外表。把地基打牢、把骨架做正,外表再朴素也能用;反过来,只追求外表绚丽,地基一塌,后面每一步都会加倍地还债。