做技术方案最烦什么?对我来说不是写代码,是画图。架构图、流程图、拓扑图、时序图,每次汇报前都要花大量时间拖框框、拉箭头,调整位置比写代码还累。后来我干脆自己动手做了一个图表设计项目,名字就叫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 风格或者纯 YAML?YAML 写起来更舒服,但解析出一个坏缩进就报错,不适合作为机器生成和编辑的格式。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?: Record<string, unknown> data?: Record<string, 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 上设置了viewBox="0 0 1000 800",而 DOM 元素在屏幕上是2000px宽,那么鼠标移动 2 像素,画布坐标才移动 1 个单位。忽略这层转换,拖拽会以两倍速移动,节点永远跟不上鼠标。
连线交互需要额外维护一个“临时边”状态。当mousedown落在节点边缘的锚点时,进入连接模式,每次mousemove更新临时边的终点坐标,mouseup时做命中检测,命中另一个节点就在数据里加入边。实现临时边要记得放在<g>的最后,这样它不会被其他元素遮住。
如果要做框选,需要再记录一个选区矩形,渲染在 SVG 顶层,判断哪些节点的矩形和选区矩形相交,选中的节点描边颜色要变化。
3.4 集成到网页应用
diagram-design作为独立包导出,使用方在 React 项目里可以这样接入:
import { createEditor } from 'diagram-design' const container = useRef<HTMLDivElement>(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 的width="800"和viewBox="0 0 400 200"同时存在时,内部坐标 200 就对应屏幕上的 400 像素。如果某个节点的x字段按 800 宽设计,设置 200 就会只出现在半屏位置。排查方法是先确认viewBox的单位和数据结构里的坐标单位是否一致,默认建议统一采用“内部坐标单位 = 业务数据单位”,CSS 显示尺寸只在外部控制,不参与坐标计算。
另有碰到 canvas 里坐标和 SVG 里坐标不一致的情况。切换渲染后端时,坐标从 SVG 的 DOM 坐标转换到 canvas 的物理像素坐标,需要乘 DPR。写一个统一的toCanvasPoint这类函数,把坐标转换集中处理,比在交互逻辑里到处乘系数要省心得多。
结尾
从最早的手拖图形,到后来写死 JSON,再到现在的完整交互编辑器,最大的体会是:把图表当作“数据的一种可视化形态”,而不是“画布上的一堆图形”,所有设计难题都会迎刃而解。数据结构清晰之后,自动布局、主题切换、版本管理都变成了水到渠成的事,而不是后期打的补丁。如果你也要做类似的工具,我的建议是先从渲染器开始,数据格式尽量简单,交互慢慢往上加,千万别第一版就想着做一个功能齐全的图形编辑器——那会把自己卷进无底洞。到现在,我自己画方案图已经离不开这套思路了,结构改起来非常顺手,而且每次改完再看 Git 历史,变化清清楚楚,这才是我做它时真正想要的。