☰
图编辑内核diagram-design:数据驱动图表,自动布局让架构图永不过期
2026/10/19 5:52:20 网站建设 项目流程

一个做架构图和流程图绘制的技术朋友,应该都体会过这类尴尬:文档里贴一张架构图,过了一个迭代图就过期了;想要重新拖一张,又得打开在线绘图工具,画完导出再传回文档;想要用代码自动生成,渲染效果又往往差强人意。

diagram-design这个项目,就是想解决这套“画了没人维护、维护等于重画”的痛点。它的定位不是又一个在线绘图网站,而是一个可嵌入、可扩展的图编辑内核,开发者可以把它打包进自己的系统,用数据驱动的方式生成和维护架构图、流程图、ER 图、拓扑图这类图表。对前端工程师、技术文档维护者,或者有自定义可视化需求的团队来说,这东西的价值不在于“能画图”,而在于“图不再是死的”。

1. 项目定位与设计动机

1.1 为什么我会想做一个图编辑内核

早期我在某个内部系统里维护一套服务调用关系文档,最初大家约定用在线绘图工具手工画,结果三个月后接口变更了十几轮,图彻底没人敢碰。后来换了个思路:用文本描述生成图,服务接口清单变更时自动重绘。这个方案治好了“图过期”的毛病,但又暴露新问题——自动生成的布局太死板,业务想微调某些节点的位置,却只能靠改坐标参数。

真正让我决定把diagram-design做成独立项目的,是一次更具体的摩擦:我给另一个团队做技术方案,需要把一条复杂链路画出来演示,我当时的工具链是“截图 + 画框 + 连线”,一张图下来,框和线全部手工对齐,前后花了两个多小时。画完我就在想,如果把这些画图能力抽象成一个可复用内核,那么以后做架构图、流程图,只需要准备结构化数据,剩下的排版、连线、交互全部交给内核处理,这是完全可行的。

所以diagram-design的初衷非常简单:让图表成为数据的投影。数据改,图自动跟着改;数据不变,人也允许手动微调局部布局。这个“自动布局 + 手动覆盖”的混合模式,是后面一切设计的起点。

1.2 和现有方案相比的差异点

市面上有不少成熟的绘图组件和编辑器项目,但它们各有偏向。有的是自带完整 UI 的独立应用,嵌入业务系统时要改一整套交互习惯;有的底层渲染能力很强,但对外暴露的是“图形语法”,画简单拓扑图可以做,做复杂编辑交互却很费劲;还有一些是纯渲染库,连拖拽、选择、缩放这些小交互都得自己实现。

diagram-design的策略是:把图编辑器的底层能力拆成数据模型、渲染层、布局引擎、交互层四个部分,每一层都允许替换。

渲染层可以用 Canvas,也可以换成 SVG;布局引擎内置了层级、力导向、网格三种常见算法,也可以注册自定义布局器;交互层的事件是内部统一治理的,拖拽、画线、框选这些操作可以被业务系统覆写或禁用。

这个拆法带来的好处很明显:团队想用它做一个只读的架构图展示页,可以只保留渲染层和布局引擎,关掉编辑交互;想做一个完整的编排设计器,则可以在交互层上面加上自己的画布、面板、右键菜单。这种灵活度,是单体编辑器和纯渲染库都给不了的。

1.3 目标场景与明确的边界

适合用diagram-design的场景,我归纳下来主要有三类:

  • 技术文档与架构展示:服务调用关系、系统模块依赖、部署拓扑。数据来自配置中心或代码扫描,图上可以点击节点跳转到详细文档。
  • 业务流程可视化:审批流、订单状态机、工单流转。要求边能携带标签,节点在不同状态显示不同颜色。
  • 数据库建模与 ER 图:表、字段、外键关系,可以反向导入表结构生成图。

同时我也划清了边界:它不做专业平面设计,不支持自由绘制任意形状,不打算成为绘图画板的平替。它只专注“节点和边”这一类图,把这一类做到位。

2. 技术架构与核心模块

2.1 数据模型:Node、Edge、Anchor 怎么设计才不乱

图编辑内核最容易翻车的地方是数据模型。一开始如果只设计 Node 和 Edge 两个简单结构,后面加端口、加分组、加子图时会发现自己被坑得很惨。

我在diagram-design里把数据模型拆成四类核心对象:

  • Node(节点):承载位置、尺寸、样式、业务数据。必填字段是 id、x、y、width、height。
  • Port / Anchor(连接锚点):挂在节点边缘的对外连接点。连线不是连到节点中心,而是连到某个锚点。
  • Edge(边):记录从哪个节点哪个锚点出发,到哪个节点哪个锚点,同时保存路由点数组和标签。
  • Group(组):一组节点的容器,支持嵌套。组本身也有位置和尺寸,但它不影响下层节点的绝对坐标。

