diagram-design 这个名字,放在技术社区里可能被理解成“画图组件”或“图表设计规范”,但对我来说,它是过去大半年一直在迭代的一个内部项目代号。简单说,它是一套完整的图编辑器内核,覆盖从数据模型、自动布局、Canvas 渲染、鼠标交互、快捷键、JSON 存档到 PNG 导出的一整条链路。团队里用它来画架构图、流程图、网络拓扑图和状态机图,省去了“画完截图再贴文档”这种反人类的流程。
为什么想写这篇总结?因为这类项目最容易踩的坑,其实不在某个具体算法,而在整体设计:数据怎么组织、坐标怎么换算、事件在哪一层接、性能退化发生在哪一行。这里面的多数问题,不是看文档就能想到的。所以我按自己做 diagram-design 的真实顺序,把从思路拆解到工程落地、再到排障实录的过程完整写出来。如果你正准备在公司内部自建一套图编辑器,或者需要用代码生成和维护技术图纸,这篇文章应该能帮你少走不少弯路。
整个项目做完后,我对“图表设计工具”这件事的理解有一点变化:它不是一个你拖拽节点、连线就完事的小玩具。真正的复杂度,来自对坐标、数据、渲染和交互这四个模块的一致性管理。任何一个环节没对齐,用户就会感觉“这个图怎么那么难用”。下面按决策顺序来聊。
1. 项目整体设计与思路拆解
1.1 为什么自研,而不是直接拿来改
先说结论:自研 diagram-design 不是“造轮子上瘾”,而是当时确实找不到一个能完全贴着业务走的方案。
我们把市面上主流的流程编辑器、图表库都拉来跑了一圈。有的擅长流程图,节点只能按固定模板来,做成自定义业务节点会非常痛苦;有的擅长组织架构图,但连线的路由方式写死了,想改成正交折线要改内部源码;还有一些看起来功能很全,但 API 设计围绕它自己的数据模型展开,和我们的接口协议完全对不上。最要命的往往是事件模型:别人的库只给你暴露“节点被点击”“边被双击”,可我们要的是“拖拽过程中实时拿到坐标并做合法性提示”,这种需求在封闭的插件机制里很难干净实现。
后来我算了一笔账:用现成库,前两周确实能快速搭出 Demo。但只要进入业务定制阶段,每改一个小需求都要去查它的源码、做 monkey patch,时间成本会快速上升,而且版本升级后这些改动大概率要重写。自己维护一套图引擎,前期投入会高一些,但数据模型、渲染、交互全部可控,后续增加新功能只是往自己的框架里填代码,不用看别人的眼色。
那是不是所有图都应该自研?也不是。如果需求就是简单的流程图、思维导图,用成熟方案反而更稳。判断标准我总结成三条:业务节点是否高度自定义、是否需要与后端协议强绑定、是否对操作体验有非主流要求。只要踩中第二条,自研的收益就很明显,因为你可以把数据格式设计和接口协议放在一起规划,而不是在别人的模型外面再做一层蹩脚的适配。
1.2 渲染层选型:Canvas、SVG 还是 WebGL
diagram-design 最大的技术决策,是渲染层用什么。我们最终选了 Canvas 为主,但做决定之前,我列过一个很朴素的对比表。
| 渲染方式 | 节点规模 | 交互精度 | 文本渲染 | 开发成本 | 典型场景 |
|---|---|---|---|---|---|
| SVG | 小(几百节点) | 高,天然支持事件 | 清晰,可复用 DOM 文本 | 低 | 流程图、图标绘制 |
| Canvas | 中到大(数千节点) | 需要自做命中检测 | 需要手动排版,遇缩放需控制 | 中 | 拓扑图、架构图、大画布 |
| WebGL | 很大(上万节点) | 复杂,基本要自研拾取 | 最费力,纹理或字形方案 | 很高 | 图数据库可视化、大规模网络图 |
我们的场景是架构图和拓扑图,单张画布里节点数量可能到几千,而且节点大多不是简单矩形,要带端口、状态图标、缩略信息。SVG 在两千个节点、三千条边的时候,DOM 数量直接上万,一个拖动操作会触发大量的样式和属性变更,浏览器很快就喘不过气。WebGL 性能确实强,但我们要做的是交互编辑器,不是纯展示页面,拾取、文本排版、多状态绘制都要在渲染管线里自己处理,以当时的团队精力来说并不划算。
Canvas 相对中庸:它有足够的绘制能力,也能承受几千节点的全量重绘,只要做好视口裁剪和状态降级。代价是事件系统、点击拾取、坐标转换全部要自己写。所以选 Canvas 不是因为它最好,而是它在“功能完整度”和“开发成本”之间最平衡。后来的实现也证明,只要你把命中检测和数据索引做好,交互精度完全不输 SVG,甚至因为少了 DOM 事件分发,拖拽反而更顺滑。
1.3 功能边界,先做减法再做加法
这种项目最怕的就是大而全。最早的问卷里,有人希望它能当 Visio 用,有人想让它跑工作流引擎,还有人建议直接做成在线协同白板。如果这些都做,项目半年内都看不到一个能验收的版本。
我先把范围砍到一条核心链路上:画布能展示图、能拖拽编辑、数据能持久化。具体功能列下来是这样的:
- 必做:节点拖拽移动、节点新增删除、连线新增删除、画布缩放平移、框选多选、撤销重做、自动布局、JSON 存档、PNG 导出。
- 缓做:多人实时协同、流程图执行引擎、自由绘制图形、模板市场、一键美化。
- 明确不做:矢量绘图软件级别的自由画布、复杂图文混排、PPT 式演示模式。
边界明确之后,很多设计就有了依据。比如因为不需要自由绘制任意路径,所以图形基础只有矩形、圆角矩形、椭圆、菱形这几种,节点模型可以简化;因为不需要协同,数据层不需要做冲突合并和操作日志同步。定义好“不做”的部分,等于提前砍掉一大半隐形成本。
2. 核心数据模型与布局算法拆解
2.1 图数据的抽象:把数据和渲染解耦
自研图编辑器,最重要的一步就是先定数据模型。这个模型一定要和渲染解耦。我见过很多失败案例,是把节点的 x、y、宽高直接存在 DOM 元素或者 Canvas 对象上,看起来方便,但一旦要序列化、撤销重做、自动布局,就会陷入“到处同步状态”的泥潭。
diagram-design 的数据模型分成三层:Graph 是整张图,Node 是节点,Edge 是边。下面是我在实际代码里使用的核心接口,省略了业务扩展字段,但骨架就是这样:
interface GraphNode { id: string; type: string; // 节点形状类型 x: number; y: number; width: number; height: number; ports?: Port[]; // 连接点 data: Record<string, unknown>; // 业务自定义数据 } interface Port { id: string; side: 'top' | 'right' | 'bottom' | 'left'; index: number; } interface GraphEdge { id: string; source: string; target: string; sourcePort?: string; targetPort?: string; type: 'straight' | 'orth' | 'bezier'; points?: { x: number; y: number }[]; // 手动航点 data: Record<string, unknown>; } interface DiagramData { nodes: GraphNode[]; edges: GraphEdge[]; viewport?: { x: number; y: number; scale: number }; }关键点有三个。第一,节点的坐标只存一份,就是世界坐标,渲染时再转换成屏幕坐标,不额外保存“渲染后的位置”。第二,data字段是开放的黑盒,业务可以在里面塞任何内容,而内核永远不解析它,这样节点组件就能自由定制。第三,边的端点不直接存坐标,而是存节点 id 和端口 id,在渲染前通过端口位置计算出来。这样节点移动时,边会自动跟着变化,不需要为每条边维护位置缓存。
当时为了处理“数据类型安全”,也犹豫过要不要为每种节点定义强类型接口。后来我放弃了,因为业务节点太多太杂,强制收口会让上层的每个业务都来改内核类型定义,反而破坏解耦。只要保证最外层的节点类型注册机制是强约束的,内部的 data 就是透明的 JSON 就好。
2.2 自动布局:先跑通层次布局,再谈好看
图编辑器如果没有自动布局,在节点一多的时候会非常痛苦。人肉拖出来的图往往毫无章法,连线交叉得一塌糊涂。但自动布局又是一个巨大的技术面,力导向、树形布局、层次布局、网格布局各有适用场景。diagram-design 里我优先做的,也是最常被用到的,是层次布局,用来处理有依赖关系的架构图。
层次布局的核心思路很直接:先确定每个节点在第几条水平线上,再调整同一层内的左右顺序,最后给每一层分配 y 坐标。基础的层分配算法可以这样描述:
function layeredLayout(nodes, edges): 构建有向图,边从 source 指向 target 计算每个节点的“最长路径层”: 没有入边的节点层为0 其他节点层 = max(入边源节点层) + 1 按层排序,层内先用原始顺序 给每层分配 y:层高 = 最大节点高度 + 层间距 对相邻层执行节点排序调整,降低边交叉 由边两端节点位置计算每条边的路径 返回更新后的节点坐标这里最容易被忽视的是“最长路径层”而不是“最短路径层”。如果用最短路径,一个被多条路径依赖的节点会被过早地放到上层,导致后面大量边的走向都是反向的。最长路径能保证每个节点都至少排在它所有前置节点的后面,视觉上所有边的方向基本一致,整张图看起来就顺很多。
层内排序降低交叉,是一个可以一直优化的点。第一版我只做了“同层按入边源节点的平均 x 排序”,效果已经能接受。更复杂的算法,比如重心法、启发式交换,后续可以慢慢加,但不必在第一版就追求极致美观。另外,不是所有图都适合层次布局,比如前后端调用关系往往是网状结构,这时候力导向布局会更好。所以我在设计里保留了布局接口,自动布局模块通过注册机制扩展,不同图类型可以绑定不同布局策略。
2.3 连线路径与正交路由
连线是 diagram-design 里视觉专业度的胜负手。如果连线都直接画一条直线,稍微复杂一点的图就会互相穿心,没法看。所以默认的连线类型是正交折线,也就是横平竖直的连接方式,这种风格在架构图、网络拓扑图里最通用。
实现正交路由,第一版没必要上重型算法,可以先做一个“带偏移方向偏好的简单路由”:
- 从源节点的连接口出发,先沿水平或垂直方向走一段;
- 中间遇到节点或边的包围盒阻挡时,在阻挡物之前插入垂直/水平转折点;
- 到达目标连接口附近时,再沿水平或垂直方向接入。
听起来简单,但代码关键在“出线方向”和“进线方向”。端口定义在节点的左、右、上、下四边,那么从右边端口出去,第一段肯定向右;进入目标节点的左侧端口,最后一段肯定向左。两者的垂直坐标如果不在同一条线上,就插入一个垂直连接段。这样的连线天然只包含水平线段和垂直线段,视觉上干净很多。
当然这种简单路由不会完美合路,多条边共用一段路径的情况也处理不了。所以我在边上保留了points数组,业务人员可以手动拖拽增加“途经点”。绘制时,路径会依次穿过这些航点。底层折线算法只负责“从端口出发到第一个航点”“在两个航点之间”“从最后一个航点到目标端口”这三段,这样复杂绕线问题就可以用人类智慧手动解决,程序只需要保持基础整洁。
3. 实操过程与核心环节实现
3.1 多画布分层渲染
diagram-design 最终是四个 Canvas 叠在一起,而不是一个 Canvas 画所有内容。从底下往上分别是:网格画布、连线画布、节点画布、交互画布。每层都有明确的职责,互不越界。
为什么拆层?因为重绘频率不一样。拖动节点时,节点层必须每帧刷新,但网格层完全不用动;框选时,交互层每帧都要画虚线框,可节点层没必要跟着重绘。拆层之后,update 时可以只标记需要变化的那层,draw 时只重绘对应画布,性能会好非常多。代价是四个画布叠加在一起的层级管理要小心,不过 CSS 里设置position: absolute加上统一宽高,对齐问题并不难。
初始化时的要点是处理 devicePixelRatio。Canvas 的物理像素宽高和 CSS 宽高不一致会导致模糊。我会在每次尺寸变化时重新设置画布 buffer 大小:
function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) { const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 保证内部按 CSS 像素绘制 }这里如果漏掉setTransform,后面画出来的所有坐标都会乘上 dpr,导致文字小、形状错位,而且导出图片时差异还会更明显。有时间的话,甚至可以把每一层画布的刷新逻辑都包成一个小型 renderer,对外只提供invalidate()接口,由内部决定哪一层需要重绘,这样业务调用方完全不需要关心渲染细节。
3.2 拖拽、缩放、框选背后的坐标变换
画布类编辑器的核心是把“世界坐标”和“屏幕坐标”分开。世界坐标是图本身的坐标,缩放平移不影响它;屏幕坐标是你鼠标事件的坐标。节点拖拽时,鼠标返回的是屏幕坐标,但节点的位置存在世界坐标里,所以每时每刻都需要换算。
换算公式很基础,但必须统一到一处,防止到处重复实现导致偶然错误。我在代码里维护了viewport对象,它是{ x, y, scale },含义是“世界坐标原点在屏幕上的位置”和“放大倍数”。对应的两个函数长这样:
function worldToScreen(wx: number, wy: number) { return { x: wx * viewport.scale + viewport.x, y: wy * viewport.scale + viewport.y, }; } function screenToWorld(sx: number, sy: number) { return { x: (sx - viewport.x) / viewport.scale, y: (sy - viewport.y) / viewport.scale, }; }缩放操作有个常见的坑:如果只是简单地把scale变大变小,画面会围绕画布左上角缩放,而不是围绕鼠标位置。用户会觉得缩放很“飘”。正确做法是缩放前先用screenToWorld算出鼠标所在的世界坐标,缩放后保证这个点仍处于鼠标位置:
function zoomAt(mouseX: number, mouseY: number, nextScale: number) { const world = screenToWorld(mouseX, mouseY); viewport.scale = clamp(nextScale, 0.2, 4); viewport.x = mouseX - world.x * viewport.scale; viewport.y = mouseY - world.y * viewport.scale; }框选实现则简单得多:在交互画布上用虚线矩形表示选区,判断一个节点是否被选中,只需要看节点矩形和选区矩形是否相交。注意这里要用世界坐标比较,可以把选区矩形也先转换到世界坐标,再做 AABB 相交判断,省去把每个节点都转回屏幕坐标的开销。命中检测同理,遍历所有节点,用矩形包含或相交判断。
3.3 导出图片与工程保存
图表画完之后要能拿出去用,所以导出是刚需。diagram-design 支持导出为 PNG 图片,同时也支持保存工程文件用于后续继续编辑。
PNG 导出的坑在于我们用的是多 Canvas 分层渲染,不能直接拿某一个 Canvas 去toDataURL。正确做法是新建一个离屏 Canvas,宽度和高度按当前图的“世界包围盒”乘以导出倍率计算,再逐个把网格、连线、节点的内容绘制上去。绘制时注意先fill一个背景色,否则透明背景下浅色节点会看不清。整个过程我封装成一个工具方法,核心代码大概是这样:
function exportPNG(scale = 2) { const bounds = getWholeGraphBounds(); // 世界坐标系包围盒 const canvas = document.createElement('canvas'); canvas.width = bounds.width * scale; canvas.height = bounds.height * scale; const ctx = canvas.getContext('2d'); ctx.fillStyle = currentTheme.background; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.scale(scale, scale); // 依次绘制网格、连线、节点,注意平移到 bounds 的左上角 drawGrid(ctx, bounds); drawEdges(ctx, bounds); drawNodes(ctx, bounds); return canvas.toDataURL('image/png'); }工程保存则不保存像素,而是保存 JSON 形式的DiagramData,里面包含节点、边、视口信息。这样重新打开时可以回到之前的位置继续编辑。比较关键的是加上version字段,因为后续结构肯定会调整,没有版本号的存档就是给自己埋雷。加载时根据 version 走不同的迁移逻辑,虽然麻烦,但这是长期维护里性价比最高的一件事。
3.4 性能优化:从全量重绘到视口裁剪
几千节点直接全量重绘,Canvas 也能扛一阵子,但拖拽时会明显掉帧。第一刀永远是视口裁剪:只画当前可见区域内的节点和边。这个优化非常朴素,效果却立竿见影。
判定可见性的代码也简单:
function isNodeVisible(node: GraphNode, viewportBounds: Bounds) { return ( node.x + node.width > viewportBounds.left && node.x < viewportBounds.right && node.y + node.height > viewportBounds.top && node.y < viewportBounds.bottom ); }有了裁剪函数后,渲染循环从“遍历所有节点画出来”变成“遍历所有节点,不在视口内就跳过”。对于与视口相交的边,也可以做裁剪,尤其是跨越很长距离的折线,如果两端都不在视口内而且离视口很远,就跳过不画。但要注意,一条线的两个端点一个在视口内、一个在视口外,这种线不能简单跳过,还得画。
第二刀是“缩放降级”。当画布缩放比例比较小的时候,整个图都缩成一小块,这时候再画节点内部的文字、阴影、渐变没有意义。我设置了两个阈值:低于 0.6 不绘制文本,低于 0.4 不绘制阴影和复杂状态图标。用户缩小后看到的是一块块色块,反而比一堆糊掉的文字更清爽,放大的时候又能看到完整细节。
第三刀是网格缓存。网格背景每帧都是重复线段,完全没必要跟着节点层一起重绘。我会在 resize 时创建一个独立的离屏 Canvas,把所有网格线一次性画上去,刷新时直接drawImage,能省掉每一帧的几十次stroke调用。这个优化很小,但对低端设备很友好。
4. 常见问题与排查技巧实录
4.1 坐标偏移和缩放失真,十次里有八次是 DPR 问题
做 diagram-design 过程中,我被问得最多的一个问题就是“为什么我拖节点到某个位置,松开后它跳了一下”。这通常不是事件逻辑有问题,而是鼠标坐标换算不对。
最常见的原因有三个。第一,拿到的是offsetX / offsetY,但 Canvas 的物理尺寸和 CSS 尺寸不一致,鼠标偏移量没有除以缩放倍数。第二,多 Canvas 布局时,事件绑在最上层 Canvas,但getBoundingClientRect用的是容器元素,导致坐标系基准不统一。第三,窗口更换显示器或者浏览器缩放后,devicePixelRatio变化了,但没有重置 Canvas 的 buffer 大小。
排查这类问题,我习惯在画布角落写一个调试面板,同时显示三组坐标:原生鼠标屏幕坐标、经过getBoundingClientRect换算后的本地坐标、经过screenToWorld后的世界坐标。然后在画布中心画一个十字标记,用鼠标把它拖到网格交叉点上。如果视觉位置和实际数据的坐标对不上,很快就能看出来是哪个环节丢了缩放因子。
4.2 大数据量卡顿,先从性能消耗大头开始查
有段时间全图画到 4000 个节点、6000 条边时,拖动节点每帧要 30 毫秒以上,体感已经明显卡顿。我没有盲目去优化数据结构,而是先用performance.now()把渲染的每个阶段都卡了一遍时间,结果发现耗时大头有三个:连线渲染占了接近一半,因为每条边都画了带阴影的贝塞尔曲线;节点文本绘制占用 30%,因为在视口内有大量可见节点都要执行fillText;最后 20% 来自阴影shadowBlur和各种状态判断。
定位到具体原因后,优化就是按部就班的事。连线默认从贝塞尔改成直线或简单的正交折线,因为架构图里太多平滑曲线反而是视觉噪音;文本绘制只在缩放阈值以上时才执行;阴影改成绘制一个半透明圆角矩形作为“假阴影”,而不是用 Canvas 原生shadowBlur,后者性能实在太差。把这些改完之后,同样一张图的拖拽帧耗时降到了 8 毫秒左右,体感丝滑很多。
这里有个经验:先量化,再优化。不要听人说“ShadowBlur 性能差”就立刻把阴影全部去掉,你得先知道自己的卡顿是不是由它引起的。用分阶段计时器把所有耗时摆出来,优化会变得异常朴素,就是找到最大头,干掉它,然后再测。
4.3 拖拽出画布后,事件突然不触发了
这类问题在桌面浏览器上不容易出现,但在触摸屏和部分窗口环境下会偶发。现象是:你按住节点,快速拖出浏览器窗口,再拖回来,发现节点卡住了,pointermove不再触发。
原因在于鼠标指针移出画布后,原来绑定在 Canvas 上的事件不再收到消息,而pointerup也被浏览器吞掉,状态机一直停留在“拖拽中”。解决办法有两个,一是监听事件时直接挂在容器元素上而不是某个 Canvas 上,让事件区域覆盖整个画布容器;二是在pointerdown时主动调用setPointerCapture,强制浏览器把后续指针事件都派发给当前元素:
canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); startDrag(e); }); canvas.addEventListener('pointermove', (e) => { if (isDragging) updateDrag(e); }); canvas.addEventListener('pointerup', (e) => { canvas.releasePointerCapture(e.pointerId); endDrag(e); });除了事件绑定,还有一个容易被忽略的点是“多画布层级下事件到底发给谁”。如果四个 Canvas 叠在一起,事件会自动派发给最上面的那个元素。为了统一,我会把事件统一绑在最上层的交互画布上,内部再通过screenToWorld换算到世界坐标,而不是让每一层都处理自己的事件。这样即使未来把交互层重构掉,事件入口也不需要改。
4.4 主题定制,最容易演变成颜色硬编码灾难
业务上会同时出现深色模式、浅色模式、品牌定制色、状态告警色。如果一开始不把主题机制做出来,后期就会在代码里到处写死#1890ff这种颜色,而且每种节点组件还会再抄一遍。
diagram-design 里我定义了一套Theme接口,所有绘制函数只从theme读取颜色、线宽、字体,不写死任何视觉参数:
interface Theme { background: string; gridColor: string; nodeFill: string; nodeStroke: string; nodeText: string; edgeColor: string; edgeWidth: number; selectionStroke: string; shadowColor: string; fontFamily: string; }节点绘制函数签名大概是drawNode(ctx, node, theme),内部用到颜色时一律theme.nodeFill、theme.nodeStroke。这样切换主题时只需要替换 theme 对象,然后通知渲染层重绘即可。为了支持交互状态,我还会在绘制前根据节点状态合并一个状态主题,比如 hover 时边框加亮、selected 时描边换色、invalid 时填充变红。这个“状态样式叠加”不要和主题混在一起,否则主题会膨胀得很难维护。
顺带提一个教训:不要为了“图省事”把颜色同时写进数据和 theme 里。如果业务节点的data里带了一个bgColor,绘制时就会优先用它,结果就是深色主题下个别节点依然白得刺眼。后来我明确规则:业务数据可以控制内容,但视觉表现统一走主题;如果某个节点确实需要特殊颜色,通过注册的节点组件显式声明,而不是在通用渲染链路里开口子。
5. 一些收尾的实践心得
做 diagram-design 的过程里,我最大的一点体会是:这类项目看起来是绘图,本质上是“数据描述、坐标转换、正确渲染”六个字。只要把数据模型定义清楚、坐标换算统一到同一个工具库、渲染层只消费不修改数据,后续所有功能都能往里稳定叠加。反过来,如果前期为了省事把坐标散落到各个组件里,后面加自动布局和协同编辑时会痛不欲生。
给准备入手的团队一个建议:不要一开始就想着实现最复杂的全局自动布局和完美路由。先把“人工拖一张 20 个节点的图”做到流畅好用,再逐步加自动布局、性能优化、大图支撑。因为人工编辑场景是所有高级功能的地基,地基不稳,自动布局排出来的图再漂亮也没法继续编辑。
最后分享一个我自己的小习惯:把所有边界操作整理成一个交互测试清单。比如“节点拖到画布外再拖回来”“缩放比例小于最小值时的提示”“框选时恰好从节点右下角开始”“导出图片时包含超出视口的节点”。每个边界情况都写成一个最小可复现 Demo,每次改动后按清单过一遍。这个小清单帮我挡掉过至少三次回归问题,也让我在加新功能时更有底气。diagram-design 到现在还在迭代,后续考虑接入自动化布局推荐和更丰富的节点组件体系,但这个“先让基础链路稳如磐石”的思路,我会一直带下去。