diagram-design这个项目,第一次看到这个名字的人可能会觉得它就是个画图工具,但真正把它做下来之后我才意识到,这背后是一个典型的"图可视化编辑器"问题:理论上只需要拖拽节点、拉几条连线,实际落地却涉及渲染、交互、数据模型、状态管理、撤销重做、序列化这一整条链路。我花了两周时间从零搭了一个Web版的图设计器,支持节点拖拽、连线、框选、缩放平移、快捷键、撤销重做,还能把图导出成JSON再重新导入。这篇博文会把整个设计和实现过程完整复盘一遍,包括技术选型的理由、核心函数怎么写、坐标换算的坑、性能优化的思路,以及我在实际开发中踩过的几个典型问题。
1. 整体设计与核心思路拆解
1.1 这个项目到底在解决什么问题
做diagram-design的初衷,是当时手头需要一个"轻量但完整"的通用图编辑器。为什么用"通用"这个词?因为我们常见的业务图表编辑器,比如流程图工具、ER图工具、架构图工具,底层能力其实高度相似:都需要维护一批节点、节点之间有连线、画布支持缩放平移、用户能自由拖拽摆放。区别只是节点的外观和数据字段不同而已。
所以我在立项时就把范围定为:做一个不绑定具体业务的图编辑器基础框架。节点是什么形状、连线上显示什么文案,都通过配置和数据传入,编辑器本身只关心拓扑结构和交互行为。
用一句话概括:diagram-design不是一个画图软件,而是一个可嵌入的图编辑引擎。它把"维护一张图"这件事拆解成数据模型、渲染引擎、交互层、状态管理四个互相独立的模块,让使用者不需要关心Canvas坐标怎么算、pointer事件怎么处理,只要传数据和样式就能得到一个可编辑的画布。
1.2 核心需求拆解
第一版的功能清单,我列得很克制,只保留了最核心的闭环能力:
- 节点基础操作:拖拽移动、选中高亮、删除。
- 连线操作:从一个节点拖出连线到另一个节点,支持删除连线。
- 多选与框选:鼠标拖出矩形框选多个节点,支持整体移动。
- 画布能力:滚轮缩放、右键拖拽平移、缩放到适应全部内容。
- 编辑能力:快捷键删除、撤销重做、复制粘贴节点。
- 数据能力:导出JSON、导入JSON恢复。
为什么先做这些而不是急着做节点配置面板、自动布局、对齐吸附这些功能?因为编辑器的核心价值在于"图的交互闭环":用户能通过操作修改图的结构,结构变化能被数据层捕获,历史记录能被追溯。如果一开始就往编辑器里塞大量业务功能,很容易做成一个看起来功能很多、但底层交互逻辑经不起推敲的Demo。
1.3 设计目标:跑得稳,再加功能
我给自己定了三条设计原则,后面每个模块都在这三条下面做取舍:
第一,数据与渲染严格分离。所有操作都在数据层完成,渲染层只消费数据,不直接修改数据。这样撤销重做、序列化、协同这些能力都会好做很多。
第二,核心交互全部基于PointerEvent,不用drag事件、不用mousedown/mousemove混合监听。原因后面会细说,简单讲就是PointerEvent能统一鼠标和触屏,并且支持捕获事件,避免拖拽中途指针移出画布后状态丢失。
第三,坐标和渲染尺寸只用一套单位。画布内部全部使用世界坐标,节点位置、连线路径、框选矩形都基于世界坐标计算,只有最末尾的渲染阶段才做一次坐标变换。
2. 技术选型与架构设计
2.1 渲染层:为什么选了Canvas而不是SVG
渲染层到底用Canvas还是SVG还是DOM,是这类项目最先要回答的问题。我见过不少项目一开始用SVG或DOM做节点,节点少的时候很快,节点一多或者连线复杂之后性能就崩掉了。
SVG的优点是声明式、易调试、样式好控制,但它的致命问题是:每个元素都是独立DOM节点,几百个节点加连线之后,整个文档树的节点数量轻松破千,浏览器在布局和事件处理上的开销会指数上升。DOM方案类似,而且还得自己处理节点层叠、事件穿透、变换矩阵的问题。
Canvas的优点是绘制完全由自己控制,可以只重绘变化区域,节点数量上到几千的时候依然能维持流畅帧率。缺点是命中检测得自己做,文字换行得自己算,它不给任何高层能力。但对于图编辑器这种高频交互场景,确定性比便利性重要得多,所以我选了Canvas作为主渲染层。
注意,这里有个非常关键的心得:Canvas + 少量DOM浮层才是最优解。节点和连线用Canvas绘制没问题,但右键菜单、节点编辑输入框、Tooltip这类东西,硬用Canvas画反而别扭。我的做法是Canvas负责"静态的图",DOM浮层负责"临时性的交互UI",两边通过同一个坐标变换函数对齐位置。
2.2 数据模型设计:一切都围绕Node和Edge展开
图这种数据结构天然适合用两个集合描述:节点集合和边集合。我定义的数据模型如下:
type NodeType = string; interface GraphNode { id: string; type: NodeType; x: number; y: number; width: number; height: number; data: Record<string, unknown>; } interface GraphEdge { id: string; source: string; target: string; sourceAnchor?: string; targetAnchor?: string; label?: string; data: Record<string, unknown>; } interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; }节点id和连线id非常重要,我一开始用的是自增数字,后来改成UUID字符串。为什么?因为图数据需要被编辑、保存、再导入,自增数字在删除节点后可能导致重复,而且复制粘贴时还要重新映射所有引用关系。用UUID做id,复制粘贴时只需要生成新UUID并更新source/target引用,省掉一整类bug。
连接关系上用anchor字段表示连线的锚点位置,这在连线落在节点哪个方向的场景里很有用。比如一个节点有"top""bottom""left""right"四个锚点,连线记录sourceAnchor为"bottom"、targetAnchor为"top",之后计算渲染路径时就能精确算出起点和终点坐标。
2.3 状态管理:没有用Redux,选了Zustand
编辑器内部状态大概分三类:图数据(nodes和edges)、交互状态(当前选中的节点、是否正在拖拽、框选矩形)、画布状态(缩放比例和平移偏移量)。
图数据是核心业务状态,交互状态是瞬时UI状态,画布状态是视图状态。三类状态生命周期不同,如果全塞进一个全局Store里,容易导致无关组件反复重新渲染。我最后用了Zustand,因为它够轻、支持selector、拿起来就能用。
interface EditorState { nodes: GraphNode[]; edges: GraphEdge[]; selectedNodeIds: string[]; selectedEdgeIds: string[]; camera: { x: number; y: number; scale: number }; // actions addNode: (node: GraphNode) => void; updateNodePosition: (id: string, x: number, y: number) => void; addEdge: (edge: GraphEdge) => void; removeEdge: (id: string) => void; setSelectedNodes: (ids: string[]) => void; // ... }选型背后的逻辑是:不需要Redux那套繁琐的样板代码,也不需要XState这种重状态机。图编辑器的状态流是线性的——操作触发action,action修改数据,渲染层订阅数据变化后重绘,历史记录在action层做快照。这套模式用任何轻量状态库都能实现。
2.4 模块划分:Core和Renderer互不依赖
整个工程分成三个包:
- core包:纯数据逻辑,定义类型、CRUD操作、序列化、坐标工具函数。不依赖任何DOM API。
- renderer包:Canvas绘制、分层渲染、文字测量、节点绘制、连线贝塞尔曲线计算。
- editor包:把core和renderer粘起来,处理pointer事件、选择逻辑、快捷键、撤销重做。
Core层不碰DOM这一点,让我在做单元测试和未来可能的Node端批量处理时非常省事。比如"批量调整节点位置"这种功能,可以直接在core层写纯函数来测,不需要浏览器环境。
3. 核心功能实现与实操细节
3.1 节点系统:圆角矩形、文本截断和选中框
节点绘制是图编辑器里最基础的部分。我实现的节点外观是圆角矩形加标题文本,支持选中高亮边框。绘制一个节点的完整流程可以拆成四步:背景矩形、标题文本、左侧图标区(可选)、选中边框。
function drawNode(ctx: CanvasRenderingContext2D, node: GraphNode, selected: boolean) { const { x, y, width, height } = node; const radius = 8; ctx.save(); // 1. 阴影和背景 ctx.shadowColor = 'rgba(0, 0, 0, 0.08)'; ctx.shadowBlur = 12; roundRect(ctx, x, y, width, height, radius); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.shadowBlur = 0; // 2. 标题 ctx.fillStyle = '#333333'; ctx.font = '13px "PingFang SC", "Microsoft YaHei", sans-serif'; contextText(ctx, node.data.label || node.id, x + 12, y + 18, width - 24); // 3. 选中边框 if (selected) { ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2; roundRect(ctx, x, y, width, height, radius); ctx.stroke(); } ctx.restore(); }文字截断是容易被忽略但实际很影响观感的问题。节点宽度固定时,标题太长会溢出。最省事的方案是使用ctx.measureText计算文本宽度,超过阈值就截断加省略号:
function contextText(ctx, text: string, x: number, y: number, maxWidth: number) { const metrics = ctx.measureText(text); if (metrics.width <= maxWidth) { ctx.fillText(text, x, y); } else { let truncated = text; while (truncated.length > 1 && ctx.measureText(truncated + '…').width > maxWidth) { truncated = truncated.slice(0, -1); } ctx.fillText(truncated + '…', x, y); } }这里有个实际经验:循环截断字符串不能从左边一个一个删,要从右边删。同时要在循环开头就先给truncated加省略号去measureWidth,否则最后画出来的宽度会差几个像素。
3.2 连线系统:三次贝塞尔曲线和命中检测
图编辑器中的连线,难点不在"画线"本身,而在"如何让线看起来自然"以及"如何让用户方便选中"。直接拉一条直线会很死板,我选择用三次贝塞尔曲线,控制点的计算规则是:根据两个节点的相对位置,让曲线从节点边界垂直/水平地"出来",再转进去。
function getEdgePath(sourcePos: Point, sourceDir: string, targetPos: Point, targetDir: string): Path2D { const { x: sx, y: sy } = sourcePos; const { x: tx, y: ty } = targetPos; const offset = 40; const c1 = getControlPoint(sx, sy, sourceDir, offset); const c2 = getControlPoint(tx, ty, targetDir, offset); const path = new Path2D(); path.moveTo(sx, sy); path.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, tx, ty); return path; } function getControlPoint(x: number, y: number, dir: string, offset: number): Point { switch (dir) { case 'bottom': return { x, y: y + offset }; case 'top': return { x, y: y - offset }; case 'left': return { x: x - offset, y }; default: return { x: x + offset, y }; } }关键点是:起点和终点不是节点的左上角坐标,而是节点边界和锚点方向的交点。比如sourceAnchor为"bottom",起点应该是x + width/2、y + height。否则曲线会从节点内部穿出去,视觉上很难看。
连线选中是个经典难点。Canvas的isPointInPath可以直接用鼠标点判断是否落在贝塞尔曲线上,但实际使用体验很差:曲线路径太窄,鼠标很难精确点中。我的做法是对路径做采样,把曲线转成几十个点组成的折线段,再算鼠标点到折线段的最短距离,距离小于8像素就算命中。
function distanceToEdge(mouse: Point, path: Path2D): { edgeId: string, distance: number } | null { // 将path采样为50个点,对每对相邻点求点到线段的距离 // 返回最小距离和对应的edgeId }3.3 画布交互:缩放、平移和坐标换算
这是canvas类编辑器最容易出bug的地方。我的画布状态很简单:camera对象包含tx、ty、scale三个值。渲染时统一做一次矩阵变换,世界坐标和屏幕坐标之间的换算公式如下:
interface Camera { tx: number; ty: number; scale: number; } function worldToScreen(world: Point, camera: Camera): Point { return { x: world.x * camera.scale + camera.tx, y: world.y * camera.scale + camera.ty }; } function screenToWorld(screen: Point, camera: Camera): Point { return { x: (screen.x - camera.tx) / camera.scale, y: (screen.y - camera.ty) / camera.scale }; }所有交互事件处理的第一件事都是把浏览器给的clientX/clientY转换成画布坐标(相对canvas元素左上角),再通过screenToWorld换算成世界坐标。之后所有逻辑都基于世界坐标计算。这条铁律能避免大量因为缩放导致的"鼠标拖拽时节点飞行"问题。
还有一个容易忽略的细节:浏览器提供getBoundingClientRect拿到的尺寸和canvas.width可能不同,涉及设备像素比devicePixelRatio。我在初始化canvas时统一做了一次处理:
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);否则在高分屏上画出来的字体会发虚,看着就很难受。
3.4 拖拽交互:PointerEvent的完整事件流
拖拽是最容易写崩的交互。很多人会直接监听dragstart/dragover,但这两个事件在Canvas里完全不适用,它主要针对DOM元素拖放。正确做法是PointerEvent加setPointerCapture:
- pointerdown:记录起点世界坐标、记录被拖节点初始位置、标记dragging状态。
- pointermove:计算当前世界坐标与起点差值,更新节点位置。
- pointerup:结束dragging,拍一次历史快照。
setPointerCapture是个关键API。它保证在pointerdown之后,即使鼠标移出canvas区域,pointermove和pointerup事件依然持续派发给当前canvas,不会因为鼠标划出画布导致拖拽中断。没有这个API,你会遇到"拖快了鼠标一飞出画布节点就定住"的经典问题。
canvas.addEventListener('pointerdown', (e: PointerEvent) => { const screenPos = { x: e.clientX, y: e.clientY }; const worldPos = screenToWorld(screenPos, cameraRef.current); // ...命中检测 canvas.setPointerCapture(e.pointerId); dragStateRef.current = { pointerId: e.pointerId, startWorld: worldPos, ... }; });拖拽连线也有一个细节:从一个节点的输出锚点拉线到另一个节点,在连线拖拽过程中要显示一条跟随鼠标的临时曲线。这个临时曲线不修改数据,只存在renderer的临时状态里。等pointerup时再做一次命中检测,如果落到了可接受节点上,才真正创建GraphEdge。这个"预览和提交分离"的思路贯穿所有交互。
3.5 撤销重做:快照栈和批量操作
撤销重做是编辑器类项目绕不开的机制。我第一版用的是最简单的快照方式:每次操作前把整个GraphData深拷贝一份推到undo栈里。这种方式实现简单、绝对可靠,节点几千以下时深拷贝的速度完全可接受。
function beforeMutation() { historyStack.push(JSON.parse(JSON.stringify({ nodes: get().nodes, edges: get().edges }))); if (historyStack.length > 100) historyStack.shift(); } function undo() { const snapshot = historyStack.pop(); if (!snapshot) return; redoStack.push(JSON.stringify({ nodes: get().nodes, edges: get().edges })); set(snapshot); }快照里的一个坑是:深拷贝使用JSON.stringify/parse方案时,data字段里的非JSON类型(比如函数、Date、Map)全都会被丢。因为我的业务数据限定为可序列化的JSON,所以这个方案没问题。如果业务需要存复杂对象,就得换成structuredClone或者递归深拷贝工具。
另一个必须注意的细节:框选移动、粘贴多个节点这类操作必须作为"一次历史记录",中途不能拆成多次快照。我的做法是:交互开始时调用beforeMutation记录一次快照,操作全部完成后再一次性提交,期间所有中间状态都不再记录。否则用户撤销一次只回退半个框选范围,体验会非常崩溃。
4. 实操过程:从零搭建一个最小图编辑器
4.1 工程搭建和依赖选择
我使用Vite + TypeScript搭建,原因是开发环境启动快、类型检查对重构的支撑很到位。状态管理用Zustand,没有引入其他重型依赖。路径解析、拖拽、连线、贝塞尔曲线这些全部手写,不依赖第三方图形库。
目录结构大致是这样:
src/ core/ types.ts graph.ts geometry.ts serialize.ts renderer/ canvas.ts draw-node.ts draw-edge.ts camera.ts editor/ event-controller.ts selection.ts keyboard.ts history.ts4.2 初始化画布和事件控制器
先初始化canvas,设置好DPR和事件监听。一个值得推荐的做法是把所有事件处理函数收敛到一个EventController类里,统一注册和销毁,避免散落一堆匿名监听函数导致后续无法解绑。
class EventController { constructor(private canvas: HTMLCanvasElement, private store: EditorStore) { this.canvas.addEventListener('pointerdown', this.handlePointerDown); this.canvas.addEventListener('pointermove', this.handlePointerMove); this.canvas.addEventListener('pointerup', this.handlePointerUp); this.canvas.addEventListener('pointerleave', this.handlePointerLeave); this.canvas.addEventListener('wheel', this.handleWheel, { passive: false }); window.addEventListener('keydown', this.handleKeyDown); } destroy() { this.canvas.removeEventListener('pointerdown', this.handlePointerDown); // ... } }4.3 实现节点拖拽
拖拽流程的核心代码如下,注意我在pointerdown增加了"点击与拖动的区分阈值":
const DRAG_THRESHOLD = 3; handlePointerDown = (e: PointerEvent) => { const worldPos = screenToWorld(clientToCanvas(e.clientX, e.clientY), camera); const hitNode = hitTestNode(worldPos, nodes); if (hitNode) { this.canvas.setPointerCapture(e.pointerId); this.dragState = { type: 'node', pointerId: e.pointerId, startWorld: worldPos, nodeId: hitNode.id, moved: false, }; } }; handlePointerMove = (e: PointerEvent) => { if (!this.dragState) return; const worldPos = screenToWorld(clientToCanvas(e.clientX, e.clientY), camera); if (!this.dragState.moved) { const dx = worldPos.x - this.dragState.startWorld.x; const dy = worldPos.y - this.dragState.startWorld.y; if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return; this.dragState.moved = true; beforeMutation(); } const dx = worldPos.x - this.dragState.startWorld.x; const dy = worldPos.y - this.dragState.startWorld.y; // 这里实际要根据是否有多个选中项来批量移动 updateSelectedPositions(dx, dy); };4.4 实现连线和删除连线的命中
连线命中检测在之前提过,用采样折线段算最近距离。实现时我写了一个独立的纯函数:
function closestPointOnSegment(p: Point, a: Point, b: Point): Point { const abx = b.x - a.x; const aby = b.y - a.y; const lenSq = abx * abx + aby * aby; if (lenSq === 0) return a; const t = Math.max(0, Math.min(1, ((p.x - a.x) * abx + (p.y - a.y) * aby) / lenSq)); return { x: a.x + t * abx, y: a.y + t * aby }; }选中后删除、双击删除、键盘Delete删除都映射到同一个removeEdgeById方法,这样可以确保历史记录只记录一次操作。
4.5 导入导出:JSON格式的完整闭环
导出格式必须包含version字段,这是序列化里最容易忽略但最重要的内容。没有version,将来模型变化之后老数据直接无法解析。
interface SerializedDiagram { version: string; nodes: GraphNode[]; edges: GraphEdge[]; camera?: Camera; } function exportDiagram(state): string { return JSON.stringify({ version: SCHEMA_VERSION, nodes: state.nodes, edges: state.edges, camera: state.camera, }, null, 2); } function importDiagram(json: string): GraphData { const parsed = JSON.parse(json); if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) { throw new Error('无效的图数据'); } // 按version做字段兼容处理 return parsed; }XML、SVG、PNG导出我没有在第一版做。实际原因是核心在于底层交互不是格式转换,格式转换是纯函数式的翻译工作,后面单独拉一个插件模块做就好。
5. 常见问题与排查技巧实录
5.1 性能问题:节点数上千后掉帧怎么办
canvas图编辑器最常见的性能瓶颈不是绘制本身,而是每次重绘时做了太多无关工作。
我第一版每次交互都全画布重绘,节点300个时会卡。优化思路:切成下层、上层两个canvas。下层Canvas只画节点和连线的静态内容,交互时不动。上层Canvas只画选中的边框、临时连线、框选矩形。拖拽时只有上层在动态重绘,下层不动,重绘开销立刻下来一大截。
另外一个实战技巧:节点多的时候,缩略绘制。scale小于0.5时跳过文字渲染,只画矩形轮廓和颜色块,视觉感知差异不大,但绘制耗时显著下降。
5.2 坐标换算的坑:为什么节点总在鼠标旁边飘
如果你遇到"拖拽时节点一直偏离鼠标",排查方向就一个:是否在某个环节漏了除以scale。这里最容易出问题的地方是缩放状态下,移动距离计算为screenDelta直接赋值给node.x。正确做法是始终把screen坐标转world坐标,再做差值。
// 错误写法:直接用屏幕坐标差更新世界坐标 updateNodePosition(id, node.x + e.movementX, node.y + e.movementY); // 正确写法:起点和当前坐标都转成world再求差 const startWorld = screenToWorld(startScreen, camera); const curWorld = screenToWorld(curScreen, camera); updateNodePosition(id, node.x + (curWorld.x - startWorld.x), node.y + (curWorld.y - startWorld.y));5.3 滚轮缩放:锚点跟随鼠标
很多canvas编辑器的缩放体验差,问题在于缩放时鼠标指向的内容会偏移出视线。正确做法是让鼠标所在位置在世界坐标上保持不动:先记录鼠标的世界坐标,再改变scale,最后调整平移值使该世界坐标映射回原屏幕位置。
function zoomAt(mouseScreenPos: Point, newScale: number) { const worldPos = screenToWorld(mouseScreenPos, camera); setCamera({ ...camera, scale: newScale, tx: mouseScreenPos.x - worldPos.x * newScale, ty: mouseScreenPos.y - worldPos.y * newScale, }); }这个公式值得背下来。它不仅在滚轮缩放里用,任何"以某个点为中心缩放"的场景都适用:图片查看器、地图、字体放大预览都是同一个原理。
5.4 连线绘制的边界问题
当两个节点非常接近时,贝塞尔曲线的控制点偏移距离需要做降级处理。如果源节点和目标节点的实际距离小于控制点偏移量offset,曲线会出现不正常的大回环。
一个简单修复是控制点取min(offset, dist / 2),同时根据方向flag区分正负。还有一个隐藏问题:startAnchor和endAnchor方向相同且两个节点重叠时,曲线会变成一条直线甚至一个点,这时候画空心箭头会出现NaN方向的报错,需要单独处理距离为0的情况。
5.5 框选时不小心拖了节点
这个交互歧义我在实际测试时才注意到:在空白处按下拖动是框选,在节点上按下拖动是移动节点,这都好分。难的是按下点同时命中一个节点和空白区域,比如节点边缘非常靠近另一个节点的阴影区域。解法是命中检测时做优先级排序:先检测选中态的节点,再检测未选中节点,最后检测空白框选。且只允许在没有命中任何节点时启动框选。
另外,框选矩形应该同时包含完整包含和相交包含两种策略,并用快捷键切换。我用的是"完全包含"模式:节点矩形完全落在框选矩形内才被选中。这样对大图管理更可控。
5.6 撤销重做之后状态不一致
这是一个很难排查但经常出现的问题:撤销后节点的位置变了,但Selection状态还残留着旧选中的节点id,Canvas上层还在绘制那个不存在的节点选中框。经验是:历史快照只记录nodes和edges,selection和camera属于瞬态,不应该进历史。每次undo/redo之后统一做一次selection有效性校验,把对应id已经不存在的节点id从selection里删除。这样才能保证渲染层不吃到脏数据。
6. 操作优化与后续扩展思路
6.1 快捷键体系:用一份schema管理
快捷键数量少时无所谓,多了之后散落在各处判断会非常乱。我把快捷键定义成一张配置表:
const SHORTCUTS: ShortcutSchema[] = [ { key: 'Backspace', cmd: false, action: 'delete' }, { key: 'z', cmd: true, action: 'undo' }, { key: 'z', cmd: true, shift: true, action: 'redo' }, { key: 'v', cmd: true, action: 'paste' }, // ... ];这套策略的核心价值:判断逻辑集中在一处,支持后期让用户自定义快捷键,也为将来移动端适配时屏蔽冲突快捷键提供了统一入口。特别注意:在Mac上cmd键对应e.metaKey,在Windows上对应e.ctrlKey,判断时最好两个都测,不要只检查ctrlKey。
6.2 自动对齐辅助线
这是图编辑器体验提升最大的一个功能,但实现复杂度也确实高。核心思路:在移动节点时,检测当前节点和画布内其他节点的水平中线、左边界、右边界、垂直中线、上边界、下边界,如果距离小于某个阈值(比如5px),就强行吸附到对齐位置,此外还要顺着对齐位置绘制一条辅助虚线。
实际做的时候有个经验:阈值判断应该基于屏幕像素而非世界坐标,否则在浅缩放或深缩放时,吸附要么太灵敏要么太迟钝。正确做法是先把世界坐标转换成屏幕坐标,用屏幕坐标计算差值,再转回世界坐标修正节点位置。
6.3 多层级和分组
图上这个版本没有做组,但架构上预留了Parent字段,也就是节点可以挂其他节点id作为父级。组件的渲染、移动、删除都是递归语义。分组能做的核心是一个"约束传播":父节点移动时,子节点相对父节点的偏移保持不变;父节点删除时,子节点提升为顶级节点。递归移动的代码量不大,但边界条件不少,测试建议从三层嵌套用例开始。
6.4 序列化和版本的向后兼容
图编辑器一旦被接入业务,保存数据的格式就变成了需要长期维护的契约。我的建议是任何字段去掉或改名都必须走版本迁移函数:
function migrateDiagram(raw: any): GraphData { let data = raw; if (raw.version === '1.0.0') { data = migrateV1toV2(raw); } if (raw.version === '1.1.0') { data = migrateV11toV12(raw); } return data; }每改一次数据结构就写一个迁移函数,旧数据通过链式迁移稳定升级到最新版本。这是成本最低、最不会出事的策略,比"直接改字段然后不管老数据"要靠谱得多。
结束前的几句心里话
diagram-design做下来的最大体会是:图编辑器没有想象中难,但也没有任何一个环节能"随便糊过去"。它不像普通业务页面那样,写完就稳了,而是核心交互和数据模型一步错、全盘错。坐标换算、事件捕获、贝塞尔控制点、历史回退,每一个内容看起来都有现成方案,但实际上只有在真正动手实现时,才会理解为什么需要这些边界处理。
如果让我给同样打算做这类项目的开发者一个建议,我会说:第一版别贪多,先把"节点拖拽、连线、撤销、导入导出"这四件事打磨到极致,其他功能在这个底盘上都会长出来。另外一定把核心数据模型设计得足够干净,节点id用字符串uuid、坐标始终用世界坐标、数据路径和渲染路径严格分开。这三条纪律守住,后面加功能会非常顺利。
最后再分享一个小技巧:调试canvas交互时,临时在canvas上叠加一层半透明网格或十字光标,会非常有帮助。你可以直接看到屏幕坐标、世界坐标、缩放比例之间的关系,排查"为什么鼠标点和操作点对不上"这类问题,效率提升不止一倍。