用 JSON 表现出来的大致结构:

{ "id": "node_102", "type": "server", "x": 120, "y": 80, "width": 140, "height": 48, "data": { "name": "订单服务", "status": "running" }, "ports": [ { "id": "p_in", "position": "left", "offset": 0.5 }, { "id": "p_out", "position": "right", "offset": 0.5 } ] }

锚点用相对位置而不是绝对坐标,这是关键的一步。节点一旦移动,锚点绝对坐标可以实时计算,但存进数据模型里的始终是“左边中间”或“顶部靠右”这种描述,这样序列化后数据结构稳定,也方便节点样式切换时锚点自动跟着变。

边的定义我踩过一次坑:最初把边的路由点直接平铺在 Edge 对象上,后来发现折线在节点移动后需要重新计算,而缓存的历史路由点会和实时路由打架。最后定下的规则是:Edge 里只存端点引用,路由点永远当作“可重算的产物”,而不是持久化数据。这样撤销重做、自动布局接进来的时候,边永远能保持一致的状态。

2.2 渲染层:Canvas 和 SVG 的取舍

渲染层选型是diagram-design里争论最久的一件事。SVG 的优点是样式灵活、DOM 事件天然支持,节点数量在几百个时开发效率很高;缺点是数量到了几千个后 DOM 节点数太多,首次渲染和交互响应都会明显变慢。Canvas 的优点是绘制性能好,一万个节点也能扛,代价是事件命中需要自己做坐标计算,文本换行、点击判定、局部样式更新都得手写,工作量大不少。

我最终采用的是“分层混合渲染”方案:

  • 静态网格和背景:用 Canvas 最底层绘制,只在视口变化时重绘。
  • 节点层:默认使用 SVG,节点数量超过 1500 个时自动切换到 Canvas 渲染模式。
  • 边层:始终使用 Canvas,因为边是最容易被大量创建的(一个节点几十条入边很常见)。
  • 交互层:用一层透明的 SVG 或 DOM 层承载拖拽、框选、参考线等即时交互反馈。

这套混合渲染在实践里效果不错。普通图表只有几十个节点,SVG 的模式下开发者可以直接用 CSS 和 DOM 调样式,调试体验好;数据量大时切换渲染模式,用户几乎感知不到区别。切换渲染层时只要保证对外暴露的坐标和尺寸语义一致,内部实现细节完全隔离。

2.3 为什么布局引擎要自己写

一开始我考虑过接入第三方图布局库,它们的力导向和分层布局确实强大可靠,但接进来之后发现两个痛点:一是布局库的大部分 option 在编辑器场景里根本用不到,白白增加打包体积;二是节点从布局态切换到手动调整态后,布局器往往需要整体重新跑一遍,很难实现“局部微调”。

自己写布局引擎之后,我把布局结果分成两类:

  • 全量布局:用户点击“自动排列”,引擎按照算法给所有节点重新算坐标。
  • 局部布局:节点被拖拽后,只重算受影响的边和相关的子节点。

分层布局、力导向布局、网格布局这些核心算法,单独拆成纯函数模块,不依赖渲染层。这样即使未来把渲染层换成 WebGL 或 SVG,布局引擎依然可以原样复用。

3. 核心功能实现与细节优化

3.1 节点拖拽:事件绑定、吸附与参考线

编辑器的第一体验来自拖拽。最早我直接监听了mousemove和mouseup,后来发现这套写法在触摸设备和 iframe 场景下有各种问题,最终统一改用 Pointer Events 体系。

拖拽逻辑本身不复杂,真正让体验拉开差距的是吸附和参考线。

吸附规则的实现,我设定如下:

  • 当节点的中心点或边缘与附近节点的中心点或边缘距离小于 8px 时,自动吸过去。
  • 参考线只在吸附发生时绘制在交互层,吸附完成或鼠标移动超过阈值后立刻清除。
  • 拖拽过程中,节点位置始终使用整数坐标,避免 CPU 损耗和渲染模糊。

参考线的计算需要维护两张表:已放置节点的“中心 x 表”、“中心 y 表”、“左/右边缘 x 表”、“上/下边缘 y 表”。拖拽时只查询附近 20px 范围内的候选线,而不是全图遍历,这样节点多时也能保持流畅。

