资讯详情

资讯详情

数据驱动图表设计:用JSON和DSL实现架构图自动生成

做技术方案最烦什么对我来说不是写代码是画图。架构图、流程图、拓扑图、时序图每次汇报前都要花大量时间拖框框、拉箭头调整位置比写代码还累。后来我干脆自己动手做了一个图表设计项目名字就叫diagram-design。这个项目的核心思路很简单用一份结构化数据描述图表交给渲染器自动画出来而不是靠鼠标一个个拖。它能生成架构图、流程图、状态图、拓扑图也支持嵌入 Web 应用做展示或编辑。适合前端开发者、技术文档维护者以及所有需要频繁产出图表的人参考。1. 项目初衷与整体设计思路1.1 为什么不用现成的画图工具市面上画图工具不少但拿来做技术方案总有不顺手的地方。有的工具功能很全可惜是桌面软件画完要导图、截图、粘贴到文档里一次两次还行改一版就痛苦。有的工具支持在线协作但导出的样式跟你自己的品牌规范完全不搭调整主题色要折腾半天。还有一些代码生成图表的方案语法够简洁但碰到复杂布局、特殊连线就力不从心。我做diagram-design之前也试过用通用绘图产品。踩过最深的一个坑是版本管理用拖拽方式画出来的图本质上是二进制文件或者私有格式存进 Git 之后没法做有意义的 diff多人协作时互相覆盖回头想找回某个历史版本只能干瞪眼。而代码驱动的图数据就是 JSON 或 DSL 文本每次改动都能通过版本库留下记录评审时能清楚看到这次结构调整了哪些节点、哪条连线改了对端体验完全不一样。所以diagram-design从第一天起就定了一条路图表由数据生成代码库里的产物是数据文件而不是“画布”。这个选择带来两个额外好处。一是任何程序都能动态生成图表比如监控系统里把服务器的拓扑关系自动渲染出来这人力拖拽做不到。二是图表可以嵌入自研后台作为一个 React/Vue 组件存在而不是一个独立产品。1.2 DSL 设计用最少代码描述图表既然决定数据驱动第一个要解决的问题就是数据格式长什么样。我参考了常见的流程图定义设计了一套尽量小的 DSL刻意保持“一张图 节点列表 连线列表 配置信息”。下面这段就是最基础的结构{ title: 订单服务架构, nodes: [ { id: gateway, type: box, label: API网关, x: 40, y: 40, width: 160, height: 60 }, { id: order, type: box, label: 订单服务, x: 260, y: 40, width: 160, height: 60 }, { id: db, type: cylinder, label: 订单库, x: 460, y: 40, width: 120, height: 80 } ], edges: [ { id: e1, source: gateway, target: order, label: HTTP }, { id: e2, source: order, target: db, label: SQL } ] }这个格式可能一眼看上去很普通但里面的选择是有讲究的。节点不用数组下标互相引用而是用id字符串是为了后续支持缩放、分组、折叠时引用稳定坐标x/y明确给出是为了渲染引擎不需要做太多推导用户想固定布局就固定布局。连线里的source/target引用的是节点id这样增删节点不会导致连线索引错乱。为什么不用 Markdown 风格或者纯 YAMLYAML 写起来更舒服但解析出一个坏缩进就报错不适合作为机器生成和编辑的格式。JSON 的好处是人人都会看、都敢改配合类型定义还能在编辑器里做自动补全和校验。实际开发中我更推荐用 TypeScript 类型别名来约束结构这样数据写错时编译阶段就能发现不用等运行时渲染出诡异效果再排查。1.3 渲染管线分层数据格式定了之后渲染就不是简单把节点画上去而是走一条完整的管线。我把整个流程切成四层数据层、布局层、渲染层、交互层。数据层是入口负责解析、校验和归一化。比如用户传的节点坐标可能是缺失的布局层会自动算用户传的连线没有指定路径渲染层会通过路由算法补一条。校验这步我后来加的很用力因为实际用起来总会有人把source写成不存在的节点这在渲染阶段查错成本极高在数据层就直接报错才友好。布局层是这四层里最容易低估的部分。如果每个节点都已经写了坐标其实布局层可以很轻只做坐标微调但大多数真实场景是用户只给节点和关系希望引擎自动排布。布局层对图做分层、排序、定坐标最后把结果回填到数据里这样用户打开图看到的是“自动布局的结果”而不是每次刷新都随机的画面。渲染层的职责是拿最终坐标和样式输出 SVG 或者 Canvas。SVG 的优点是每个图形都是 DOM 节点事件绑定、样式覆盖都特别自然调试时直接用浏览器开发者工具就能找到某个框对应的元素。Canvas 的优点是节点一多也不卡但鼠标命中和局部更新要自己写。这个项目最开始全用 SVG因为定位是技术文档和架构展示单张图节点量在几十到几百SVG 完全扛得住还能直接用 CSS 控制样式。交互层则是渲染层之上的能力扩展拖拽节点、连线吸附、框选、缩放画布。交互层不碰数据层只修改坐标和拓扑改完把新数据抛给使用者。这是个很重要的设计边界——如果交互逻辑和渲染逻辑混在一起后面想换渲染后端就寸步难行。2. 核心细节解析与实操要点2.1 节点模型设计节点模型是整个项目的底座定义得不好后面所有功能都别扭。我最终的结构比开篇示例多几个字段interface DiagramNode { id: string type: box | circle | cylinder | diamond | image | custom label: string x: number y: number width?: number height?: number style?: Recordstring, unknown data?: Recordstring, unknown parentId?: string }style是节点的视觉样式比如填充色、边框色、圆角data是业务数据比如节点关联的服务名、IP 地址。这俩必须分开。最开始我图省事把样式全塞进data结果写样式判断的逻辑到处都是后来拆开才理顺渲染引擎只认style业务方只读写data互不干扰。parentId字段是为了支持分组。分组这个概念看着简单实际上牵扯到坐标计算子节点移动时父组的尺寸要自动扩展父组移动时所有子节点要跟着平移。实现时我给父组记一个偏移量子节点存的是相对父组的坐标渲染时算出绝对坐标这样两边的移动逻辑都不会乱。这里有两点容易踩坑。第一是子节点坐标的坐标系一定要说清楚否则改了点代码就出现“节点和分组错位”的情况。第二是删除父组时要决策子节点是跟着删还是提升到顶层我推荐提升到顶层并保留绝对坐标至少不会把用户的整块数据搞丢。2.2 自动布局算法怎么选自动布局是数据驱动图表里最有技术含量的一块。不同图类型适用的算法不一样层级图适合用层次布局依赖关系图适合用拓扑分层集群关系图适合用力导向。diagram-design默认实现的是简化的层次布局。思路分三步。第一步根据连线关系算出每个节点的层级。入度为零的节点放第一层随后不断把相邻的节点推到下一层直到所有节点都被分配到层级。这样得到的是一个分层列表每层放哪些节点一目了然。第二步把同一层的节点均匀横向排列层与层之间纵向拉开固定间距。第三步如果连线出现比较严重的交叉做几轮同层节点顺序的调整尽量降低交叉线数量。力导向布局我也留了个基础版本主要是用“互相排斥的力”加“边连接的弹簧力”迭代几百轮。这个算法不需要显式指定层级但不能作为默认方案因为极不稳定每次运行结果都可能不一样。用在展示“群落关系”时效果不错用在流程图上会把人看懵。实际使用中最有效的经验是把两种布局都暴露出来同时允许用户上传时自带坐标。引擎只在坐标缺失时介入几十行代码就能让四种场景都能覆盖——自己排好的、半自动的、全自动的、以及手动微调后的。2.3 连线与交互节点画出来之后有一半的体验在连线上。最简单的连线是直线但节点稍微错开一点直线就会穿过别的节点。我实现了两种走线方式正交连线和贝塞尔曲线。正交连线适合流程类和架构类图规则是先水平走一段再垂直走最后再水平到达目标。听起来简单但自动寻路要考虑避开节点矩形否则线会贴着节点边缘穿过。我实现了一个简化方案在起点和目标点之间生成若干个候选路径点再对所有路径点做碰撞检测选择路径最短且不穿过任何节点矩形的那条。完全避开障碍是个很深的课题这里做到“基本不穿框”就已经能满足大部分需求。贝塞尔曲线用于拓扑和关系图视觉上柔和也不会被矩形的棱角干扰。曲线控制点的生成公式不复杂取起点和终点的水平距离取一半作为控制点偏移量一阶贝塞尔就是这两段的方向组合。关键参数是“曲度”默认 0.5具体取值要看节点间距太大会绕远太小会接近直线。交互层里最常用的是拖拽。拖拽的本质是鼠标事件到节点坐标的映射代码逻辑不长但坐标转换不能搞错。一个画布有viewBox浏览器里还需要getBoundingClientRect把鼠标位置转换成画布坐标如果没做这步拖拽时节点会始终比鼠标慢或者快一个偏移量。拖拽之外我还做了“从节点引出连线”的交互用户按下鼠标从一个锚点开始拖画一条临时线到另一个节点上松开就完成连线。实现时要注意临时线要用最高层级渲染不能被其他节点遮住松手时要检测“目标是不是节点”不是节点就丢弃这次操作。2.4 样式体系与主题图表设计工具最容易被忽略的是主题系统。一开始我把颜色写死在渲染函数里改成想要的颜色就得改代码还没有任何版本边界。后来抽象出Theme对象统一管理背景色、节点填充色、边框色、文本色、连线条纹色等核心变量。interface Theme { background: string fontColor: string nodeFill: string nodeBorder: string edgeColor: string edgeWidth: number nodeRadius: number }有了主题对象暗色模式就变得非常简单。用户只需要在渲染前把Theme切换成darkTheme所有节点、连线、标签颜色都会自动跟着变。这里有一个小窍门不要在每个组件里直接读取主题变量的值而是把主题挂在根上下文中子组件通过上下文获取。否则一旦需要支持多图多主题并存全局变量会让整个工程改到怀疑人生。还有一个容易忽略的细节是“标签可显可隐”。架构图通常需要展示节点名称但连线上的标签在密集区域会互相重叠。我在渲染边时判断是否有冲突如果有就不再渲染该边的文字标签转而用“高亮选中边显示标签”的方式解决。这样默认视图是干净的需要细节时再通过交互拿。3. 实操过程与核心环节实现3.1 环境与工程结构整个项目用 TypeScript 编写构建工具选 Vite。选 TypeScript 的原因很简单图表数据结构的字段多类型复杂JSDoc 注释根本兜不住有了类型系统重构的时候编译器能帮我把所有引用点一次性揪出来。Vite 相比老牌打包工具的优势是启动快修改代码反馈快开发体验好。工程目录按模块划分src/ core/ types.ts parser.ts validator.ts layout/ hierarchical.ts force.ts render/ svgRenderer.ts canvasRenderer.ts interaction/ drag.ts connect.ts theme/ index.ts dark.ts light.ts index.tscore层不依赖任何 DOM 和渲染库这样可以在 Node 环境中跑单测也可以被后端调用做离线出图。layout层接收纯数据结构、输出新的坐标同样能单测。只有render和interaction层才依赖浏览器 API。开发顺序上我建议先实现纯粹的“数据结构到 SVG 代码”的映射别一上来就套交互。先把静态图渲染正确确保障碍可以度量再逐步增加拖拽、缩放这些高级能力。从我的经验看这样调试成本最低。3.2 从 JSON 数据到画面的关键代码渲染器最核心的代码其实只有两块遍历节点画图形遍历边画路径。下面这段是 SVG 渲染器简化后的样式去掉了很多样式细节保留主要骨架function render(nodes: DiagramNode[], edges: DiagramEdge[], svg: SVGSVGElement) { const g svg.querySelector(.graph-main) || createGraphMain(svg) // 清空准备重新绘制 g.replaceChildren() nodes.forEach(node { const rect document.createElementNS(http://www.w3.org/2000/svg, rect) rect.setAttribute(x, node.x ) rect.setAttribute(y, node.y ) rect.setAttribute(width, node.width ) rect.setAttribute(height, node.height ) rect.setAttribute(rx, theme.nodeRadius ) rect.setAttribute(fill, node.style?.fill || theme.nodeFill) rect.setAttribute(stroke, node.style?.stroke || theme.nodeBorder) g.appendChild(rect) const text document.createElementNS(http://www.w3.org/2000/svg, text) text.setAttribute(x, node.x node.width / 2 ) text.setAttribute(y, node.y node.height / 2 ) text.setAttribute(text-anchor, middle) text.setAttribute(dominant-baseline, central) text.textContent node.label g.appendChild(text) }) edges.forEach(edge { const source findNode(edge.source) const target findNode(edge.target) const path document.createElementNS(http://www.w3.org/2000/svg, path) path.setAttribute(d, buildEdgePath(source, target)) path.setAttribute(fill, none) path.setAttribute(stroke, theme.edgeColor) path.setAttribute(stroke-width, theme.edgeWidth ) g.appendChild(path) }) }一个值得关注的细节是replaceChildren()清空重绘。节点数量在几百个以内时全量重绘完全没压力代码还简单。不要一开始就做 diff 更新那是优化阶段的事。buildEdgePath根据边的类型决定返回直线、贝塞尔还是正交路径内部会计算起点终点在节点边缘上的落点不会从节点内部中心直接拉线。3.3 让图表可交互拖拽与连线拖拽功能我写过很多版最终稳定下来的核心逻辑非常短const dragState { id: null, dx: 0, dy: 0 } container.addEventListener(mousedown, e { const point toCanvasPoint(e.clientX, e.clientY) const node hitTest(point.x, point.y) if (!node) return dragState.id node.id dragState.dx point.x - node.x dragState.dy point.y - node.y }) document.addEventListener(mousemove, e { if (!dragState.id) return const point toCanvasPoint(e.clientX, e.clientY) const node getNode(dragState.id) node.x point.x - dragState.dx node.y point.y - dragState.dy render() }) document.addEventListener(mouseup, () { dragState.id null })这里toCanvasPoint是最关键的转换函数。如果 SVG 上设置了viewBox0 0 1000 800而 DOM 元素在屏幕上是2000px宽那么鼠标移动 2 像素画布坐标才移动 1 个单位。忽略这层转换拖拽会以两倍速移动节点永远跟不上鼠标。连线交互需要额外维护一个“临时边”状态。当mousedown落在节点边缘的锚点时进入连接模式每次mousemove更新临时边的终点坐标mouseup时做命中检测命中另一个节点就在数据里加入边。实现临时边要记得放在g的最后这样它不会被其他元素遮住。如果要做框选需要再记录一个选区矩形渲染在 SVG 顶层判断哪些节点的矩形和选区矩形相交选中的节点描边颜色要变化。3.4 集成到网页应用diagram-design作为独立包导出使用方在 React 项目里可以这样接入import { createEditor } from diagram-design const container useRefHTMLDivElement(null) useEffect(() { const editor createEditor(container.current, { theme: light, enableDrag: true, enableConnect: true, data: initialData }) editor.on(change, data { // 把最新图表数据同步回业务状态用于保存 saveToServer(data) }) return () editor.destroy() }, [])设计成非框架绑定的核心原因是为了通用性Vue、Svelte、原生 JS 都能用不需要每个框架都维护一套绑定。如果要深度集成某框架也应该在这套核心 API 之上再封装一层而不是直接改核心。初始化时我把数据一次性传入后续所有改动都通过事件向外同步。这种“外部拥有数据、编辑器只负责展示和修改”的模式和后端保存逻辑天然配合不用关心编辑器内部的临时状态。配合第一节提到的 JSON 数据结构保存下来的数据可以直接通 Git 管理。4. 常见问题与排查技巧实录4.1 导出图片乱码或截断导出 PNG 或 SVG 是图表工具的基本功能。最容易遇到的问题有三个。第一个是文字变成方块。SVG 里的text如果引用了本地没有安装的字体在别人电脑上打开就会缺字。解决方案是把中文字体转成 Web Font 嵌入或者导出前把文本转换成path路径。后者最稳缺点是文本不能再编辑适合最终交付。第二个是导出的图只有左上角一部分。原因多半是 SVG 的viewBox和宽高设置不一致图的实际内容超出了viewBox范围。解决方式是先遍历所有节点计算出整个图的边界范围再动态设置viewBox和导出画布尺寸保证内容完整装入。第三个是导出图片模糊。这跟浏览器 DPR 有关SVG 转换 PNG 时需要把画布宽高乘以window.devicePixelRatio然后用canvas.drawImage时设置 CSS 尺寸为原始大小才不会在高分屏上糊成一片。4.2 大量节点渲染卡顿图表数据从几十个节点涨到几百甚至上千的时候SVG 全量重绘就会变卡。我曾经在一个拓扑场景里测试过 800 个节点、1200 条边拖一次画布要卡两秒交互完全没法用。首先优化的不是换渲染方案而是降低无效渲染次数。全量重绘要在requestAnimationFrame里做把快速连续触发的变更合并成一帧。拖动过程中只更新位置属性不重置整个 SVG 的 DOM。这一层优化能让几百节点的图表恢复流畅。如果节点量超过一千就要考虑 Canvas 渲染或做视口裁剪。Canvas 用requestAnimationFrame连续绘制性能远高于 SVG DOM 操作。视口裁剪是只渲染当前可见区域内的节点和边缩放平移时重新计算可见范围。两种方案可以组合视角拉远时降低绘制细节视角放大时绘制完整标签。投入产出比最高的还是第一步先合并渲染帧再考虑更换渲染后端。很多画面卡顿并不是渲染框架的问题而是自己的循环里每帧都在做重复工作。4.3 连线交叉严重自动布局跑出来的图连线交叉是常态。交叉多就会显得乱阅读顺序也不清晰。解决思路有三个方向。第一是调整分层算法。很多交叉来自同一层内节点顺序随意比如 A 连 B、C 连 D如果顺序是 A、C、B、D就会产生长线交叉。可以通过计算入边和出边序号来调整同层顺序减少交叉。第二是修改连线路径用正交线避免斜线穿过节点区域。第三是允许用户手动干预固定某些节点的坐标布局引擎遇到固定节点就跳过它的位置计算。不要指望一个算法把所有图都排得完美。实际运维中的架构图节点优先级和视觉分组有大量业务含义算法很难理解。最好的策略是自动布局给一个可接受的起点然后暴露手动固定坐标的机制。这在其他画图工具里叫“freeze node position”几乎所有布局算法都应该提供这个能力。4.4 坐标不一致问题坐标不一致的问题通常出现在拿到数据之后发现节点位置在代码里设置的是一套渲染出来又是另一套。根源往往在viewBox和 CSS 尺寸的配合。举个例子SVG 的width800和viewBox0 0 400 200同时存在时内部坐标 200 就对应屏幕上的 400 像素。如果某个节点的x字段按 800 宽设计设置 200 就会只出现在半屏位置。排查方法是先确认viewBox的单位和数据结构里的坐标单位是否一致默认建议统一采用“内部坐标单位 业务数据单位”CSS 显示尺寸只在外部控制不参与坐标计算。另有碰到 canvas 里坐标和 SVG 里坐标不一致的情况。切换渲染后端时坐标从 SVG 的 DOM 坐标转换到 canvas 的物理像素坐标需要乘 DPR。写一个统一的toCanvasPoint这类函数把坐标转换集中处理比在交互逻辑里到处乘系数要省心得多。结尾从最早的手拖图形到后来写死 JSON再到现在的完整交互编辑器最大的体会是把图表当作“数据的一种可视化形态”而不是“画布上的一堆图形”所有设计难题都会迎刃而解。数据结构清晰之后自动布局、主题切换、版本管理都变成了水到渠成的事而不是后期打的补丁。如果你也要做类似的工具我的建议是先从渲染器开始数据格式尽量简单交互慢慢往上加千万别第一版就想着做一个功能齐全的图形编辑器——那会把自己卷进无底洞。到现在我自己画方案图已经离不开这套思路了结构改起来非常顺手而且每次改完再看 Git 历史变化清清楚楚这才是我做它时真正想要的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →