
1. 项目起因从画图两分钟改图两小时到自研 diagram-design说句实话做技术这行久了最烦的事情不是写代码而是画图。这里的画图特指画系统架构图、业务流程图、时序图、数据流向图这一类设计图。你需要跟同事对齐方案需要给下游团队交代接口边界需要在评审会上把思路讲清楚结果打开画图软件面对一块空白画布光是拖拽控件、调对齐、改配色就能耗掉一下午。更要命的是需求一变整张图的结构就跟着变你不得不手动挪一堆框和线。我个人最高纪录是有一次改一张核心链路图改到凌晨两点最后发现只是把两个模块换了个位置。diagram-design 这个项目就是冲着这个痛点去的。它本质上是一个用数据描述图表、用算法自动排版、用代码渲染出图的可视化设计工具。你不再需要手动拖动每一个元素而是用结构化的方式描述有哪些节点、节点之间什么关系、分组归属如何剩下的布局、连线、样式统一交给引擎处理。它既能用来画快速演示用的白板级草图也能作为技术文档平台、低代码平台、甚至内部运维大屏的一部分被集成进去。这个项目最早是从一个模拟项目X的内部需求里长出来的。当时某跨平台系统要做模块边界梳理团队里三个同学各画一版架构图画出来的东西互相之间根本对不齐。有人用 UML 工具有人用在线表格里嵌方块还有人直接在文档里用字符拼。后来我就想与其继续在画图工具里较劲不如先定义一套图表的数据结构再写一个渲染器把这些数据变成图。diagram-design 的第一行代码就是这么落地的。它不追求成为下一个专业绘图软件而是想解决图应该跟着数据走而不是跟着手走这个根本问题。从适用人群来说这个项目最适合三类人一类是经常要画技术方案图但不想被图形化编辑器折磨的开发者一类是需要在文档或博客里嵌入架构讲解图的内容创作者还有一类是低代码平台、数据中台、流程可视化产品的开发者他们需要把图表渲染能力当成一个模块嵌到自己的系统里。如果你属于其中任何一种这篇文章里讲的思路、代码结构、踩坑记录应该都能直接拿过去用。2. 技术选型与核心原理为什么用数据驱动渲染代替手动拖拽刚开始构思 diagram-design 的时候我面临一个特别现实的选择到底是做一个类似传统画布工具的编辑器还是做一个数据驱动渲染引擎前者你见过无数个画布、图元、拖拽、缩放、对齐辅助线交互很直观但代码复杂度极高光是一个框选、旋转、吸附就能写掉几千行逻辑。后者强调输入数据、自动排版交互少一些但胜在结构清晰、可控、可嵌入。我选了后者。原因很简单我真正需要的是一个能把架构想法快速变成看得懂的图的东西而不是又一个需要花半小时学习工具栏的编辑器。2.1 核心数据模型节点、边、分组与锚点diagram-design 的数据模型脱胎于图论里的有向无环图DAG概念但做了面向设计场景的扩展。最核心的三个概念是节点Node、边Edge和分组Group。节点描述一个方框/圆圈/任意形状字段大致是interface DiagramNode { id: string; type: string; // 节点类型如 service、database、external label: string; width?: number; height?: number; style?: Recordstring, string; // 覆盖默认样式 data?: Recordstring, unknown; // 业务自定义数据 }边描述一条从 A 指向 B 的连线字段包括起点节点、终点节点、连线的标签、连线类型直线、折线、曲线等。分组描述哪些节点属于同一个子系统/泳道/模块后续会自动生成背景色块或泳道边界。这套模型最大的好处是它是稳定的、可序列化的、可 diff 的。你可以把一张图保存成 JSON放到 Git 里做版本管理也可以从后端接口动态组装出这套结构实现接口一变图自动跟着变。相比之下传统画布工具保存的通常是每个元素的绝对坐标和样式那种格式天然不适合做自动化。2.2 渲染层设计为什么用 SVG而不是 Canvas图表渲染我最终选择了 SVG没有用 Canvas。原因很直接这个项目里图的交互主要是点击、悬浮、选中而不是每帧 60 帧的粒子动画。SVG 的 DOM 天然支持事件绑定、CSS 样式、浏览器无障碍树调试起来也非常直观你可以在开发者工具里直接查看某个路径节点绑定了什么数据。Canvas 在绘制几千个节点时性能会更好但它的拾取hit-testing和事件处理需要自己实现开发成本明显更高。实际测试下来SVG 在节点数不超过 3000 的情况下交互流畅度依然可以接受。对于架构图、业务流程图这种典型场景几百个节点就已经是很大的图了。2.3 为什么不用现成的拖拽库肯定有人会问市面上现成的图编辑库那么多为什么还要自己写我当时的评估是现有库各有各的绑定。有的偏流程引擎有的偏脑图有的偏关系图谱它们对自定义节点样式自定义交互事件和业务数据深度绑定这些需求支持得并不好。我们可以用第三方的渲染能力作为底层但真正的数据结构、布局算法、交互策略必须自己做否则最后一定会被一个边界 case 卡住。这里贴一张当时的技术选型对比供你参考方案布局可控性自定义节点二次开发成本包体积通用图编辑库中一般低但限制多大专用流程库低弱低中自建 SVG 渲染 布局算法极高自由中高可控自建方案看上去成本更高但长期看收益最大。尤其当你的业务需要数据变化后图自动重排这个能力时自建方案可以真正做到把布局逻辑完全握在自己手里。3. 关键功能拆解自动布局、连线交互与样式体系diagram-design 的核心竞争力不是能画图而是图会自己长好。这里面最花心思的是三个模块自动布局引擎、连线交互、样式主题体系。另外还附带了一套扎实的导入导出工具链。3.1 自动布局算法从层级排列到力导向自动布局是整个项目里最硬核的部分也是我花时间最多的地方。它解决的是给定节点和边的数据如何让图看起来不混乱、不重叠、层次清晰。对于架构图和流程图我使用最多的是分层布局算法layered layout。思路可以这样理解把有向边作为依赖方向先给每个节点分配一个层级layer同一层级的节点尽量放在同一水平线或垂直线上再对层内节点排序以减少边的交叉点最后调整坐标消除节点重叠。具体实现上我用了一个经典的思路构建 DAG如果没有环就做拓扑排序如果有环先做环检测并把回边标记出来最后再单独绘制。按拓扑序给节点分配层级保证每条边的终点层级大于起点层级。对每一层的节点按中位数法排序让相连的边尽可能靠近这一步能显著减少线交叉。计算每层的宽度和高度分配最终坐标。当图数据里没有明确的上下游关系时比如知识图谱、系统依赖全景图我会切换到力导向布局。力导向布局的物理模型很好理解节点之间像弹簧有边相连的节点互相吸引所有节点之间又存在斥力不能挤成一团整个系统经过若干轮迭代后达到受力平衡。虽然这种布局的结果有一定随机性但在展示复杂关系时接受度非常高。// 一个简化版的力导向迭代过程 function tick(nodes: Node[], edges: Edge[]) { nodes.forEach((n) { // 计算斥力 nodes.forEach((other) { if (n.id other.id) return; const dx n.x - other.x; const dy n.y - other.y; const dist Math.sqrt(dx * dx dy * dy) 0.01; n.vx (dx / dist) * (repulsion / (dist * dist)); n.vy (dy / dist) * (repulsion / (dist * dist)); }); }); edges.forEach((e) { // 计算弹力让相连节点靠近 const source nodes.find((n) n.id e.source); const target nodes.find((n) n.id e.target); // 更新速度和位置... }); // 迭代 300 次后收敛 }如果你只是为了快速出一个能看的图不用纠结算法细节直接把数据交给布局引擎即可。但如果你想深度定制比如让某一层固定横向排列、让某个节点固定坐标就需要在布局前后加入锁定和回写两个步骤。3.2 连线交互路径计算与锚点偏移连线是图设计里最容易被忽略、也最影响观感的功能。多数人觉得连线不就是一条线嘛实际上线的路径、箭头、拐弯、避让都很有讲究。在默认的直线连接方式之外我实现了三种连线类型直线适合节点少、关系简单的图。折线正交路径适合架构图横平竖直专业感强。曲线贝塞尔适合展示流向柔和适合大屏可视化。折线的计算是最麻烦的。两个节点之间有多个候选路径我需要选择一条不会穿过任何节点矩形的路径。我实现了一个简化版的 A* 路径搜索把画布网格化把有障碍物节点占据区域的网格标记为不可通行然后搜索最短的曼哈顿路径。为了效率我限制搜索范围只覆盖起点和终点周边的网格而不是全图。这样大部分连线可以在毫秒级返回路径。这里有一个特别值得说的设计锚点偏移。默认情况下连线会连到节点的几何中心但如果一个节点同时连出好几十条边所有线都从中心出发看起来就像毛线团。我实现了一个基于接线端口port的机制节点上下左右四个方向各有一个端口布局引擎会计算边的起点和终点应该落在哪个端口上这样连线就不会穿过节点中心观感会干净很多。比如 A 节点在左边B 节点在右边那么 A 到 B 的线就自然地走 A 的右端口到达 B 的左端口。3.3 样式主题体系让图开箱即用且有品牌感一个看起来专业的图不只需要布局对还需要配色统一、字体对齐、间距合理。diagram-design 里我做了两套内置主题一套浅色主题适合文档和 PPT一套深色主题适合大屏和演示。每个主题包含节点填充色、边框色、文字色、连线颜色、箭头大小、分组背景色、泳道间距等一系列变量。更关键的是我把样式拆成了三层覆盖机制。最底层是主题默认值中间层是节点类型级样式比如所有数据库节点都是圆柱形蓝色背景最上层是单个节点的内联样式。这样做的好处是修改一个变量整张图的风格都会跟着变但如果某一个节点需要特别标注只需加一行内联样式即可。样式系统还支持条件渲染。比如节点有一个 status 字段值是 running、warning、error 之一。渲染器会自动把节点的边框颜色映射为绿色、黄色、红色。这个功能在做系统状态图时极其好用不需要任何额外代码数据一刷新图就带上了健康度信息。3.4 数据导入导出JSON、代码语言与 PNG/SVGdiagram-design 的输入输出能力是这样的输入侧支持 JSON 数据和一段简单的脚本式描述语言。脚本式描述语言是我自己定义的比如group 用户端 { 移动端App - 网关服务 Web端 - 网关服务 } group 服务层 { 网关服务 - 订单服务 网关服务 - 用户服务 }渲染器逐行解析这段文本自动生成节点和边。它的价值在于你在写技术文档时可以用一种近乎伪代码的方式描述架构不用手动去碰坐标。输出侧则支持 SVG 和 PNG也支持直接复制代码格式。SVG 用于矢量场景放到文档里可以无限缩放PNG 用于 PPT 和分享。值得一提的是我在这里做了个不完美但实用的决定不使用任何第三方大型渲染引擎所以导出 PNG 时是调用浏览器端的画布一次性重绘图片清晰度完全够用。4. 实战演示用 diagram-design 画一张订单系统架构图说了这么多原理直接给你跑一遍最典型的用法画一张订单系统的服务架构图。这张图包含 7 个节点、3 个子系统分组、若干条带箭头的调用链。4.1 从零定义一张图的数据结构我用一个普通项目的数据来举例。假设系统有这几个模块前端 Web、移动端 App、API 网关、订单服务、用户服务、支付服务、消息队列、数据库。调用关系是前端和 App 调用网关网关转发到订单服务、用户服务订单服务调用支付服务订单服务和支付服务都读写数据库订单服务向消息队列发送事件那么数据大概长这样const graph { groups: [ { id: client, label: 客户端, nodes: [web, app] }, { id: server, label: 服务层, nodes: [gateway, order, user, pay] }, { id: infra, label: 基础设施, nodes: [mq, db] } ], nodes: [ { id: web, type: web, label: Web 前端 }, { id: app, type: app, label: 移动端 App }, { id: gateway, type: server, label: API 网关 }, { id: order, type: server, label: 订单服务 }, { id: user, type: server, label: 用户服务 }, { id: pay, type: server, label: 支付服务 }, { id: mq, type: mq, label: 消息队列 }, { id: db, type: db, label: 主数据库 } ], edges: [ { source: web, target: gateway, label: HTTPS }, { source: app, target: gateway, label: HTTPS }, { source: gateway, target: order, label: RPC }, { source: gateway, target: user, label: RPC }, { source: order, target: pay, label: RPC }, { source: order, target: mq, label: 事件 }, { source: pay, target: db, label: SQL }, { source: order, target: db, label: SQL } ] };你不需要指定任何坐标。渲染器拿到这份数据后先按 group 的声明顺序确定布局主方向再对每组内部做层级排序最后算出画布总尺寸并把图居中。整张图从数据输入到渲染完成耗时大约几十毫秒这在 hand-made 阶段是难以想象的。4.2 自定义节点模板给数据库加个圆柱形外观每个节点类型可以通过模板函数自定义外观。模板函数接收节点的基本数据返回一组自定义的 SVG 片段。比如数据库节点我想要一个圆柱形的效果diagram.registerNodeType(db, { render(ctx) { const { width, height, label } ctx.node; return ellipse cx${width / 2} cy${height * 0.15} rx${width * 0.4} ry6 fill#e8f0fe stroke#4a7ab5 / path dM ${width * 0.1} ${height * 0.15} L ${width * 0.1} ${height * 0.85} A ${width * 0.4} 6 0 0 0 ${width * 0.9} ${height * 0.85} L ${width * 0.9} ${height * 0.15} fill#f4f8ff stroke#4a7ab5 / text x${width / 2} y${height * 0.5} text-anchormiddle${label}/text ; } });这里用 SVG 路径手工画了一个圆柱轮廓上面是一个椭圆下面是一个矩形加椭圆弧底。你不需要会复杂的 SVG 语法只要懂得最基础的 path 命令M 移动到、L 画直线、A 画圆弧就可以定制出绝大多数形状。我的经验是先画一个矩形占位再把四角倒角、把底部改成弧线一个看起像样的图标节点就出来了。4.3 导出与嵌入生成一张可以直接放进方案文档的图图渲染完成后调用导出接口const svgString diagram.toSVG(); const pngDataUrl await diagram.toPNG({ scale: 3, // 三倍分辨率保证打印清晰 backgroundColor: #ffffff });SVG 字符串可以直接嵌入 HTML也可以保存成.svg文件。PNG 是 base64 格式的数据 URL粘到文档编辑器里即可。如果是在线文档平台还可以直接把渲染器挂到页面里数据一变图就自动更新完全不依赖人工二次截图。这一套流程跑通后我再画方案图的速度提升非常夸张。以前画一张订单架构图最快也要半小时现在只要数据结构写对了图几乎是瞬间生成。我的习惯是直接在版本管理工具里维护一份.graph.json文件每次技术方案评审前改下数据导出新图再也不用为图片忘记更新这件事背锅了。5. 在开发与真实使用中踩到的坑从零写一个图渲染引擎坑一定不会少。我在这里把最容易复现的四个问题列出来如果你也打算做类似的事可以直接绕开。5.1 布局抖动为什么每次渲染图的位置都不一样第一个坑发生在分层布局里。问题表现是同一份数据连续渲染两次节点位置完全不一样。查了很久才发现问题出在层内节点排序时依赖了哈希表的遍历顺序而哈希表的顺序在不同浏览器进程里并不是稳定的。解决方案也简单所有节点在进入布局引擎前先按 ID 排序再进行排序计算。这样只要数据不变输出的坐标就是确定的。这个改动虽然不起眼但对于导出后对比 diff这个场景至关重要否则每次提交记录里的图片都会因为布局随机而看不出真正改了什么。5.2 坐标精度与缩放导出图片后边缘被裁掉第二个坑是缩放时以左上角为原点导致的内容裁切。当我实现了画布缩放功能后发现缩放到很大比例再导出 PNG图片右下角的节点会被裁掉。原因是渲染器把画布的宽高固定死了而缩放后的节点坐标已经超出原画布范围。后来我改成了自适应画布模式在导出前先计算出所有节点的包围盒bounding box然后动态调整画布大小和视口偏移。这个思路也适用于内嵌场景——如果容器容器尺寸变化画布可以自动缩放居中而不是固定在某个视口里。5.3 大规模图的性能瓶颈一次性渲染 vs 分批渲染当我把图的数据量测试到 2000 个节点、3500 条边时页面已经明显卡顿。初始渲染时间接近 2 秒而且拖动时没有摩擦感。我的优化方案是把渲染拆成主树同步渲染 附件异步渲染主树核心主干节点通常是深度优先搜索的前几层先渲染用户能立刻看到图的主体结构。其他节点分批在后续帧中补充浏览器的requestIdleCallback就是干这个的。同时对所有边做了合并处理把同方向的边折叠为一条带多个箭头的路径减少 DOM 数量。这个方案实施后初始可交互时间降到了 400 毫秒左右对架构图应用场景来说完全够用。如果用 Canvas也许还可以更强但考虑到 SVG 的开发效率和 debug 体验我依然不后悔这个选型。5.4 撤销重做比想象中麻烦的快照问题一开始我以为撤销重做很简单保存操作栈每次操作 push 一份快照撤销时 pop 然后重置数据。但真正做下去才发现如果图里有上千个节点每次 push 完整快照都会消耗大量内存稍微操作几次页面直接就崩了。后来我改用命令模式command pattern每个操作都实现apply和revert两个方法。比如移动节点这个操作我记录的只是每个节点的 id 和坐标变化量而不是全量节点数据。撤销时反向应用坐标变化重做时重新应用。这样内存开销极小而且大部分操作很快就能执行完。这个经验虽然简单但让我意识到图编辑器的数据管理本质上和数据库的 undo log 是一个思路——记录变化增量而不是记录全量数据。6. 给想自己做图设计引擎的人的建议如果你看完这篇文章也想在自己的项目里做一个同样轻量的图设计能力我给你几个很现实的经验。6.1 起步范围一定要小从一个固定方向开始不要一上来就想做一个什么图都能画的引擎。先只支持层级分明的架构图把它做成熟后再加泳道、再加分组、再加任意有向图。diagram-design 早期代码里有许多对特定场景的特殊处理正是这些不通用的代码把产品打磨到了可用状态。通用性会随着你对业务的理解自然增长不要强求第一版就能覆盖所有图。6.2 数据模型比渲染算法更重要我见过很多人一上来就研究布局算法、贝塞尔曲线、力导向物理引擎这些当然重要但如果没有一个稳定、可扩展、可序列化的数据模型所有算法最后都会变成空中楼阁。我的建议是先把DiagramNode、DiagramEdge、DiagramGroup这几个核心接口定义清楚再加style、data、ports这些扩展字段。接口设计得很稳后面加什么功能都不慌。6.3 优先支持嵌入再做独立编辑器diagram-design 的第一版本质上不是编辑器而是一个渲染器。我先把渲染器做得足够好然后在它的基础上封装了一个只读展示组件最后才加了属性面板和拖拽创建节点之类的编辑功能。这个顺序的好处是即使编辑功能还很不成熟渲染能力已经可以给别人用了。很多内部工具、文档系统、大屏项目需要的就是纯展示能力这部分价值转化最快。我个人的体会是图设计工具看起来是一个锦上添花的组件但它真正解决的是团队沟通成本的问题。当图可以通过数据自动生成、自动更新、自动嵌入到文档中时你就再也不会画出一张图片后还要手工改版。diagram-design 离一个商业级产品还有距离但它已经把数据到图这条链路打通了剩下的事情无非是沿着这个方向继续加功能和打磨体验。如果这个项目对你也有启发我建议你也动手写一版最小实现不用复杂能渲染十个节点、三条边、一张分层布局就足够你感受到数据驱动设计这件事的真正魅力了。