3.2 连线与边路由:曲线控制点计算

从一个锚点拖到另一个锚点,边的形状需要实时预览。三种类型里,贝塞尔曲线用得最多,控制点计算规则是:

  • 如果起点在节点右侧,控制点朝右延伸;终点在左侧,控制点朝左延伸。
  • 控制点长度取两个端点水平距离绝对值的一半,但设下限 30px 和上限 120px。
function computeBezierControlPoints(sourcePoint, targetPoint) { const dx = Math.abs(sourcePoint.x - targetPoint.x); const dy = Math.abs(sourcePoint.y - targetPoint.y); const horizontal = dx >= dy; if (horizontal) { const offset = Math.min(120, Math.max(30, dx / 2)); const direction = sourcePoint.x < targetPoint.x ? 1 : -1; const p1 = { x: sourcePoint.x + direction * offset, y: sourcePoint.y }; const p2 = { x: targetPoint.x - direction * offset, y: targetPoint.y }; return { p1, p2 }; } const offset = Math.min(120, Math.max(30, dy / 2)); const direction = sourcePoint.y < targetPoint.y ? 1 : -1; const p1 = { x: sourcePoint.x, y: sourcePoint.y + direction * offset }; const p2 = { x: targetPoint.x, y: targetPoint.y - direction * offset }; return { p1, p2 }; }

这个规则看起来简单,实际用下来比通用的 “椭圆偏移” 或 “随机抖动” 效果更好,因为大多数业务流程图是左右流向的,控制点在水平方向能保证边不穿过节点主体。

正交折线(直角连线)难度高一个量级。初期我直接按曼哈顿距离生成“横-竖-横”三段,但遇到起点和终点在垂直方向重叠时,折线会穿过节点。后面改成简化版障碍路由:先看起点和终点之间是否有重叠块,有则在中间插入一个绕行点。

3.3 缩放平移与小地图

画布缩放最核心的公式是“以鼠标所在位置为缩放中心”。很多编辑器做缩放时,直接缩放坐标倍数,结果鼠标指针下的内容反而滑走了,体验相当糟糕。

正确做法是先把鼠标位置从屏幕坐标转换为画布坐标,缩放后再把画布坐标映射回屏幕坐标,根据前后屏幕位置的差值修正平移量:

function zoomAtPoint(viewport, mouseScreenX, mouseScreenY, factor) { const mouseCanvas = viewport.screenToCanvas(mouseScreenX, mouseScreenY); viewport.scale = clamp(viewport.scale * factor, 0.2, 2.5); const after = viewport.screenToCanvas(mouseScreenX, mouseScreenY); viewport.translateX += (after.x - mouseCanvas.x) * viewport.scale; viewport.translateY += (after.y - mouseCanvas.y) * viewport.scale; }

小地图的实现则是创建一块较小的 Canvas,每次视口变化时把整个图的轮廓用低细节方式绘制上去,再画一个代表当前视口的半透明矩形。小地图上点击和拖拽可以直接跳转视口,这个功能在我测试大数据量图时非常实用。

3.4 框选、多选与分组

框选算法本身不难,就是矩形和节点外接矩形的相交判断,直接把节点坐标换算到屏幕坐标系再判断。但有几个隐藏细节需要注意:

  • 框选应该用“鼠标按下时所在画布位置”和“鼠标移动时当前位置”组成选区,而不是屏幕坐标选区,否则会受缩放干扰。
  • 被选中的节点如果跨越了分组层级,要么全部选中,要么全部取消。我在实践中选择了“跨组全选”,因为局部选择容易让用户在视觉上产生混淆。
  • 多选移动时,所有节点的位移量相同,边的端点只需要用“原坐标 + 位移量”重算,不需要跑路由算法。这个优化让几百个节点同时拖动时也能保持 60 帧。

分组的行为是另一块复杂内容。拖拽父组时,子节点做的是“相对坐标偏移”,即给组内所有节点增加同样的位移,同时递归更新嵌套组的坐标。删除组时,有两种策略:把子节点平移到根层级,或者把子节点提升到组的父层级。我默认选择了后者,保留原层级结构更符合直觉。

4. 自动布局算法实战

4.1 层级布局:从有向图到规整分层

层级布局最适合流程图和调用链图,做出来的图是同方向、分层排布的,非常整齐。基本思路分三步:

第一步:分层。输入有向图,用拓扑排序或最长路径算法给每个节点分配层数。源节点在第 0 层,它的下游在第 1 层,以此类推。为了避免循环引用导致死循环,我会先识别环并打断其中一条边,布局完成后再把环标记出来提示用户。

