做了快大半年的在线图设计工具,也就是这个 diagram-design 项目,总算从“能画几个框连几条线”进化到了可以正经画拓扑图、流程图、架构图的可用状态。这期间踩的坑、推倒重来的设计、跟性能死磕的过程,我觉得比最终产品本身更有记录价值。这篇就当是自己项目复盘,也顺便给想搞同类 Web 图编辑器的朋友一些可参考的实操细节。
diagram-design 本质上是一个纯前端的图形化编辑工具,核心能力是让用户在画布上自由创建节点、拖拽连线、框选、缩放,同时支持 JSON 数据的导入导出和当前主流图片格式的渲染导出。适合想快速搭一个内部绘图工具的个人开发者,也适合需要在一个大平台里嵌入图形编辑能力的团队项目做参考。我写这篇文章的时候,项目已经跑通了几万级节点的渲染和编辑,所以整篇内容会集中在架构选型、数据模型、渲染交互、自动布局、性能优化这几个方向,每个部分我都会把“为什么这么选”和“实际怎么落地”尽量讲透。
1. 项目定位与整体架构拆解
1.1 核心需求解析:一个图编辑器到底要做什么
很多人一提到 diagram-design,第一反应就是“画框 + 箭头”。但真要做到好用,问题清单一下就铺开了:画布需要无线扩展吗,节点拖拽要不要吸附对齐,连线怎么跟节点边缘而不是中心点锚定,框选跨区域怎么处理,撤销重做栈怎么设计,浏览器崩溃之后数据能不能恢复,图表导出的清晰度能不能扛住打印需求。
我前期的调研结论比较朴素:市面上的通用图工具可以分两类,一类是 Tableau / 板类工具那种“画布 + 自由图形”的轻量方案,另一类是专业建模工具那种“强约束 + 表单驱动”的重型方案。diagram-design 取的是中间态,既要支持自由绘图,又要提供可编程的数据结构。这个定位直接影响了后续几乎所有技术决策:底层数据结构必须支持任意节点类型,画布渲染必须独立于业务数据,交互层要做到识别手势意图而不是简单响应鼠标坐标。
这里有一个容易被忽略的点:图编辑器的核心是“编辑器”三个字,不是“图”。也就是说,重点不仅仅是把节点画出来,而是让用户能够编辑这篇图、维护这篇图、导出一篇图。数据模型、状态管理、指令系统、渲染引擎,都必须围绕“可编辑、可追溯、可恢复”来设计。这是我在项目早期最大的认知转变。
1.2 技术选型为什么这么定
技术栈上我最终选了 TypeScript + Canvas 2D 自研渲染,配合 Web Worker 做布局计算,组件层用了类 React 的响应式框架,但没有直接上一套现成的图形编辑器框架,核心原因有三点。
第一,可编程性是刚需。图网格的数据结构,比如节点、边、画布视图等,必须可以直接被业务代码读取和修改,这意味着数据层和渲染层要严格分离。市面上的成熟绘图框架大多封装很重,你想在每次拖拽结束的时候拿到“哪个节点的连接锚点被拖到了哪个索引位置”,往往需要绕很多圈子。
第二,渲染性能可控。我对比过 Canvas 和 SVG 方案,SVG 的优势是 DOM 天然可事件绑定,做 100 个节点以内的小图非常轻松,但是一旦节点数量上到 3000 以上,DOM 节点太多带来的内存和事件代理压力会非常明显。Canvas 全部走绘制指令,配合脏矩形和视口裁剪,理论上能支持几万节点的平铺展示和基础交互。
第三,做图编辑器的核心壁垒其实在布局算法和交互细节上,与其花大量时间熟悉第三方框架的开发范式,不如把精力放到这些核心逻辑上。这个选择让我在后期排查问题时有十足的掌控力,代价是初期做了不少渲染基础工作,比如坐标变换、命中检测、脏矩形更新这些,说实话工程量不小。
1.3 整体架构与模块划分
项目从模块上拆成了四层,层与层之间通过接口约定连接,不互相引用具体实现。
数据层负责图的业务状态:节点的位置尺寸、边的路径、分组的层级结构、画布的视图变换和缩放级别。渲染层只负责把数据层的内存对象绘制出来,它不关心对象是节点还是多边形组件,只关心拿到什么 Shape 描述就画什么。交互层接收鼠标、键盘、触摸事件,转换成标准指令比如 MOVE_NODE、ADD_EDGE、SELECT_AREA,然后交给指令系统执行。指令系统再去改数据层,并广播“数据已变更”的事件让渲染层刷新。
这样分层的好处是显而易见的。就拿渲染层切换来说,项目早期我用的是上一版 Canvas 渲染,后期想增加离屏渲染优化,只需要在渲染层内部做改造,数据层和交互层几乎不用动。第二个好处是便于做测试,指令系统是纯函数式的输入输出,可以单测覆盖,不需要开浏览器去模拟用户行为。
interface GraphCommand { type: CommandType; payload: Record<string, unknown>; undo?: () => void; redo?: () => void; }指令系统是所有编辑操作的标准入口,类似 git 的思想,每一步修改都是可逆的提交。实际开发时我强烈建议从一开始就建立这个抽象,否则后期补撤销和重做功能会极其痛苦。
2. 数据模型与核心数据结构
2.1 图数据模型设计:节点、边、分组与锚点
图编辑器最基础的数据结构是节点(Node)和边(Edge)。节点至少包含 id、x、y、width、height、zIndex 和 type 字段,type 决定了渲染层用哪种图形来绘制这个节点。边至少包含 id、sourceNodeId、targetNodeId、sourceAnchor、targetAnchor 和路径字段。
只记录 id 而不直接嵌套坐标是数据模型设计里的关键决策。如果边里面直接存了“连接线的起点坐标”,那么节点一旦被拖动,边的起点坐标就失效了,必须手动去同步每一条边的数据。正确做法是让边永远引用源节点和目标节点的 id,布局变化后,程序根据节点的当前位置动态计算锚点坐标。这就好比导航系统只记录目的地地址,而不是记录目的地的经纬度快照,每次出发前现算路线才对。
锚点这个概念值得展开说一下。节点四周的上下左右中心,或者自由位置的几个固定点,可以作为边的连接口。实际画图时,边不是从节点中心连出来的,而是从锚点出发,沿着节点边缘贝塞尔曲线连到另一个锚点。如果边从中心连出,线会覆盖节点内部的内容,很难看。
我设计的锚点数据结构:
interface AnchorPort { id: string; nodeId: string; offsetX: number; // 相对于节点左上角的偏移 offsetY: number; direction: 'top' | 'bottom' | 'left' | 'right'; }方向字段用于自动布局时计算连线的出口走向,没有这个字段,排布算法很难知道一条边应该从节点的哪个方向出门。
分组结构也要在数据模型里预留好。常见做法是给节点增加 parentGroupId 字段,组节点本身也是一个 Node,只不过它有 children 子节点集合。嵌套分组在编辑时要注意一个不变量:子节点的坐标一定是相对父组节点的相对坐标,父组移动时,子节点的画布坐标跟随变化。这个约束如果不在数据层卡死,交互层很容易把坐标搞乱。
2.2 序列化与版本兼容:让 JSON 既通用又可迁移
图数据最终要落盘和导入导出,最自然的选择是 JSON。但 JSON 本身只是序列化格式,真正的难点是版本兼容。我遇到的一个经典问题是:早期保存的 JSON 文件里,节点只有宽高,没有圆角半径和颜色配置;后期增加样式字段后,老文件打开后样式字段是 undefined,渲染层要兜底给默认样式。
解决思路是给整个文件加一个 schemaVersion 字段,导入的时候根据版本信息走迁移通道。迁移函数做成纯函数,输入老版本的 JSON 对象,输出新版本的对象,链式调用逐个版本升级。我实际项目中出现了 4 个版本,代码大致像这样:
const migrations = { 1: (data) => ({ ...data, nodes: data.nodes.map(node => ({ ...node, borderRadius: node.round ? 8 : 0, })), }), 2: (data) => ({ ...data, edges: data.edges.map(edge => ({ ...edge, lineType: edge.dashed ? 'dashed' : 'solid', })), }, // ...更多版本迁移逻辑 };带上版本号之后,画布的核心画布视图参数也要跟着走:zoom 缩放级别、panX 平移量、panY 平移量,这些字段决定了用户打开文件之后第一眼看到的是哪个区域。没有记录这些的话,用户打开文件总是得手动挪到自己的画布中心,体验很差。
2.3 数据流转:从 JSON 到内存对象再到渲染指令
一次完整的打开文件过程,数据会经过四个阶段:原始 JSON 字符串、解析后的 JSON 对象、规范化后的内存模型(Map 结构)、渲染层消费的绘制指令列表。
为什么内存模型要用 Map 而不是数组?因为图编辑里高频操作是“给我 id 为 xxx 的节点”,数组查一次是 O(n),Map 是 O(1)。一旦项目规模上来,每次连线都要查两端节点的当前位置,数组遍历的耗时可以直接卡掉交互帧率。我在一次拓扑图导入测试里,6000 个节点就用数组实现,连线时明显感觉到卡顿,换成 Map 之后这个瓶颈立刻消失了。
规范化过程中要处理数据校验。节点缺字段补默认值、边的端点引用了不存在的节点则丢弃并告警、坐标切成两位小数避免浮点误差积累。校验逻辑往往在项目后期会越加越多,因为用户会通过不同渠道产出格式略不一样的 JSON 文件,防御性编程在这里非常值得投入。
3. 画布渲染与交互实现细节
3.1 自研 Canvas 渲染引擎的技术要点
我实现基于 Canvas 2D 的自研渲染引擎时,从抽象层面只做三件事:收集绘制指令、按视口裁剪指令、批量执行绘制调用。核心接口是一个 draw 函数:
interface RenderObject { id: string; transform: { x: number; y: number; scale: number }; shapes: ShapeInstruction[]; } type ShapeInstruction = | { type: 'rect'; bounds: [number, number, number, number]; fill: string; stroke: string; radius: number } | { type: 'path'; d: string; stroke: string; fill: string; lineWidth: number } | { type: 'text'; text: string; x: number; y: number; fontSize: number; fill: string };绘制指令是序列化的描述结构,业务代码改数据层,渲染层根据数据生成指令,不直接持有 DOM 元素或 Canvas 对象实例,这让图形切换样式和重新绘制变得很轻量。
多图层渲染是性能优化的重要基石。我把画布拆成三层:底图层的网格背景、主图层的内容节点和边、覆盖层的选中框和交互反馈。每次变更只重绘对应层,而不是整张画布。比如拖拽节点的时候,网格背景和选中框都不用重新生成,只需要重绘主图层的内容。这样一帧的绘制量从“所有图像素”降到“移动区域附近的一小片”,帧率提升非常明显。
3.2 鼠标事件与坐标变换:从屏幕坐标到画布坐标
Canvas 事件处理最基础也最核心的东西是坐标变换。浏览器给我们的鼠标事件坐标是视口坐标即屏幕内坐标,而画布有 pan 偏移和 zoom 缩放,所以必须经过一次矩阵转换才能得到画布坐标:
function screenToCanvas(sx: number, sy: number, view: { zoom: number; panX: number; panY: number }) { return { x: (sx - view.panX) / view.zoom, y: (sy - view.panY) / view.zoom, }; }这个公式是整个交互系统的地基。坐标不对,后面所有命中检测、连线的反馈预览全部歪掉。另一个容易掉进的坑是缩放是以鼠标位置为中心还是以画布中心为中心。专业图工具基本都是跟随鼠标位置缩放,用户的习惯是“鼠标指着哪里就放大哪里”,实现上要先把鼠标屏幕坐标转成画布坐标,再按比例调整 panX 和 panY:
function zoomAt(mouseX: number, mouseY: number, newZoom: number, view: View) { const canvasXY = screenToCanvas(mouseX, mouseY, view); view.zoom = Math.min(Math.max(newZoom, 0.2), 5); view.panX = mouseX - canvasXY.x * view.zoom; view.panY = mouseY - canvasXY.y * view.zoom; }我自己早期用了一个笨办法,先改 zoom 再手动平移让鼠标指向的画布坐标保持不变,试了一个多小时总觉得缩放逻辑稳定不了,后来坐下来把公式推导了一遍才搞清楚关键就在于同时修改 panX 和 panY,顺序问题反而次要。
3.3 节点拖拽、连线与框选的关键实现
节点拖拽看着简单,实际触发的操作很多。一次完整拖拽包含以下几个状态:mousedown 时命中检测选中节点、记录鼠标按下时节点初始坐标和鼠标初始坐标、mousemove 时按初始坐标加差值算出新位置、mouseup 时判定是否需要做吸附对齐。
吸附对齐是个加分项也是减分项。减分是说对齐逻辑写得不好会让节点像被“粘住”一样,拖动不跟手。我的做法是只检测跟鼠标移动方向垂直的若干条网格线,比如水平移动时优先对齐横着的参考线,而且对齐判定通过后就返回对齐位置不再继续吸附。阈值我设置为 5 像素,灵敏度刚好。
连线交互的关键在于区分两种意图:用户是在已有节点之间建立连接,还是想把边拖拽到空白区域创建一个新节点。我用拖拽距离阈值来区分,超过 8 像素才进入连线预览状态,这时候渲染一条从锚点出发跟随鼠标移动的贝塞尔曲线,同时高亮显示可以连接的锚点。如果鼠标落在空白区域,在 mouseup 时创建一个新的默认节点并把边连上这个新节点。这个小功能用起来会觉得工具很“懂”,实现成本其实不高。
框选则是把选择矩形内的所有节点加进选中集合。边界情况是勾选时如果按住 Shift,应该是追加选择而不是重新选择。我踩过的一个坑是框选范围内包含被遮挡节点时表现不一致,后来明确定义框选只按坐标边界判断,不考虑遮挡关系,因为为了假想遮挡去做射线检测,性能代价完全不划算。
4. 自动布局算法的工程落地
4.1 为什么需要自动布局:从手工排布到可复用编排
图编辑里最花时间的事情之一就是手工调整节点位置。用户拖拽几十个节点去适配一种美观的排布方式,费时且结果主观。自动布局的价值在于:批量生成的图,比如 JSON 导入、数据库表结构逆向出来的 ER 图,可以一键排布成结构清晰、连线不交叉的状态。
我最初决定集成一个开源布局库,后来评估后发现很难直接复用,因为布局库通常要求输入统一的图对象,输出所有节点的坐标,但我的数据模型带有分组、锚点、方向这些扩展字段,开源库的信息建模不足以表达这些语义。所以在自己项目里集成了一个精简的布局引擎模块,实现了两种算法:分层布局和力引导布局。
4.2 分层布局:树形图与流程图的自动化
分层布局适合流程和树状结构,算法核心思路就是拓扑排序再加上行列分配。
第一步做拓扑排序,搞清楚节点的层级深度。源节点层数为 0,它的下游节点层数为 1,以此类推。如果图里有环,比如 A 连到 B、B 连到 C、C 连回 A,就没办法做严格的分层,需要先检测环并打断一条临时边,排完布再把临时边恢复。
第二步做层内排序,目的是尽量减少连线交叉。我用的是启发式方法:先按入度排序,然后反复把同一层节点按“连接到下一层节点的顺序”重新排列,迭代若干次。实测下来交叉数能减少约 60%,效果已经能满足大部分流程图的可读性要求。
第三步分配坐标。每个节点纵向坐标由层数乘以固定间距决定,横向坐标由同层节点的索引顺序决定。为了保持布局紧凑,不固定画布横向宽度,而是按该层最大节点宽度动态求和。
分层布局的输出是一个蕴含层级语义的坐标分配,处理完之后再用贝塞尔曲线连接边。因为层级关系明确,边的走向可以设为垂直正交模式,视觉上比任意曲线更符合流程图的标准观感。
4.3 力导向布局:通用图的物理模拟
力导向布局适合没有明显层级关系的图,比如社交关系、依赖关系复杂度高的网络图。核心思想是把每个节点看作物理粒子,节点之间连边类似弹簧,没有边的点会被均匀力推开,整体收敛到能量最低的位置。
我维护了三类力:弹簧力(沿边方向,长度越偏离理想距离受力越大)、库仑斥力(所有节点之间互相排斥,距离越近力越大)、重力(把所有节点轻微向画布中心牵引,防止整体散出视野)。
每帧迭代更新:
function tick(graph, positions, params) { graph.nodes.forEach(n1 => { let fx = 0, fy = 0; graph.nodes.forEach(n2 => { if (n1.id === n2.id) return; const delta = distance(positions[n1.id], positions[n2.id]); const repulse = params.repulsion / (delta * delta); fx -= (positions[n2.id].x - positions[n1.id].x) / delta * repulse; fy -= (positions[n2.id].y - positions[n1.id].y) / delta * repulse; }); graph.edges.forEach(e => { if (e.sourceId !== n1.id && e.targetId !== n1.id) return; const target = e.sourceId === n1.id ? e.targetId : e.sourceId; const delta = distance(positions[n1.id], positions[target.id]); const spring = params.springStiffness * (delta - params.idealLength); fx += (positions[target.id].x - positions[n1.id].x) / delta * spring; fy += (positions[target.id].y - positions[n1.id].y) / delta * spring; }); velocities[n1.id].x += fx * damp; velocities[n1.id].y += fy * damp; }); // 更新位置并限制迭代次数 }三层嵌套循环的时间复杂度接近立方阶,节点多了很容易成为性能杀手。实测在 300 个节点、800 条边时,JavaScript 直接跑力导向迭代明显掉帧。后来我把布局计算挪进了 Web Worker,主线程稳定 60 帧,布局计算在后台完成,完成后把坐标数据回传,这个分工很有效。
4.4 布局参数调优与实战示例
布局算法不是配好参数就能通吃所有场景,我总结出几组经验参数:
| 场景 | 分层布局间距 | 力导向刚度 | 迭代次数 |
|---|---|---|---|
| 系统架构图 | 纵向 80px,横向 120px | 不使用力导向 | 不适用 |
| 数据库 ER 图 | 纵向 100px,横向 160px | 不使用力导向 | 不适用 |
| 社交关系图 | 不适用 | 0.02 刚度,斥力 500 | 400 次 |
| 依赖关系图 | 不适用 | 0.015 刚度,斥力 800 | 600 次 |
参数表不是一个死规矩,它只是我项目里的默认值。跑布局算法时最好出一个“预览 + 应用”的流程,用户可以先看一轮结果觉得不满意再调整参数。自动布局功能如果做成一键定死,往往是功能上线两周后就没多少人使用了,相反“可干预、可调整、可回退”的体验会让功能复用率提高很多。
5. 性能优化实录
5.1 瓶颈定位:从 Draw Call 到内存分配
diagram-design 开发到了中后期,我遇到最大的性能瓶颈出现在打开大文件的时候。那次我用脚本生成了一个包含 12000 个节点、16000 条边的模拟拓扑图,文件导入后画布渲染几乎卡成幻灯片。
先别急着优化渲染代码,第一步要做的是确认瓶颈在哪个环节。我使用浏览器 performance 面板抓取了一段 Heatmap,发现渲染帧时间里占比最大的是计算节点坐标变换和绘制填充文字,其次是事件处理中的命中检测需要遍历所有节点边界。
分析下来核心瓶颈其实有三个方向:
- 遍历成本太高,每个操作都要全量遍历节点数组或边数组。
- 绘制指令没有复用,每次重绘都重新计算一遍所有样式,即使样式根本没变。
- 事件命中检测直接用线性扫描全部节点,没有空间索引就无论如何都快不了。
5.2 针对性优化:视口裁剪与图元批量重绘
优化的第一刀砍向视口裁剪。Canvas 的 drawRect 本身很快,但前提是不要绘一大堆在视口之外的图形。我加了一个简单的可见性判断:
function isVisible(rect: Rect, viewport: Rect) { return rect.x < viewport.x + viewport.width && rect.x + rect.width > viewport.x && rect.y < viewport.y + viewport.height && rect.y + rect.height > viewport.y; }在 12000 个节点的场景下,一屏大概只能看到 300 到 400 个节点,裁剪后绘制指令量降低到原来的约 3%,那帧率立刻就好了很多。
第二刀优化了重绘策略。原先的做法是任何节点变化都重绘整层,拖拽一个节点时周围 12000 个节点也要一起重新绘制,自然是浪费。我引入了区域脏矩形,鼠标按下到抬起这个时间段内记录的移动最小包围盒,重绘时只针对这个包围盒放大 20% 的矩形区域。Canvas 配合 save/restore 的 clipping 就能做到局部重绘。
第三刀是事件命中检测用格子索引。我把画布按 256 像素划分格子,节点登记到它覆盖的所有格子中,鼠标移动时只需要查鼠标坐标所在格子的候选节点,从全量扫描变成局部小范围扫描,从数万次比较降到个位数的比较:
class SpatialGrid { private grid = new Map<string, string[]>(); private cellSize: number; insert(nodeId: string, rect: Rect): void { const cells = this.getCoveredCells(rect); cells.forEach(cell => { if (!this.grid.has(cell)) this.grid.set(cell, []); this.grid.get(cell).push(nodeId); }); } query(rect: Rect): string[] { // 查覆盖格子内节点的交集 } }这套网格索引刚开始只服务于鼠标命中检测,后来发现区域框选和视口裁剪也都能利用它,复用率很高。
5.3 优化后的数据表现与基线参考
优化完成后,我重新跑了一组基线数据作为自己的回归依据,也推荐任何一个图编辑器项目都建立这样的基线测试,避免以后改出性能滑坡还没察觉。
优化前的性能表:
| 节点数 | 全量重绘帧耗时 | 命中检测耗时 | 整体交互帧率 |
|---|---|---|---|
| 1000 | 18ms | 0.6ms | 55fps |
| 5000 | 80ms | 3.2ms | 12fps |
| 12000 | 220ms | 8ms | 4fps |
加了视口裁剪和脏矩形之后:
| 节点数 | 视口重绘帧耗时 | 命中检测耗时 | 整体交互帧率 |
|---|---|---|---|
| 1000 | 12ms | 0.02ms | 60fps |
| 5000 | 18ms | 0.03ms | 60fps |
| 12000 | 22ms | 0.04ms | 60fps |
可以看到大文件场景从原来几乎不可用恢复到了流畅可编辑的水平。但另一边也暴露出新瓶颈,就是文字数量变大后 Canvas 的 fillText 开销撑起来了。后面我又做了一层文本离线渲染成位图再贴回画布的处理,这个技巧在节点很多、文字很短的情况下堪称救命优化。
6. 常见问题与排查经验速查表
6.1 高频问题清单与脚本化排查
开发图编辑器这类交互密集应用,问题往往藏在状态同步和坐标计算上面,我把自己清过的典型问题整理成一张速查表。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 节点拖到边缘后松手,结果位置跑偏 | 坐标变换中用了缩放后的偏差值,没有先除以 zoom | 所有屏幕坐标进画布坐标前统一做 screenToCanvas |
| 拖动节点时边上文字位置不同步 | 边文字坐标只计算过一次 | 在每次节点移动事件里重新计算边的文字位置并标记重绘 |
| 撤销一步后节点还在但连线断了 | 撤销逻辑只保存了节点快照,忘了边数据 | 指令快照保存节点和边的完整版本号 |
| 框选后按键删除,有的节点删不掉 | 选中集合里面有被 Group 授权的子节点,删除逻辑没处理 | 选中集合删除前先展开分组 |
| 导入 JSON 后节点坐标全在左上角 | JSON 没有保存 view 参数,默认 view 是 0 坐标 | 迁移逻辑默认设置 pan 和 zoom |
| 力导向布局迭代到 600 次后还在震荡 | 参数里阻尼值设得偏小,弹簧刚度偏大 | 增加阻尼系数到 0.6,降低弹簧刚度到 0.02 |
这类问题大多不复杂,难在复现条件很隐蔽。我自己的经验是给每个交互动作都写一个录屏回放 + 事件序列 dump 的调试入口,只要用户反馈一个 Bug,就让他一键导出事件序列,我可以按序列回放整个操作,直接定位到出问题的那一步。这个工具开发成本不高,但排查效率至少提升一个数量级。
6.2 印象最深的三个 Bug:定位过程复盘
第一个是“文本框缩放时文字消失”。现象是缩小画布到 0.5 倍以下时,部分节点的文字不见了。排查发现是因为渲染层为了性能在低缩放级别跳过了 drawText 调用,初衷是减少绘制量,但跳过的条件判断写成了“if (zoom < 0.5) return”,导致文本图层从绘制队列彻底移除。修复方案是改为判断视口尺寸内文本位图是否已生成,如果已生成就直接 drawImage,没有生成就走 fallback 画文字。
第二个是“撤销后边线变成岔路”。这问题极其隐蔽,用户操作是:拖拽 A 节点使得 B 节点和 C 节点之间的连线被重新路由,然后撤销。多次撤销后偶现连接线变成奇怪的折线。定位后才发现,连线路径在节点移动时被动态重新计算并缓存到了边数据字段里面,但撤销恢复的是旧的路径字段,而新路径计算逻辑认为路径字段已经是最新的,就不会重新算。本质是缓存一致性被破坏。修复方式是去掉边数据中的路径缓存,路径完全实时计算,换来一致性但增加了一点计算量。
第三个是“框选时特别大的节点选中区域错位”。大节点宽 2000 像素,按格子索引插入时正确,查询时用鼠标坐标直接查到了相邻格子,于是命不中,这其实是格子索引设计缺陷:框选时应该查询粉红线覆盖的所有格子,而不是只查鼠标落点所在的单格。修复后把 query 函数改成用矩形相交查询,框选大节点就能稳定命中了。
6.3 开发与维护图编辑器值得养成的几个习惯
一些经验放这里,对后来者应该有参考价值。
第一,任何坐标变换公式都要写成纯函数并做单测。我吃过没有坐标单测导致缩放中心移位的亏,后来把所有数学变换逻辑集中到一个模块里,主要函数全部补了单测,之后这类问题很少复发。
第二,图层重绘时机的控制要有统一入口。我原来各个模块想 flush 就 flush,导致同一帧内重复绘制了三四次。后面改成统一调度器模式,只有关键输入如鼠标移动才会触发下一次渲染,其他任何状态更新只标记重绘区域,一个 requestAnimationFrame 周期内最多执行一次绘制。
第三,图数据文件导出要带完整环境信息。画布尺寸、节点数量、schemaVersion、view 快照都放进 JSON,这样即便是未来换了实现,老数据也能轻松迁移。不要只保存节点数组,不然总有一天你会吃数据显示不完整的亏。
最后想分享一个我踩过几次坑之后学到的真香操作:给每个节点和边加一个 updatedAt 时间戳字段。最初觉得这是冗余信息,后来做增量保存、多人协同的冲突合并、以及异常数据的排查,都要靠这个时间戳来追责和比对,价值超出预期。反正字段不参与渲染,序列化成本可以忽略。对这种长期维护的项目来说,多留一点元数据永远比少留好。