diagram-design 这个项目,最开始是为了解决一个特别具体的痛点才动手做的:我们要在产品里嵌入一套"能拖、能连、能导出"的在线图表设计能力,让用户直接画系统架构图、流程图、ER 图。市面上的现成方案试了一圈,总有几个绕不过去的坎——有的只适合看图不适合改图,有的扩展点太封闭,想加一种自定义节点得扒源码,还有的一引进来就是几十兆依赖。于是我们决定自己写一个。项目从立项到稳定跑通花了大概一个季度,今天我把整个脉络完整复盘一遍:数据模型怎么设计、渲染为什么选 SVG、拖拽连线和自动布局里的工程细节、以及最终如何干净地接入业务方。
1. 先聊聊为什么非要自研一个图表设计器
1.1 现成方案的瓶颈不只是"不好改"
一开始我们想得比较简单,认为嵌一个开源图表库就行。但真正往产品里放的候才发现,问题往往藏在三个层面。
第一是编辑能力和展示能力是两回事。很多图表库做的其实是"根据数据渲染一张图":你给一份节点和连线的数据,它把图画出来,支持缩放、平移、点击。可我们要的是"用户能自己拖一个新节点进来、从一个锚点拉线到另一个锚点、框选一堆节点批量删除"这套完整的编辑交互。展示层做得再漂亮,编辑入口缺失,一切都白搭。第二是定制能力。这类产品通常节点类型是自己定义的,比如数据库表节点、接口节点、依赖服务节点,每种节点的外观、字段、校验逻辑都不一样。如果底层不预留节点类型注册机制,后面每一个新需求都是灾难。第三是依赖体积和可维护性。引入一套重型的图形框架,哪怕只用了 10% 的功能,也要背上它的包体积、版本兼容和升级风险。对基础架构来说,这种黑盒依赖很让人不安。
我做过一张对比表,把当时考察的几个方向和自研方案放在一起:
| 方案 | 编辑交互 | 自定义节点成本 | 包体积 | 性能表现 | 维护可控性 |
|---|---|---|---|---|---|
| 纯展示型图表库 | 弱,需二次开发 | 中高 | 小 | 好 | 一般 |
| 通用图编辑框架 | 强 | 中,但有隐性限制 | 大 | 一般 | 差 |
| 白板引擎 | 强 | 强,但太重 | 很大 | 中 | 差 |
| 自研 diagram-design | 自己定 | 注册制,成本低 | 可控 | 可控 | 完全掌控 |
对比完之后,我们的判断就很清晰了:自研不是情怀,是因为现成方案没有一个能同时满足"编辑能力 + 自定义节点 + 可控体积"这三个硬指标。
1.2 自研的边界:做图设计器,不做大而全的白板
我们给自己定了非常明确的边界:只做"节点 + 连线 + 画布"这三种核心对象的通用图设计,不做手绘画笔、不做便签贴纸、不做多人实时协同。原因很简单,边界越大,工作量越大,后面每一个"顺手加个功能"的请求都会让架构变得臃肿。核心能力聚焦之后,反而能把这台机器做得足够顺滑。
这个边界后来帮了大忙。因为"图设计"的本质很容易抽象:一切图无非是若干节点,节点之间有连线关系,再加上画布上的视图状态。把这个模型定清楚,业务方可以自己在节点里塞任意 data,而底层的拖拽、缩放、撤销、导出都是通用的。
2. 核心数据模型:节点、连线和画布如何抽象
2.1 一个图,在内存里到底存什么
图表和树最大的区别是:节点之间可以有任意多条边,甚至可以成环。所以建模时我不会给节点加"children"这种层级字段,而是走标准的图论模型——一组节点 + 一组引用关系的边。为了让数据可序列化、可保存、可同步,节点和连线全部用 id 引用,而不是内存对象引用。这一点在实现撤销栈和远端同步时尤其重要。
直接看 TypeScript 的接口定义最直观:
interface DiagramNode<T = unknown> { id: string; type: string; // 节点类型,如 'dbTable' / 'apiNode' x: number; // 逻辑坐标,不是屏幕像素坐标 y: number; width: number; height: number; ports: Port[]; // 锚点数组,连线从这里出发和接入 data: T; // 业务数据,任意类型 style?: NodeStyle; // 展示样式,如 fillColor, strokeColor } interface Port { id: string; position: PortPosition; // 相对位置,比如 'top' / 'bottom' / 'left' / 'right' 或 {x: 0.5, y: 0} } interface DiagramEdge { id: string; sourceId: string; sourcePortId: string; targetId: string; targetPortId: string; points: Point[]; // 中间绕行点,至少包含起点和终点 style?: EdgeStyle; } interface DiagramCanvas { nodes: DiagramNode[]; edges: DiagramEdge[]; zoom: number; // 缩放比例 offset: { x: number; y: number }; // 画布平移偏移 selectedIds: string[]; viewport: { width: number; height: number }; }2.2 几个容易被忽略的设计决策
第一个决策是坐标必须用逻辑坐标,而不是像素坐标。像素坐标跟屏幕分辨率、缩放比例绑定,一旦 zoom 变化,所有位置都要重算,很容易产生浮点误差。我统一用一个"画布坐标系",渲染时通过一个 transform 把它映射到屏幕。缩放平移是视图层的职责,数据层永不感知。
第二个决策是锚点(Port)必须是节点的子对象,而不是连线上的临时坐标。一开始我犯过错:连线的端点直接存 x/y,结果就是当节点被拖走后,连线原地不动,图立刻断掉。把端点绑定到锚点,拖节点时连线才会自动跟随。锚点位置用相对比例存,例如{x: 0.5, y: 0}表示节点顶部中点,这样不管节点宽高怎么变,锚点总能对齐到正确位置。
第三个决策是连线要保留中间路径点。直线连当然简单,但实际画图经常需要折线绕行。如果只存起点和终点,路由算法每次都要重算,而且用户手动调整过的绕行点会被覆盖。所以我让每条 edge 自带一个可变长度的points数组,渲染时按顺序绘制。
这套模型跑通后,无论是撤销、导出、还是服务端保存,都只需要对一份普通的 JSON 操作,不用跟 DOM 或复杂对象打交道。
3. 渲染层选型:为什么是 SVG 为主
3.1 SVG 和 Canvas 的取舍
图表渲染方案吵了很多年,核心其实就是两派:SVG 走 DOM 节点,Canvas 走像素绘制。我对它们的判断很直接:
- SVG 适合几百到几千个图元、交互密集、需要样式丰富的场景。它是矢量图形,天然支持事件绑定,每个节点就是一个 DOM 元素,调试也方便。
- Canvas 适合几万甚至更多图元、需要极致渲染性能的场景,但交互命中检测、局部重绘都得自己实现,复杂度高一个量级。
diagram-design 面向的是在线设计器,节点的数量级通常在一千以内,但用户会在上面长时间拖拽、连线、框选。这种情况下 SVG 的事件模型可以省太多事情。把节点做成 DOM 元素之后,浏览器自己处理命中和事件分发,我只需要关心数据变化。
3.2 分层渲染:网格、图元、交互层各管各的
一个细节但关键的架构选择是分层。整个渲染结构分为三层:
- 最底层是网格层,用 SVG
<pattern>画背景网格,用户开启网格吸附时,拖拽坐标可以直接取模吸附到网格值。 - 中间是图元层,也就是所有节点和连线。节点是独立元素,连线用
<path>绘制。 - 最上层是交互层,专门放框选矩形、拖拽中的临时连线、吸附高亮指示器。把交互元素独立出来,是为了避免每次交互都要重绘所有节点。
坐标变换我用了一个<g transform="translate(offset.x, offset.y) scale(zoom)">包住整个图元层。所有节点绘制时只需要写逻辑坐标,改变 zoom 或 offset 时只更新这个 g 的 transform 属性。这个方案比每次去改每个节点坐标要高效得多,也避免了浮点漂移累积。
3.3 性能优化:只更新需要变的东西
SVG 最大的性能陷阱是"全量重绘"。如果拖一个节点,就把整张图的 innerHTML 重算一遍,一千个节点时帧率会立刻掉到个位数。我的做法是按变更粒度更新:拖节点时只改那个节点的 x/y 属性,连线的 path 只更新与之相连的部分。
在实现上,我把"内存中的数据"和"渲染出来的 DOM 元素"做了映射。用一个 Map 存nodeId -> 元素实例,当数据变化时,只找到对应元素执行更新操作。SVG 的 transform 和 d 属性更新开销都很小,实测一千节点时拖动依然能保持流畅。
还有一个小技巧:不要频繁创建和销毁 DOM 元素。框选、拖拽过程中产生的临时元素留在交互层,统一 update,结束后再隐藏或清理。频繁的 append/remove 会带来大量重排和重绘,而几十个临时元素的属性更新几乎不消耗性能。
4. 交互层的工程细节:拖拽、框选、连线、撤销全在这里
4.1 统一用 Pointer Events
交互层我一开始用的是 mouse 事件,后来发现触屏设备完全不可用,于是全部迁移到 Pointer Events。它能把鼠标、触屏、手写笔统一成同一套事件。下面三个是核心原则:
- 按下时记录起始坐标:把事件对象里的 clientX/clientY 转成画布逻辑坐标,存到状态里。
- 设置判定阈值:如果移动距离小于 3 像素,视为单击;超过阈值才进入拖拽。否则用户只是想点一下节点,结果却把它拖走了,体验非常差。
- 使用 setPointerCapture:在 pointerdown 之后,把事件捕获绑定到当前元素,这样即使鼠标移出画布,up 事件也不会丢。
4.2 拖拽节点与框选
拖拽节点本身不难,难点在于拖拽过程中其他一切的联动。一个节点被拖动时,要同步做的事包括:
- 更新节点自己的 x/y;
- 重新计算所有连接到它锚点的边的路径;
- 如果开启网格吸附,坐标取模到网格值;
- 如果开启了辅助对齐线,还要检测它是否与其他节点的边缘对齐。
这些逻辑如果都写在节点组件内部会非常混乱,所以我把它抽成了一个InteractionManager,负责统一收集事件、维护当前拖拽状态、调用模型层更新数据,再由渲染层响应数据变化。
框选稍微巧妙一点。用户在空白处按下拖拽时,交互层出现一个半透明矩形,同时遍历所有节点做矩形相交判断。这里要注意,判断用的是逻辑坐标,而不是屏幕坐标。屏幕坐标要经过"减 offset、除 zoom"的逆变换才准确。shift 框选时,我把当前选中的 id 集合保留,新的命中的点追加进去,实现加选。
4.3 连线:从锚点拖出临时线
连线是最能体现设计器"手感"的地方。用户从某个锚点按下鼠标,拖出一条线,放开时落到另一个锚点,就成功建边。这个流程有个核心难点:拖拽过程中要实时判断当前鼠标附近是否有可连接的锚点,并且要有吸附效果。
我的实现是:mousemove 时拿到鼠标逻辑坐标,遍历画布中所有锚点,计算距离,若距离小于阈值(比如 12 像素),则把临时线的终点吸附到锚点位置,并把目标节点高亮。松开时判断是否处于吸附状态,是则创建正式 edge,否则丢弃临时线。
这里有性能上的讲究:每个 mousemove 都全量遍历所有锚点会有点耗。小图无所谓,但为了避免大图卡顿,我用了一个简单的空间索引——按网格把锚点分桶,只需要检查鼠标所在格子及其相邻格子里的锚点即可。
4.4 撤销栈:命令模式而不是快照
撤销/重做是设计器的标配。一开始我图省事,每做一次操作就把整个 canvas 对象深拷贝一份存进历史栈。200 个节点时还勉强能看,500 个节点时每次操作的内存占用就很吓人,而且操作一多,历史栈越来越大,卡顿感非常明显。
后来我重构成了命令模式:每个操作先产生一个 command 对象,里面包含 do() 和 undo() 两个函数。比如拖拽节点产生的命令就是:
interface Command { do(): void; undo(): void; label?: string; } // 拖拽节点示例 function createMoveCommand(nodes: DiagramNode[], before: Map<string, Point>, after: Map<string, Point>): Command { return { do() { nodes.forEach(n => { const p = after.get(n.id); if (p) { n.x = p.x; n.y = p.y; } }); }, undo() { nodes.forEach(n => { const p = before.get(n.id); if (p) { n.x = p.x; n.y = p.y; } }); }, }; }多个操作可以合并成一个命令,例如拖拽多个节点,before 和 after 统一记录。命令模式不仅省内存,还可以在 do/undo 之外追加一些副作用,比如更新连线路径、触发变更事件。历史栈维护一个 index,执行 undo 就 index-- 并调用旧命令的 undo,执行 redo 就 index++ 并调用新命令的 do。
这里必须提醒一个极易踩的坑:命令里保存的状态必须是深拷贝的坐标快照。如果直接引用节点对象,撤销时节点早就被改掉了,等于撤销了一个寂寞。我一般会为每次命令保存一份轻量的坐标 Map,而不是一整棵对象树。
5. 自动布局:让乱糟糟的连线图自动变得可读
5.1 什么时候会用到自动布局
很多人觉得自动布局是"锦上添花",其实在真实场景里它是刚需。比如用户导入一份 JSON 数据,节点全部堆在原点附近,这时候如果不做自动布局,用户第一眼看到的就是一张没法看的乱图。又或者某些业务方需要"根据依赖关系自动生成架构图",节点之间层级明确,手摆根本不现实。
diagram-design 的自动布局我按两种场景做了两套策略:有向无环结构的层次布局,和一般无向结构的力导向布局。这里重点说层次布局,因为它对流程图、架构图、ER 图的支撑最直接。
5.2 层次布局的三个步骤
层次布局的核心思想是把节点按层级铺开,每层一行或一列,连线的方向尽量一致,减少交叉。我的实现分三步:
第一步,分配层级。找出所有入度为 0 的节点作为根节点,从根开始 BFS 遍历,每个节点的层级等于它的最大父层级 + 1。如果一个节点有多条入边,我会取所有父层级中的最大值,避免节点被安排得太靠前导致连线向上回溯。
第二步,层内排序。这一步的目标是在同一个层内调整节点顺序,使连线的交叉尽可能少。我用的是 barycenter(重心)方法:某个节点的理想位置 = 它所有相邻节点位置的均值;然后按理想位置排序。这个算法会迭代几次,每次都用上一次的结果作为输入,通常四五次迭代后交叉数就收敛到很低。
第三步,分配坐标。确定每一层的节点顺序后,横向用固定节点间距逐个摆放,纵向每一层用固定的层间距。这一步会留出足够的空间给折线绕行。
实际上在工程实现里,我会先处理一个有环图的问题。BFS 层级分配在遇到环时会导致层级无限递增,解决办法是先用 Tarjan 算法或简单 DFS 找出回边,在计算层级时忽略回边,布局后再用曲线或折线表示回边。
5.3 工程化:布局不能阻塞界面
布局算法如果在主线程同步跑,图一旦上千个节点,用户会看到明显的"卡死几秒"。我把布局引擎设计成了异步可取消的任务:
class LayoutTask { constructor(nodes: DiagramNode[], edges: DiagramEdge[]) {} async run(): Promise<Map<string, Point>> { // 分阶段执行,每个阶段之间让出主线程 const levels = await this.assignLevels(); const ordered = await this.sortWithinLevels(levels); const positions = await this.assignCoordinates(ordered); return positions; } }使用方拿到布局结果后,可以先预览再应用:把结果坐标叠加在交互层上,用半透明状态展示,"应用"才真正写回数据并触发撤销命令。这样用户误点了布局还能一键撤销,体验会好很多。
5.4 力导向布局的取舍
无向图或者关系网图我跑的是力导向算法,本质上就是让节点之间相互排斥、有边相连的节点相互吸引,迭代若干次后系统趋于稳定。它的优点是几乎不需要用户干预,缺点是结果不稳定,同样一组数据跑两次坐标不同,而且迭代次数少了很容易陷入局部震荡。所以我的处理是:只对没有指定布局类型的图调用它,固定迭代次数(比如 200 次),迭代完成后高斯平滑一次坐标。它能给出一个"能看"的初始布局,但精细调整还是得靠用户手工。
6. 导出与接入:把设计器变成业务的基础设施
6.1 三件套:JSON、SVG、PNG
图表设计器最终要能交付内容,导出功能是逃不掉的。我提供了三种导出格式,各有用途:
- JSON:用于保存、恢复、协作同步。数据就是前面定义的
DiagramCanvas结构,直接JSON.stringify即可。 - SVG:用于矢量图嵌入文档、PPT、网页。导出的 SVG 要求所有样式内联,不能依赖页面里的 class。
- PNG:用于截图、分享、论文插图。PNG 的生成不能直接截屏,而是基于 SVG 做二次转码。
PNG 导出我踩过不少坑,核心代码大致是这个思路:
async function exportPng(data, options = {}) { const svgString = serializeToSvg(data); // 生成 svg 字符串 const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = new Image(); await new Promise(resolve => { img.onload = resolve; img.src = url; }); const canvas = document.createElement('canvas'); canvas.width = (data.width || 1200) * (options.scale || 2); canvas.height = (data.height || 800) * (options.scale || 2); const ctx = canvas.getContext('2d'); if (options.background !== 'transparent') { ctx.fillStyle = options.background || '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); return canvas.toDataURL('image/png'); }这里有两个容易翻车的地方:一是外部字体,SVG 里如果用到了特殊字体,必须用 object 内嵌或转成 path,否则别人的电脑上打开就是乱码;二是分像素模糊,导出时要把坐标取整或加 0.5 偏移,否则缩放后线条会有明显毛边。
6.2 对外 API 设计:少暴露,好扩展
我认为一个好的设计器库,对外 API 应该非常克制。diagram-design 暴露的东西只有三类:
初始化与数据加载:
const designer = new DiagramDesigner({ container: document.getElementById('app'), registry: registry, // 节点类型注册表 enableGrid: true, gridSize: 10, }); designer.load(canvasData);事件钩子:业务方最关心的是"节点被点击了""连线创建了""选中变化了",这些以回调方式暴露,内部实现完全不透出。
自定义节点注册:
type NodeRenderer = (node: DiagramNode, ctx: RenderContext) => HTMLElement | SVGElement; registry.register('dbTable', (node, ctx) => { const el = document.createElementNS('http://www.w3.org/2000/svg', 'g'); // 在这里根据 node.data 画表格外观 return el; });采用注册制之后,业务方新增一种节点类型几乎不需要触碰底层代码。这是当时我们判断"自研值得"的最重要理由——业务在快速变化,而底层模型稳定,注册制把变化隔离在业务侧。
6.3 包体积优化:按需加载
diagram-design 的包我做成了多入口:核心渲染与交互是主包,布局引擎、导出模块、内置节点类型各自独立。业务方可以根据场景只引需要的部分。现在前端工程都支持 tree-shaking,把大模块(尤其是布局和 PNG 导出)单独拆出来,首屏体积能显著下降。我见过有些产品只是想让用户"画个简单的框图",结果拉了整个白板引擎进来,首屏多出几 MB,这是完全可以避免的。
7. 复盘:这个项目最值得记住的几个坑
7.1 坐标漂移问题:缩放中心永远是鼠标
这是交互层我踩得最深的一个坑。刚开始做滚轮缩放时,只是简单地改变 zoom 值,然后把 offset 设为offset * ratio。结果缩放过程中,图中某个点会不断飘走,用户完全无法控制缩放到想看的位置。
正确的做法是:缩放前后,鼠标所在位置的逻辑坐标必须保持不变。实现起来也不复杂:缩放前先把鼠标屏幕坐标反转成逻辑坐标,缩放后根据新的 zoom 重新计算 offset,让鼠标重新指向同一个逻辑坐标点。这个转换函数值得一写再写,并且要在框选、拖拽中复用,否则交互时很容易出现"鼠标下没有东西"的诡异手感。
7.2 连线和节点状态不同步
最开始连线的端点坐标是节点坐标的快照,导致节点被拖拽时连线像断开了一样。后来改成每次渲染时根据节点和锚点的当前坐标实时推导连线端点,这个问题就彻底消失了。这里的原则是:不要保存任何可以从其他数据推导出来的冗余字段。数据越冗余,不同步的风险越高。
7.3 撤销栈引用的失效问题
用命令模式做过撤销机制的人,一定遇到过"删了一个节点,然后撤销,结果连线还在引用那个节点"的灵异状态。根因在于命令只记录坐标,没有处理拓扑关系的变化。我现在会为每类命令定义它影响的节点集合,并且在 do/undo 之外维护一个资源版本号。每次执行命令后,所有依赖这些节点的视图都会收到"结构可能已变化"的通知,从而重新校验连线引用。这个教训让我意识到,撤销机制不是简单的历史栈,它是数据模型的第二次实现。
7.4 关于自研的一点个人体会
做 diagram-design 最大的收获,不是写了几千行代码,而是想清楚了一个道理:工具类产品最重要的是边界清晰。一开始很容易陷入"什么功能都想要"的冲动,最后发现每次加功能都会让核心交互变慢。后来我强制自己只做三个核心能力——拖拽、连线、撤销,其余全部靠扩展机制交给业务侧。事实证明,边界清晰带来的收益比功能数量大得多。
最后分享一个注意事项:如果你的场景只是"展示一张图,偶尔编辑几个节点",完全没必要自研,用现成的展示型图表库就够了。但如果你要做的是一款以图表编辑为核心能力的业务系统,希望自定义节点类型、深度控制交互细节、长期演进维护,那自研一个像 diagram-design 这样的小而精的引擎,会是更可持续的选择。