第二步:同层节点排序。相同层的节点按什么顺序排列,决定了图的交叉程度。我使用重心排序算法:每个节点的理想位置是它上游邻居位置的平均值,然后按理想位置从小到大排列同层节点。多轮迭代后,交叉明显减少。

第三步:坐标计算。每层的高度被平均分配,节点在层内按排序结果顺序排列,同一层的节点 y 坐标相同。这个简单策略在大部分场景下已经能产生不错的效果,不需要上复杂的二次规划。

层级布局最后要做“跨层边”归一化。比如第 0 层的节点直接连到第 2 层的节点,视觉上会有一条跨两层的长线。把它看作经过第 1 层“虚拟节点”的两段短边,在布局时预留空间,逻辑上简单且不会产生线交叉重叠。

4.2 力导向布局怎么调参才不会乱

力导向布局适合没有明确方向的图,比如服务依赖拓扑、社区关系图。它的原理是模拟物理系统:相连节点之间存在弹簧引力,所有节点之间互相排斥。

核心实现:

function simulate(nodes, edges, iterations, k, damping) { const temperature = 1.0; for (let iter = 0; iter < iterations; iter++) { for (const a of nodes) { for (const b of nodes) { if (a === b) continue; const delta = distance(a, b); const repulsion = (k * k) / delta; applyForce(a, b, -repulsion); } } for (const edge of edges) { const a = nodesMap.get(edge.source); const b = nodesMap.get(edge.target); const delta = distance(a, b); const attraction = (delta * delta) / k; applyForce(a, b, attraction); } for (const node of nodes) { node.x += node.forceX * temperature * damping; node.y += node.forceY * temperature * damping; node.forceX = 0; node.forceY = 0; } temperature *= 0.99; } }

关键参数:

  • k:理想边长,与节点数量相关:k = area / sqrt(totalNodeCount)。
  • 节点数过万时:直接两两计算斥力是 O(n²),无法接受,需要用空间哈希或四叉树做邻居裁剪。
  • 温度系数:初期设为 1,每轮迭代乘以 0.99,让布局先快速展开、再慢慢稳定。如果一开始温度太低,初始重叠的节点根本分不开。

调参经验:优先调整斥力,而不是引力。斥力太小图会挤成一团;斥力太大图发散得厉害,节点分组感不明显。每次只动一个参数,记录输出图的边交叉数和平均边长,多迭代几轮再决定下一步。

4.3 网格布局与场景匹配

网格布局的规则最简单:指定一行放几个节点,加上横向和纵向间距,按顺序平铺。它最合适的场景是“数据初始化”和“无意义的均匀展示”——比如新增了一堆新节点还没确定关系时,先用网格摊开,再让用户逐个连线。

在设计diagram-design时我给网格布局加了几个增强项:

  • 支持按节点分组平铺:同类型的节点放在一块,减少用户二次整理。
  • 支持按宽度自动计算每行数量,而不是固定写死。
  • 支持混合模式:节点数量超过阈值时自动拆成多列网格。

网格布局的代码量很少,但它是自动布局里最常用、最不容易出错的一种。很多时候用户不想要花哨的力导向,只是想把一堆散落的节点排整齐,网格布局完美契合这个需求。

5. 性能优化与渲染调优

5.1 万级节点卡顿的排查思路

diagram-design在单次加载 12000 个节点、16000 条边的数据集时,最早的表现是:首次渲染卡了 3 秒,拖拽平移时大概只有 5 帧。

排查步骤:

  1. 先用性能面板确认瓶颈在脚本还是绘制。通过 profiler 发现脚本占 62%,绘制占 25%,说明主线程在数据转换和布局计算上消耗过大。
  2. 减少初始布局计算。如果用户不点“自动布局”,所有节点的初始坐标可以延迟到首帧渲染后再算,避免一次性布局卡住。
  3. 上剔除逻辑。渲染循环中只绘制视口内的节点和边。窗口外 50px 以外的对象直接跳过,这个优化对性能提升最明显。

剔除前需要构建一个空间索引。我的做法是把画布分成若干个网格单元,每个节点落在哪个单元就登记进去,视口更新时只遍历视口覆盖到的几个单元。

5.2 分层渲染与局部重绘

Canvas 渲染模式下,如果每帧都清空全画面再重绘上万个元素,即使有剔除也会产生不小开销。更合理的做法是分层:

  • 背景层:静态网格,只在 viewport 缩放比例变化时重绘。
  • 边层:只重绘发生变化的边,如果边数量多但变化少,把每条边单独离屏渲染成小 Canvas 图块,图块位置变化时重新贴上去。
  • 节点层:每个节点渲染到独立的小图块,只有节点样式变化时才重绘图块。

