1. 项目概述:为什么需要一套图解设计器
工作里经常有一种尴尬的场景:某次评审会上,后端工程师在白板上画调用链,画到一半发现右下角少了两个模块,擦掉重画;产品经理在另一张纸上画用户流转图,每个箭头都歪歪扭扭,旁边还得配一段文字解释“虚线代表可选流程”。等会议结束,这些白板照片躺在群里,没人再打开第二回。我就是在这种反复出现的协作摩擦中意识到,团队缺的不是画图的人,而是一套能随时打开、能复用、能对接业务数据的图解设计器。
diagram-design 这个项目,就是为解决这类问题而起。它不是一个“在线画图工具”那么简单的产品,而是一套可以嵌入到现有系统里的图解编辑与渲染方案。简单说,它做三件事:把节点和连线抽象成数据模型,把画布交互封装成可操作的编辑器,把设计结果输出成 JSON、图片或结构化数据。这样,不同的系统都能基于同一套图数据去渲染、编辑和分析。
这个项目适合谁?如果你是做前端可视化、搭建内部低代码平台、做流程设计器、拓扑图编辑器、或任何需要“图”来承载业务逻辑的技术同学,这篇内容会很有参考价值。我会把我在实际搭建过程中踩过的坑、做过的权衡、最终沉淀下来的实现方式拆开讲,尽量把一个图解设计器的核心环节讲透。
我在动手写代码之前,先想清楚了一个问题:什么才是图解设计器的“灵魂”?不是漂亮的连线样式,不是拖拽动画,而是“图数据模型”。节点是什么结构,连线怎么表达,端口挂在哪个位置,布局信息存不存、怎么存,这些决定了后续所有功能的上限。数据模型一旦定死,后面的渲染、交互、导入导出都会跟着将就,所以这个基础值得花大力气设计。
2. 整体设计与技术选型:为什么是 Canvas 而不是 SVG
2.1 渲染方案的两次对比实验
选型这件事,我前后做过两轮对比实验。第一轮我用 SVG + React 写了一版,发现节点数量在 200 个以内时体验很好,拖拽流畅,样式用 CSS 控制非常直观,事件绑定也简单。但当我测试 1000 个节点、2000 条连线的拓扑图时,DOM 节点数量激增,浏览器开始明显卡顿,每次拖拽一个节点,React 的 diff 过程要处理大量 SVG 元素,性能瓶颈非常明显。
第二轮我换成 Canvas 渲染,把节点和连线全部绘制在一个画布上。初次实现时手感完全不同,没有 DOM 事件可用,所有点击、拖拽、悬停都要自己做命中检测。但这套方案的高性能上限很乐观,一屏绘制几千个节点没有压力。最终我选择 Canvas 作为渲染核心,应用层保存完整的图数据模型,把 Canvas 当成一个“无状态绘制器”,每次数据变化后全量重绘。这里的关键并不是 Canvas 比 SVG 绝对好,而是要认清你的场景:节点数量、交互复杂度、是否需要 DOM 级联样式。如果只是画几十个节点的架构图,SVG 完全够用,而且开发效率高得多;如果要承载大图、频繁拖拽、缩放平移流畅,Canvas 是更稳的方向。
2.2 分层架构的三个核心模块
整个设计器我拆成了三层:数据层、编辑器内核层、渲染层。数据层负责图的模型定义和序列化,也就是 Node、Edge、Port 这些基础数据结构,以及把它们整体导出成 JSON 的方法;编辑器内核层管理选择态、悬停态、拖拽动作、撤销重做栈,这一层不关心绘制细节,只做状态流转和指令分发;渲染层拿到编辑器的状态快照,在 Canvas 上把所有元素画出来。
这样的拆分在初期看起来繁琐,但对后续扩展非常有用。比如我想新增一种“分组”能力,只需要在数据层加一个 Group 类型,在渲染层实现分组的绘制逻辑,编辑器内核不用大动。如果某天我想换一个渲染方案,比如从 Canvas 换成 WebGL,只需要重写渲染层,数据模型和内核状态完全不用改。
我再强调一点:不要把业务逻辑塞进渲染层。我见过不少设计器项目,绘制函数里直接写死业务字段的取值逻辑,比如“某个节点颜色根据 status 字段是否为 error 来决定”,这样短期内省事,但一旦业务规则变化,你得去翻绘制代码。正确做法是数据层提供 getStyle(node) 之类的规范化接口,把所有样式映射集中管理,渲染层只负责把样式画出来。
2.3 核心数据结构的设计取舍
节点数据我设计成这样的核心结构:
interface GraphNode { id: string; type: string; x: number; y: number; width: number; height: number; ports: Port[]; data: Record<string, unknown>; style?: Partial<NodeStyle>; } interface Port { id: string; type: 'input' | 'output' | 'both'; position: { x: number; y: number }; // 相对节点左上角的偏移 } interface Edge { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; points: Point[]; // 折线拐点 data: Record<string, unknown>; }这里我把连线的连接锚点定义为“节点 + 端口”,而不是直接存“两个节点之间的坐标”。原因很直接:当节点移动时,连线端点要跟着端口走,如果你只存了两条连线的端点坐标,移动节点后还得手动计算更新所有连线。有了端口模型,每次重绘时从端口位置推导连线端点,代码逻辑非常干净。另外,points 数组存的折线拐点是为了支持自定义路由,比如正交折线、平滑曲线,没有这个数组,线型扩展会很受限。
图模型整体我会包装成一个 Graph 类,提供 addNode、addEdge、removeNode、getConnectedEdges 这类基础方法。removeNode 的时候自动把关联的连线也删掉,这属于图模型的基本一致性保障,很多初版设计器会忽略,导致删除节点后画布上残留“悬空线”。
3. 核心细节解析与实操要点
3.1 画布坐标系统的处理
图解设计器最容易栽跟头的就是坐标系统。浏览器里 Canvas 的默认坐标是“左上角原点,向右向下为正”,这本身没问题,但一旦引入缩放和平移,坐标转换就复杂起来。我维护了两个核心变量:scale(缩放比例)和 viewport(视口左上角对应的世界坐标)。
屏幕坐标与业务坐标(世界坐标)之间的转换是高频操作,包括鼠标位置换算、拖拽节点位移、画布平移距离。公式基础但容易写错:
function screenToWorld(sx: number, sy: number) { return { x: (sx - viewport.x) / scale, y: (sy - viewport.y) / scale }; } function worldToScreen(wx: number, wy: number) { return { x: wx * scale + viewport.x, y: wy * scale + viewport.y }; }注意这里的 viewport 存的也是世界坐标,不是屏幕坐标。很多实现会把 viewport 误存成“像素偏移量”,导致放大缩小后平移速度不一致。我踩过这个坑,调试了半天发现缩放后拖动画布总是“发飘”,根源就是视口坐标混用了两个坐标系。经验是:所有持续性状态全部存世界坐标,只有事件处理临界点才做一次转换,用完即弃。
缩放的控制中心也很讲究。常见的缩放方式是“以鼠标位置为锚点”,也就是说,你鼠标指着哪个点,缩放后那个点在屏幕上的位置保持不变。实现上需要两步:先算出鼠标对应的世界坐标点,再调整视口让这个点在缩放后映射回同一屏幕位置。很多图表库没有做这个细节,缩放体验就会很生硬。
3.2 钩子的设计思路
节点之间的连线是图解设计器里交互复杂度最高的部分。我的实现思路是这样:从端口拖拽出一个临时的橡皮筋线,这条线一端固定在源端口,另一端跟随鼠标移动。鼠标松开时,检测目标位置是否有可连接的端口,有的话生成一条正式连线,没有的话丢弃橡皮筋线。
端口命中检测要区分“输入还是输出”方向,不能让 output 端口连到另一个 output 端口,这需要在检测里加过滤条件。有些图允许同向连接,但业务上大部分场景不允许,所以我会把“连线合法性校验”设计成开放接口,由外部传入校验规则,设计器内核默认只允许 input <-> output 互连。
折线路由的算法,第一版我用的是“曼哈顿路由”,也就是从起点出发先沿水平方向走一段,再垂直方向到达终点,中间经过拐点。这个算法在大部分布局规整的图中表现不错,但如果源端口和目标端口的相对位置比较刁钻,画出来的线会穿过节点本身。后来我调整了策略:如果源端口和目标端口水平方向无重叠且间距足够,优先走“先水平后垂直”的路径;否则先垂直后水平。这种规则式的处理虽然不如寻路算法那样万能,但在绝大多数业务图中效果已经足够,而且性能远好过网格寻路。
3.3 拖拽、框选与多选状态
拖拽是最基础的交互,但要做到“不丢状态、不卡顿”,有几个细节。
拖拽节点时要保持鼠标与节点抓取点的相对位置不变。具体做法是拖拽开始时记录鼠标位置与节点原点的偏移量,移动时用当前鼠标坐标减去这个偏移量,得到节点的新位置。这个偏移量是“世界坐标下的偏移”,如果你误用屏幕坐标,在缩放状态下拖拽节点就会和鼠标错位。
框选我采用的是“橡皮筋选区”,鼠标拖出一个矩形,实时判断哪些节点被完全包含或部分包含。批量选择时要注意一个矛盾:框选要的是“矩形范围内所有节点”,但点击选择要的是“精确命中某一个节点”。所以要区分鼠标拖动距离,如果按下到移动超过 5 像素才算框选,否则视为点击。
多选之后,拖拽其中一个节点,其他节点要同步平移,同时所有连线的端点随着端口位置自动更新。这一步听起来简单,但在实现里要避免每个节点单独更新导致的中间态闪烁,正确做法是一次性更新整批节点的位置,然后触发一次重绘。
3.4 TypeScript 类型体系与状态管理
图解设计器的状态管理,我没有引入重型的状态管理库,而是用了一个简单的“订阅-发布”模式外加双向链表做撤销重做。编辑器维护一个 state 对象,包含当前选中的节点 ID 集合、悬停的图元 ID、缩放比例、视口位置等。任何交互动作都会走一个叫做 applyChange 的方法,生成一个新 state,并推入 undo 栈。
这里的关键设计是命令模式:不是直接修改节点数组,而是通过 Command 对象来描述“做什么”。比如 MoveNodeCommand 记录 nodeIds 和 delta 坐标,执行时移动节点,撤销时反向移动。这样撤销重做的实现非常自然,而且命令可以被批量合并,比如拖拽过程中每帧产生一个移动指令,但撤销时希望一步回到拖拽前的位置。处理方式是把连续同一类型的指令合并成一个组合指令,记录起始和最终状态。
一个容易被忽略的点:图的缩放、平移这类“视图态”的变化要不要进 undo 栈?我的答案是不需要。撤销重做只作用于业务数据(节点、连线、分组),视图状态是临时的用户偏好。如果缩放也进撤销栈,用户会觉得很混乱,因为撤销画布位置不是直觉操作,甚至可能找不到刚才在看哪块。
4. 实操过程与核心环节实现
4.1 搭建编辑器骨架的完整步骤
这一节我把整个编辑器从零到可用的实操流程梳理一遍,读者可以完全照着搭,先跑通主链路,再逐步加固。
第一步,初始化画布容器。一个 Canvas 元素加上一个容器 div,Canvas 的宽高设置为容器的 100%,这里要做一次高清屏适配:将 Canvas 的实际像素尺寸乘以 devicePixelRatio,并在绘制时调用 ctx.scale(dpr, dpr),防止文字和线条在高分屏上发虚。
const canvas = document.getElementById('canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d')!; const dpr = window.devicePixelRatio || 1; function resize() { const rect = canvas.parentElement!.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }第二步,绑定基础事件。我监听的事件有 mousedown、mousemove、mouseup、wheel、dblclick、contextmenu。注意 mouseup 要绑定在 window 上而不是 canvas 上,因为用户可能拖拽到 canvas 外再松手,如果只在 canvas 上监听 mouseup,状态永远无法结束。wheel 事件需要 preventDefault 来阻止页面滚动,同时监听 passive: false。
第三步,实现主循环重绘。不是每帧都渲染,而是在有变化时标记 dirty 位,用 requestAnimationFrame 调度绘制。即使是全量重绘,也要注意把“背景网格”单独做一层缓存,因为网格在平移缩放时计算量大,却每帧重复绘制,性价比极低。我的做法是离屏 Canvas 绘制一张网格纹理,主绘制时先 drawImage,再叠加节点和连线。
第四步,写节点绘制函数。每个节点根据类型渲染成不同形状,业务上常用矩形、圆角矩形、圆形。绘制时先保存上下文状态,应用节点 transform(位置、缩放),绘制形状、填充色、边框、图标和标签文字,最后恢复上下文。保存和恢复上下文是 Canvas 绘制的铁律,不这样做,某个节点修改了阴影属性,下一个节点会带着阴影一起画出来,排查起来非常头疼。
第五步,写连线绘制函数。连线分三种类型,直线、折线、曲线。我默认使用折线,在 points 数组里存储拐点,绘制时用 beginPath 依次 lineTo。为了视觉清晰,连线我分两层绘制:先画一条宽度为 8 的透明描边作为“热区”,再画宽度为 2 的可见线条。这样鼠标点击命中时用热区检测,能容忍用户稍微点偏几个像素,体验会好很多。
第六步,对外暴露 API。编辑器内部逻辑全部封闭在一个类里,外部通过方法调用:addNode(type, x, y)、addEdge(source, target)、toJSON()、fromJSON()、setZoom(scale)。这里我特别强调,不要把内部状态对象直接返回给外部修改。比如 setZoom 应该是一个方法,而不是允许外部直接改 viewport 属性,否则会绕过撤销栈、绕过重绘调度,产生各种奇怪问题。
4.2 图数据导入导出的完整链路
导入导出能力是图解设计器接入业务系统的关键。我的设计是 toJSON 输出整图数据,结构如下:
{ "version": 1, "nodes": [ { "id": "n1", "type": "server", "x": 120, "y": 80, "width": 160, "height": 90, "ports": [ { "id": "p1", "type": "input", "position": { "x": 0, "y": 45 } } ], "data": { "name": "订单服务" } } ], "edges": [ { "id": "e1", "source": { "nodeId": "n1", "portId": "p2" }, "target": { "nodeId": "n2", "portId": "p1" }, "points": [], "data": {} } ] }这里我加了一个 version 字段,这是一个非常关键的细节。图数据结构只要上线,就一定会演进,比如未来增加分组、增加样式主题、增加业务标签。没有 version 字段,老数据的兼容解析就是一场灾难。fromJSON 按当前版本来解析,老版本通过 migration 函数升级,这是所有严肃的数据持久化场景都必须提前考虑的设计。
从 JSON 恢复图时,我建议不要直接赋值节点数组,而是逐条校验并调用内部的 addNode 和 addEdge 方法。这样可以复用 ID 分配、端口映射、去重逻辑,而不是绕过内核直接改状态。实际开发中我见过一次事故,就是因为直接赋值导致新加载的图没有重建连线的端点索引,拖动节点时线不跟着走。
图片导出我用的是 Canvas 原生能力,调用 canvas.toBlob 输出 PNG。这里要处理一个隐藏问题:如果画布内容大于视口显示区域,比如用户缩得很小,整图超出 Canvas 当前尺寸,直接 toBlob 只能截取当前视口范围。正确做法是创建一个离屏 Canvas,把尺寸设成整图边界大小,按当前视口做一次等价变换后绘制一遍,再导出。导出前还可以加一个白底填充步骤,因为 PNG 默认透明,放到白底文档里没问题,放到深色背景的 PPT 里可能看不清文字。
4.3 让画布操作手感接近专业工具的处理细节
画布操作手感的调优空间其实很大,专业工具的“专业感”往往就体现在几个细节。
缩放速度的控制。我使用 wheel 事件改变 scale,逻辑是 scale *= Math.pow(1.1, -deltaY / 100)。这里用指数函数而不是线性加减,是为了让缩放比例在视觉上均匀。直接加减缩放值会导致放大时跳变、缩小时迟钝。同时设置缩放上下限,比如 0.1 到 3,防止用户把节点缩成像素点或者撑出屏幕。
平移的惯性。第一版拖拽画布松手后立即停止,感觉非常“硬”。后来我做了速度跟踪:记录最近 100ms 内的位移向量,mouseup 时如果位移量超过阈值,就启动一个动画帧循环,让视口以衰减速度继续移动。这是一个小幅提升体验的功能,但很值得做,用户拖动大图松手后会有一种“顺势滑行”的顺畅感,实测下来对整个交互评级的提升很明显。
吸附对齐。当节点靠近其他节点的水平中线或垂直中线时,自动吸附修正位置。实现方式是在拖拽过程中检测当前节点的中心与附近节点的中心坐标差,如果差值小于阈值(比如 8px),就把位置调整到刚好对齐。吸附功能虽然小,但在用户手工排列节点时能节省大量微调时间。
右键菜单。我实现了一个轻量的上下文菜单,监听 contextmenu 事件,阻止默认菜单,显示自定义菜单项,比如“删除”“复制”“置顶”“创建分组”。菜单定位要用世界坐标,否则缩放后菜单会偏离鼠标位置。
4.4 复制粘贴与剪贴板通路实现
复制粘贴在图纸编辑器里是提效的重要功能,但实现起来比想象中复杂。难的不是复制节点数据,而是“粘贴时如何处理 ID 冲突”。
我的做法是:复制时把选中子图序列化成 JSON 存到内部变量(同时写一份到系统剪贴板,方便跨系统粘贴文本);粘贴时深拷贝数据,重新生成所有节点和连线的 ID,同时维护一个 oldId -> newId 的映射表,遍历连线时把 source 和 target 里的旧 ID 通过映射表替换成新 ID。
这个映射步骤很容易出错。如果复制了节点 A 和 B,A 有一条连线指向 B,粘贴时这条线也应该复制,且新线的起点指向新的 A、终点指向新的 B。如果不做 ID 映射,粘贴出来的线会连到原始节点上,整个图就乱了。还有一个细节是粘贴位置的偏移,通常在原图基础上向右下偏移 20px,防止覆盖原位。
5. 真实项目中的常见问题与排查记录
5.1 大图渲染掉帧与内存占用过高
当节点数超过 3000 时,全量重绘开始出现掉帧。排查后发现绝大多数的绘制时间花在不可见图元的绘制上。场景往往是用户把图缩放得很小,整个画布上密密麻麻几百个节点,Canvas 依然挨个画出来。
解决办法是视口剔除(culling):重绘前先计算当前视口对应的世界坐标范围,只绘制与这个范围有交集的节点。200 个节点可见时,即使全图有 5000 个节点,渲染成本也只在可见部分。这里还要注意连线的剔除策略:如果一条线的两个端点都不可见,通常可以跳过绘制;但为了防止线穿过视口边界,我保守地采用“只要有一个端点可见就画这条线”的规则。
内存占用高的问题,我排查下来往往出在离屏 Canvas 或者快照缓存上。网格缓存、每个节点的样式缓存如果无限叠加,内存会不断上涨。我的策略是所有缓存统一放在一个 Map 里,对 key 同时记录时间戳,每 5 分钟清理一次未命中的陈旧缓存。
5.2 连线命中检测误触与拐点拖拽的难题
连线不像节点有固定矩形区域,它的“热区”是一条很窄的线段。用热区透明度检测法最简单:重新绘制一遍所有连线,但用纯色、宽度加大、不用抗锯齿,然后读取 Canvas 上鼠标坐标处像素的 RGBA 值来判断是否命中。这个方法听起来粗暴,但实测非常高效可靠,配合 id 映射表就能反查到具体是哪条线被点中。
拐点拖拽是另一个细节。折线路径的拐点默认不显示,只有选中连线时,才会把全部拐点画成小方块,供用户拖动调整路径。拖拽拐点时要注意区分命中的是节点还是拐点:拐点的命中优先权应该高于连线热区,否则在拐点位置按下鼠标会被连线逻辑抢占。实现时我按固定的顺序做检测,先节点、再拐点、再连线、最后空白区域。
5.3 撤销重做在复杂操作下的边界情况
撤销重做在简单场景下没有太多坑,但一旦涉及“框选 + 拖拽 + 删除 + 粘贴”这类复合操作,就要格外小心。
一个典型问题是:拖拽节点持续触发移动命令,用户希望松手后只撤销一步,而不是每帧一个撤销记录。解决方法是拖拽开始时开启命令合并,把整个拖拽周期内的所有移动指令合并成一条 MoveNodeCommand。
另一个问题是删除一组节点后撤销,必须同时恢复节点和被摘除的连线。我在 Command 对象里存储了完整的执行前状态快照,撤销时直接还原整块数据,而不是只还原一部分。相比逐步逆向操作,快照方式更稳,也不会因为某个删除环节漏了导致状态不一致。
第三个问题是图数据的幂等性。撤销栈记录的命令应该保证:执行完 undo 再执行 redo,得到的状态必须与最初状态完全一致。数据里如果出现随机 ID 或者依赖外部时间的属性,就会破坏幂等性。所以生成 ID 时我用内部自增序列而不是时间戳拼接。
5.4 文字输入和缩放的兼容性处理
节点内标签双击要进入编辑态,这个时候要在 Canvas 上方覆盖一个透明的文本输入框,用绝对定位对准节点的文字区域。这里遇到的主要问题是字体大小换算:Canvas 里绘制文字用的是世界坐标单位(经过 scale 变换),而输入框用的是屏幕像素,所以输入框的 font-size 要设置为 fontSize * scale 的换算值。
另一个常见问题是文本溢出。标签字数多了以后直接超出节点边界,难看不自适应。我的处理方案是渲染前先度量文本宽度,如果超过节点可用宽度,自动截断并追加省略号;双击进入编辑态时则完整显示所有人文本。测量文本宽度用 ctx.measureText 方法,注意要在排版设置完成后测量,比如设置了 font 属性再测,否则结果不对。
缩放时文字要不要一直跟着放大缩小?专业工具通常会给“文字最小字号限制”,缩得太小文字会糊成一团。我的做法是渲染时计算当前 scale 下的文字像素大小,如果小于 12px,就强制按 12px 绘制,相当于文字不再缩放,但位置仍跟随节点。这个策略在两种缩放下都保证可读性。
5.5 快捷键体系与冲突处理
快捷键是效率工具的重要组成部分。删除用 Delete/Backspace、复制用 Cmd/Ctrl + C、撤销用 Cmd/Ctrl + Z,这些几乎成了用户肌肉记忆。但有一个细节:当用户正在输入框里编辑文本时,这些快捷键不应该触发画布操作,否则输入内容会莫名其妙地丢失或者画布上的节点被误删。
我的实现是在输入框聚焦时给编辑器设置一个“交互锁定”状态,快捷键处理器先检查锁定状态,锁定期间直接放行所有按键给输入框。另外要注意浏览器默认行为,比如 Backspace 在非输入框状态下可能触发浏览器后退,需要在快捷键拦截时禁止默认行为。Cmd/Ctrl + 滚轮的缩放快捷键也要小心,它可能和系统级的页面缩放快捷键撞车,我的处理是只拦截发生在画布容器内的 wheel 事件,不全局拦截。
6. 基于个人经验的几点心得
6.1 从零开始写与改造现有库的真实取舍
很多团队在启动图表设计器项目时,第一反应是调研开源的流程图库。我也做过这个环节的对比:成熟的库开箱即用,功能齐全,社区资料多,但“定制它以适应业务”的成本往往比想象中高得多。库的内部事件流和状态管理是黑盒,业务上常见的“特殊校验规则”“行内编辑样式”“自动化布局耦合”常常要 hack 到库的内部,升级版本会让你胆战心惊。
我的建议是,如果你要做的不是通用型画布工具,而是深度绑定的业务系统,自己搭一套“轻量内核 + 自定义渲染层”的架构更可控。这里的“轻量内核”并不是指简陋,而是核心模块清晰:图模型、选区管理、指令系统。这些代码量不大,精写一个月就能稳定下来,而它带来的自由度是后续业务延展的基石。
当然,如果需求只是画几张架构图给文档用,完全没有必要自研。工具选型要跟真实需求匹配,不要为了追求架构感而过度设计。
6.2 面向将来的架构演进:图层、自动布局与实时协作
我目前的设计器还留了一些扩展位,计划在后续版本逐步补全。
图层系统是第一个重要扩展。现有模型只有一层,所有节点堆在一个平面上,这在复杂图里很难管理。加图层之后,节点模型需要增加 layerId 字段,渲染层按图层顺序绘制,图层面板支持锁定和隐藏。这个改动设计器内核几乎不用动,渲染层加一个排序逻辑。
自动布局是第二个方向。目前靠手工摆放,但大型图往往需要按层级或拓扑关系自动排列。自动布局最难的不是算法本身,而是它和用户手动调整的交互次序。用户手动拖过一个节点之后,自动布局再跑一遍会把用户的精心调整全部打乱。合理做法是:自动布局操作显式触发,且记录在撤销栈里,用户可以一键撤销回手动状态。
实时协作这块,我目前的设计是在数据模型层预留了每次变更的 Command 序列,天然适合作为协同同步的最小单元。加上一个消息通道,把 Command 广播给其他端,接收端执行同样的 Command,就能实现基本的多端同步。这里要注意冲突合并策略:两个用户同时移动同一个节点的极端情况需要定一个策略,比如“最后写入者胜出”或者服务端统一做状态归并,业务上通常选前者。
6.3 最后想说的一个看似小事的细节
在文章的最后,我想分享一个做完整套设计器之后让我印象最深的体会:Canvas 图解设计器的核心门槛不在“绘制”,而在“状态”。绘制一个圆角矩形只要十行代码,但让它能被选中、可拖拽、能撤销、缩放不错位、连线跟着走、导出后布局不变,这背后每一环都需要你思考“当前的这个操作,应该落在哪一层、以什么形式进入状态流转”。
我自己踩过最大的坑是前期急于实现视觉效果,把节点位置直接硬编码在渲染逻辑里,导致后面加撤销重做、加自动布局时反复重构。后来我把所有状态变动收敛到 applyChange 通道里,所有历史记录挂到命令栈上,重构成本才真正降下来。这个经验送给大家:图解设计器,数据模型稳了,后面的一切都会稳;数据模型将就了,后面每一步都在还债。