这个“离屏图块”方案让我在边频繁变化的场景下获得了明显收益:拖动一个节点,原来需要重绘几百条边,现在只需要更新那个节点的图块和它邻接的几条边的图块。

需要注意的是离屏图块会额外占用内存,所以节点数超过三千时必须做 LRU 缓存淘汰。

5.3 细节优化:文本测量与缓存

节点里最容易被忽视的性能杀手是文本测量。Canvas 环境下每次绘制文本都要计算宽度来做折行和居中,上万次测量下来对性能影响不小。

我的两个缓存策略:

  • 文本宽度缓存:同一个文本内容在同一个字号和字体下只测量一次。
  • 文本折行结果缓存:以文本内容和可用宽度为 key 缓存折行结果,节点在渲染时直接使用。

这个优化听起来小,但在长文本架构图里的效果非常明显。配合字号预扫描,在渲染前把节点里的文本布局一次性算好,渲染时直接绘制,实测渲染时长减少约 35%。

6. 常见问题与排查技巧

6.1 坐标精度问题

症状:缩放比例较大时,拖拽节点会出现轻微跳动,参考线吸附时对不齐。

原因:浮点数坐标在连续变换中产生精度误差,累积到一定量后,坐标和屏幕位置的换算就会抖动。

解决方案:

  • 节点坐标一律使用整数,拖拽结束时Math.round处理一次。
  • 缩放过程中关闭吸附,缩放结束再重新计算。
  • 将缩放范围限制在 0.2 到 2.5 之间,低于 0.2 时精度问题无法避免,高于 2.5 后用户也无法精确操作。

6.2 撤销重做栈处理

撤销重做如果单纯保存整个数据快照,数据模型小时没问题,节点上千时内存占用惊人,而且每拖一次就存一次快照,性能和内存都吃不消。

我采用命令模式:

  • 每个操作对应一个 Command,包含 undo 和 redo 两个函数。
  • 命令入栈时同时记录影响到的节点 id 集合,redo 时只重算这些节点的相关边。
  • 连续拖拽的多个移动事件合并成一个复合命令,避免撤销时需要点几十次。

有一个容易踩的坑:命令队列里的对象引用和当前数据模型对象是不同代的对象。恢复旧命令时,不能直接拿旧对象赋值,必须通过 id 去当前数据模型里查找节点,再修改其坐标。否则会出现撤销后节点和边引用断裂的问题。

6.3 浏览器兼容性细节

  • Pointer Events 在较老版本的 iOS Safari 上支持不完整,需要提供 MouseEvents 降级方案。
  • 文本测量结果在不同字体引擎下会有 1-2px 误差,在计算节点宽度时要主动加 padding,不能依赖精确测量。
  • Canvas 的roundRect等方法在部分 WebView 上有兼容问题,绘制圆角矩形需要自己封装。

6.4 快速排查速查表

现象可能原因处理方式
节点拖不动事件绑定在 Pointer Events 兼容层出错检查是否注册 pointerdown、确认 iframe 是否吞掉鼠标事件
边画歪了锚点绝对坐标没更新确认锚点计算是每次渲染时实时计算,而非缓存
缩放后节点位置漂移缩放中心公式不正确检查是否先转画布坐标再转回屏幕坐标
文本折行错乱字体加载完成前测量了宽度在字体加载完成后重新测量并刷新
大量节点渲染卡顿视口剔除未生效检查空间索引是否覆盖画布边缘对象
撤销后连线失效命令里存了对象引用改为按 id 查找,再更新坐标

最后再分享一点个人心得

做完diagram-design之后,我自己最大的收获不是学会了多少图形算法,而是彻底理解了一个道理:图编辑器真正难的不是画图,而是“状态一致”。拖拽、缩放、撤销、自动布局、多选,每一个功能单独看都不复杂,但它们一旦同时作用于同一批数据,稍微有一点不一致,用户立刻就会感觉到。

所以我在项目里最重视的一条原则是:任何模块都不允许私自修改核心数据模型。渲染层只能读,交互层通过命令写,布局引擎把结果交给命令执行器去落盘。这个约束让项目从最初的数据模型设计到后来的功能扩展,几乎没有出现过数据错乱的问题。

如果你也正在做一个类似的图编辑项目,我的建议是:先把数据模型想清楚,再动手写渲染代码。模型不乱,后面全都不乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询