diagram-design 这个项目,是我在给内部低代码平台补流程图功能时被逼出来的产物。市面上现成的绘图组件要么太大太重,要么定制一个交互细节要翻半天源码,索性自己动手实现了一个轻量级的图设计引擎。做完之后发现,这个领域看着简单,真正落地时全是细节:坐标怎么换算、锚点怎么命中、撤销重做怎么做才不卡、缩放平移怎么才能顺滑。
这篇就把我从需求拆解到核心交互实现的全过程摊开来讲。整个项目不算大,核心实现量大约在三千行左右,但涉及到了图可视化编辑器的几乎所有基础能力:节点模型、连线模型、坐标系变换、拖拽交互、多选框选、撤销重做。无论你是在做流程编辑器、架构图工具、脑图产品,还是单纯想在项目里嵌入一个可以拖拽的绘图面板,这篇内容都能直接拿来参考。我会把每一步的设计原因、代码结构和踩过的坑都写清楚,尽量做到你看完就能自己复现。
1. 这个工具到底要做什么:需求与边界
1.1 一眼看懂 diagram-design 是什么
diagram-design 本质上是一个面向 Web 的轻量级图设计引擎,核心能力是让用户在白板式的画布上自由创建节点、拖拽位置、用连线表达关系,并且支持缩放、平移、多选、撤销重做这些编辑器级的基础操作。它不追求像专业绘图软件那样面面俱到,而是把图表编辑里最常用、最核心的那条路径打磨顺。
我一开始给自己定的目标是:任何情况下,从鼠标按下到完成一个节点的创建或一条连线的建立,时间不能超过两秒,操作不能超过三步。这个目标听起来容易,实际上对交互设计要求很高。比如拖拽一个节点到另一个节点上自动吸附、连线时自动识别目标锚点、框选时多选命中范围这些体验细节,都直接影响这两秒能不能成立。
这个工具适合谁来参考?如果你正在做或者准备做任何形式的可视化编辑器,我的经验是不要一上来就接一个大型框架。先拆清楚自己的核心操作路径,用最朴素的方式把模型和交互打通,等确定模型稳定了再考虑迁移到成熟方案。diagram-design 的价值就在于把这条路径上的所有关键技术点都摊开讲清楚了。
1.2 功能清单与不做清单
每个项目开始之前,必须先划定边界。我给 diagram-design 定的功能范围是这样:
- 节点操作:创建、删除、拖拽、修改尺寸、层级调整(置顶置底)
- 连线操作:锚点连线、折线自动布局、跟随节点移动、删除
- 画布操作:缩放、平移、框选、多选、删除批量节点
- 状态操作:撤销、重做、保存与恢复(序列化)
- 辅助能力:对齐参考线、吸附网格、右键菜单预留
同时我明确列了一个“不做清单”:不做自动布局算法(那是图形算法领域的事)、不做复杂图元库、不做多人协同的冲突合并、不做移动端触屏适配。这些功能除非你的核心场景真的有刚需,否则只会无限拉长开发周期。
这个清单带来的直接好处是技术方案可以极简化。比如因为不需要多人协同,我就完全不用考虑 Operation Transform 这类复杂算法,状态管理只需要管好本地一份数据。不做自动布局,就省掉了力导向、层级布局这一整套图形计算。每一块被砍掉的功能,都是在给自己省时间,也是在降低出 bug 的维度。
1.3 技术选型:为什么选 SVG 而不是纯 Canvas
图编辑器领域有个经典争论:渲染层到底用 SVG 还是 Canvas。我的选择是主渲染用 SVG,部分辅助图层用 Canvas 或者干脆用绝对定位的普通 DOM。原因很简单:diagram-design 的节点数量级在中低规模(几百个以内),在这个范围内 SVG 的 DOM 事件模型天然适合交互,每个节点、每条连线都是一个独立的 DOM 元素,click、mousedown、mouseenter 这些事件不需要自己做坐标命中计算就能直接绑定。
Canvas 的优势在大规模渲染,但代价是所有的拾取(picking)逻辑都要自己实现。比如你要判断用户点到了哪条线上,就必须自己做线段距离计算;要判断点到了哪个节点,又得做矩形碰撞检测。这些不是不能做,而是对一个初始版本来说开发成本和调试成本都很高。
SVG 的缺点主要在性能上,节点到几千个之后操作会明显变慢。我的折衷方案是:静态场景下用 SVG 渲染,连续拖拽过程中用 CSS transform 直接操作 DOM 元素而不是改 SVG 属性。CSS transform 走的是合成器线程,不触发布局和绘制,实测在中等画布下拖拽流畅度足够。如果后面真遇到几千节点的场景,再考虑把渲染层替换成 Canvas,但数据模型和交互逻辑都不用动,这就是模型与视图分离设计带来的好处。
2. 最容易被低估的部分:图元模型与数据结构
2.1 节点模型设计
图编辑器的数据模型是整个项目的基石,这块如果设计松了,后面每一步交互实现都会痛苦。节点模型我最终是这样定义的:
export type NodeType = 'rect' | 'circle' | 'diamond' | 'text'; export interface DiagramNode { id: string; type: NodeType; x: number; y: number; width: number; height: number; zIndex: number; attrs: { label?: string; fill?: string; stroke?: string; strokeWidth?: number; fontSize?: number; }; // 每个节点的四个锚点,连线时使用 anchors?: Anchor[]; }关键设计决策有两点。第一,id 必须是稳定且全局唯一的,不能拿数组下标当 id。因为连线、撤销快照、序列化持久化都依赖 id 做引用,一旦用下标,数组顺序一变全线崩溃。第二,x、y 保存的是节点的左上角坐标,而不是中心点坐标。左上角坐标在框选、拖拽、对齐参考线时计算更直观,和图形 API 的默认行为也一致。
锚点(Anchor)这个设计很多人会忽略,但对连线体验影响巨大。每个节点我默认生成四个锚点:上、下、左、右,外加一个中心锚点。锚点的位置不是存死的,而是根据节点当前尺寸实时计算的。比如右侧锚点的坐标永远是(x + width, y + height / 2)。这样做的好处是节点一旦拖拽缩放,锚点自动跟随,不需要额外同步逻辑。
export interface Anchor { id: string; direction: 'top' | 'bottom' | 'left' | 'right' | 'center'; // 实时计算位置,不存静态坐标 }2.2 连线模型设计
连线比节点复杂得多,因为一条连线可能经过多个转折点,而且当它关联的节点移动时,连线必须跟着变。我设计的连线模型长这样:
export interface NodeRef { nodeId: string; anchorId: string; } export interface DiagramEdge { id: string; source: NodeRef; target: NodeRef; // 中间转折点列表,按顺序连接 points: Array<{ x: number; y: number }>; style?: { stroke?: string; strokeWidth?: number; arrow?: 'none' | 'target' | 'source'; }; }这里最重要的设计决策是:连线的起点和终点不直接存坐标,而是存“引用”(NodeRef)。这样节点移动后,连线可以重新计算端点的坐标;但也带来一个新问题,如果移动节点后直接改连线的 points,节点的坐标一变,折线就全部错位了。
我的做法是:连线数据里不存冗余的端点坐标,只保存中间转折点。渲染时根据 source 锚点和 target 锚点的实时坐标,加上 points 里的中间转折点,动态生成最终的路径。在执行上,节点移动时会触发一个rerouteEdge操作,重新计算所有关联连线的转折点。对于相对简单的场景,我先用直角折线(正交连线)算法:先判断两个锚点的相对方位,决定先走水平还是先走垂直,中间最多生成两到三个转折点。
2.3 画布坐标系:不要把所有坐标都存成屏幕坐标
这个坑几乎是所有绘图编辑器开发者的第一步必踩的坑。如果你在缩放或平移之后发现节点和鼠标位置对不上,十有八九是坐标系统搞混了。我用了三套坐标的概念来避免这个问题:
- 世界坐标(Model Coordinate):节点和连线统一存放在这个坐标系里,单位是逻辑像素,与缩放比例无关。
- 视口坐标(Viewport Coordinate):画布上实际显示出来的坐标,等于世界坐标乘以缩放比例再加上平移偏移量。
- 屏幕坐标(Screen Coordinate):浏览器页面的实际像素坐标,viewport 坐标再加上画布 DOM 元素在页面里的偏移量。
三者的换算关系是:
screenX = worldX * scale + panX + canvasOffsetX screenY = worldY * scale + panY + canvasOffsetY鼠标交互拿到的永远是 screen 坐标,你所有的数据操作都应该换算到 world 坐标再执行。我封装了一组工具函数,比如screenToWorld(clientX, clientY)和worldToScreen(worldX, worldY),并且强制规定在事件处理器里第一步就把屏幕坐标转成世界坐标,后面所有逻辑一律只操作世界坐标。这个约定帮我避免了至少几十个疑难 bug。
2.4 缩放与平移的实现策略
缩放和平移我用的是 Transform 模型的中心方法。store 里维护一个viewport对象:
interface Viewport { scale: number; panX: number; panY: number; }渲染 SVGRoot 节点时,用一个<g transform="translate(panX, panY) scale(scale)">包住所有的图元。所有世界坐标都画在这个g里面,缩放平移只改这一层 transform。这个方案的优缺点都很明显。优点是每次缩放平移只更新一个 transform,浏览器原生处理,速度快,而且所有内部元素的坐标完全不用改。缺点是缩放之后 SVG 里文字、线宽这些会跟着放大缩小,导致大图时线宽粗得离谱,缩小时字小得看不清。
我的处理方式是给连线的 strokeWidth 和节点的 fontSize 做反向缩放补偿:实际渲染值等于设计值除以当前 scale。这样视觉上字号和线宽恒定,体验和 canvas 手写类似。要注意这个补偿值只在 render 时计算,不能写回 model 数据,否则数据会被污染。
3. 核心交互的完整实现过程
3.1 拖拽创建节点
节点创建的交互路径有很多种:从左侧面板拖出一个图元、右键菜单添加、直接快捷键复制。我的实现优先支持从面板拖拽创建,因为这个交互对用户体验最友好,同时坑也最多。
流程拆开是四步。第一步,在左侧物料面板的每个图元上监听 mousedown,记录起点屏幕坐标,并把当前画布的 viewport 状态快照保存下来。第二步,mousemove 时创建一个临时浮动的节点元素,跟随鼠标移动,此时在 store 里只做一个 preview 节点,标记为isPreview = true。第三步,鼠标移动时实时检测当前鼠标位置是否落在画布区域内,如果落在画布外就显示一个禁用样式。第四步,mouseup 时,把屏幕坐标换算成世界坐标,设置新建节点的位置,去掉 preview 标记,正式提交进入 store。
这里有一个非常关键的细节:mousedown 发生在面板上时,mousemove 和 mouseup 事件必须绑定到 window 上,而不是绑定到面板或者画布上。原因是用户一旦按下鼠标,后续事件可能在页面任意位置触发。如果只绑定在画布上,鼠标快速移出画布后事件就丢了,会出现“鼠标松开但拖拽没结束”的灵异现象。正确写法是 mousedown 时 window.addEventListener('mousemove', handler) 和 window.addEventListener('mouseup', handler),在 mouseup 之后统一移除。这套逻辑在拖拽节点、框选、连线等所有拖拽型交互里都要用。
3.2 锚点连线的交互流程
连线交互是图编辑器里最容易写乱的部分。我的实现思路是:连接行为分成两个阶段,第一阶段从源锚点拖出一条临时连线,第二阶段在目标锚点上松手完成连接。
具体交互流程是这样。第一,监听每个锚点元素的 mousedown,记录 source 锚点引用,设置 editorState 为connecting。第二,mousemove 时,根据当前鼠标位置实时画一条临时线段或者曲线。如果当前没有悬浮在目标锚点上,临时线的终点就是鼠标位置;如果悬浮到了目标锚点上方,临时线终点吸附到目标锚点上,并给目标锚点加一个高亮样式。第三,mouseup 时,先取当前鼠标位置,遍历所有可见锚点做命中测试。命中测试不是简单的“鼠标坐标等于锚点坐标”,而是判断鼠标位置到锚点的距离是否在一个阈值范围内,我采用的阈值是 10 个屏幕像素。命中成功就创建正式连线,否则取消。
连线实时绘制阶段我额外做了防抖处理:mousemove 事件触发非常频繁,每个事件都直接改 store 会引发大量渲染。我用了一个 requestAnimationFrame 的节流函数,把最新的鼠标位置保存到一个临时变量里,每个动画帧只读取一次这个变量来更新临时连线路径。这样一来,即使 mousemove 每秒触发 60 次甚至 120 次,实际的渲染频率也被限制在屏幕刷新率以内,拖动明显更跟手。
3.3 框选多选
框选是多选操作的基础。用户在画布空白区域按下鼠标左键并拖动时,绘制一个蓝色的半透明矩形,鼠标松开时,所有与矩形相交的节点被选中。
矩形相交检测用 AABB(axis-aligned bounding box)方法,就是判断两个矩形是否有交集,代码很简单:
function rectIntersect(rect1, rect2) { return !( rect1.x + rect1.width < rect2.x || rect2.x + rect2.width < rect1.x || rect1.y + rect1.height < rect2.y || rect2.y + rect2.height < rect1.y ); }但要注意一个细节:框选区域是在屏幕坐标上绘制的,而节点位置是世界坐标。你不能直接拿屏幕坐标的矩形和世界坐标的节点做相交判断,必须先统一坐标系。我的做法是:框选开始时记录起点世界坐标,拖动过程中每次更新终点世界坐标,最终形成一个世界坐标下的选择矩形,再和所有节点的世界坐标做相交判断。这样框选在所有缩放级别下行为都一致。
选中之后就需要维护一个 selection 状态,里面保存一组选中的节点 id。所有后续操作,比如删除、整体拖拽、属性面板展示,都基于这个 selection。整体拖拽选中的多个节点时,我会在拖拽开始时记录每个节点的原始位置,然后把每次的鼠标位移增量统一加到所有选中节点上,这样保证多节点相对位置不变。这里又是一个容易翻车的地方:增量必须是世界坐标的增量,不能直接加屏幕坐标的增量,否则缩放状态下多选拖拽会和鼠标错位。
3.4 撤销重做(命令模式与快照)
撤销重做实现方案我对比过两种。一种是命令模式(Command Pattern),每个操作封装成一个可逆的类,有 execute 和 undo 两个方法。优点是内存占用少、语义清晰,缺点是每个操作类型都要单独实现反向逻辑,比如拖拽节点可以记录前后位置做反向,但复杂操作比如框选删除、批量移动、连线重路由,反向逻辑很容易漏,漏一个就是撤销后数据错乱,排查成本很高。
我最终采用了另一种更简单粗暴的方案:快照模式。每次发生“可撤销操作”之前,把整个图数据做一次序列化快照,压入撤销栈。撤销时从栈中取出一份快照整体恢复,重做时再从重做栈弹出历史快照。实现量很小,而且因为每次撤销都是整体恢复,不会存在“只恢复了一半”的情况。
快照模式的核心问题是内存和序列化性能。我的解法有三个。第一,所有节点和连线的数据都是纯 JSON 兼容对象,没有函数、没有循环引用,直接用JSON.stringify序列化。第二,撤销栈深度控制在 50 步以内,超过就丢弃最老的快照。第三,对大图场景做了一个简单优化:只在用户的连续操作停止 300ms 后才生成快照,而不是每次 mousedown 都压栈。拖拽一个节点从开始到松开,整个过程算一步操作,最终只保存一次快照。
这个时间窗口的实现方式也简单:每次操作开始时设置一个 timer,操作过程中 timer 不断重置,操作结束后 300ms 快照才会真正生成。代码看起来不多,但在实际上手体验中,这个细节决定了一个操作算一步还是算一百步。
4. 实测中踩过的坑:问题排查与优化实录
4.1 坐标换算为什么总是差一截
我在开发初期遇到一个非常诡异的现象:拖拽节点时,节点总是“慢半拍”或者“偏一段距离”。排查了很久,最后发现问题出在获取 canvas 偏移坐标时用了错误的接口。有人会下意识用getBoundingClientRect()返回的值直接做减法,但在 SVG 内部元素上有getScreenCTM()这个方法,它返回当前元素到屏幕坐标系的变换矩阵,在缩放平移场景下比手动计算可靠得多。
坐标换算的稳定写法是这样的:
function screenToWorld(clientX, clientY) { const svgRoot = diagramSvgRef.current; const pt = svgRoot.createSVGPoint(); pt.x = clientX; pt.y = clientY; const inverted = svgRoot.getScreenCTM().inverse(); const worldPt = pt.matrixTransform(inverted); return { x: worldPt.x, y: worldPt.y }; }这个方案有三个好处:缩放、平移、SVG 自身的偏移量全部由浏览器矩阵运算处理,不需要你手动去减 panX、减 canvas 偏移、再除 scale,逻辑上完全不重。而且即使未来 SVG 结构变了,只要根节点上的 viewport transform 不变,这套换算仍然成立。强烈建议所有坐标换算都走这个函数,不要在事件处理器里到处手写加减乘除。
4.2 锚点不好点中的问题
锚点连线的第一个版本上线后,用户普遍反馈“连不上线,太难聚焦了”。问题根源在于锚点的可视化尺寸只有 6 像素,鼠标稍微偏一点就点不中。我知道不能用“把锚点做更大”这种粗暴方式解决,因为太大锚点会遮挡节点内容。
最终的解决方案是“视觉尺寸和命中尺寸分离”。视觉上锚点仍然显示为 6 像素的小圆点,但命中区域扩大到 12 像素。做命中测试时,不是检测鼠标是否精确落在圆内,而是计算圆心的距离是否小于命中半径。更进一步,鼠标靠近目标锚点时给锚点增加一个放大的 hover 样式,等于做了一个隐式的交互反馈,告诉用户“你可以在这里松手连上”。这个体验优化让连线成功率几乎提升了百分之四十。
4.3 拖动时卡顿与事件丢失
拖动卡顿分两种。一种是连续的 mousemove 触发太频繁导致渲染压力过大,另一种是每次鼠标移动都在触发重渲染和重算所有连线。
第一个问题的解法我在前面说过,用 requestAnimationFrame 节流。但有一点要补充的是:这个节流不能作用在事件监听的注册上,mousemove 监听每一次都要执行,但只有最后一次位置被记录下来,真正触发渲染的是一个独立循环。也就是说,事件处理函数里只做赋值操作,不做任何计算和渲染。这个“读事件、写变量、帧渲染”的模式在交互密集场景下是核心原则。
第二个问题的解法是“脏标记机制”。store 里维护一个dirtyFlag,当拖拽节点、移动连线等操作发生时,先标记脏,再通知渲染层按需刷新。如果没有任何操作发生,mousemove 就是不产生任何计算的“空转”。另外还有一个细节:拖拽过程中不重算关联连线的完整路由,只先用一条临时的近似曲线把连接关系表示出来,拖拽结束时再统一重算路由。视觉上用户不会在意连线路径在松手前是精确的还是近似的,但性能上这个节省非常可观。
4.4 撤销重做吃内存、大图卡的问题
快照模式做撤销重做,图一旦大起来,内存飙升是个很现实的问题。几十个节点的时候无所谓,几百个节点,每次快照带上完整的路由信息、样式属性,一串 JSON 序列化出来轻松达到几兆字节。50 步历史就是几百兆,页面直接崩。
我做了三件事来解决。第一,深层冻结和浅拷贝分开:nodes 数组用新的数组对象,但里面的节点对象保证不可变(immutable),快照只是保存一个对旧引用集合的引用,而不是把所有数据复制一遍。这个对内存的节省是数量级的。第二,序列化时去掉非必要字段,比如实时计算的 points 不需要保存,路由可以在恢复时重新计算。第三,快照延迟生成和上限控制,前面说过的 300ms 等待和 50 步上限,这两个值要根据实际图规模调整。
如果你用量测试发现还是扛不住几百个节点的快照,可以考虑把撤销策略降级为只保存操作命令,这是比较后期的优化方向,但模型和交互层完全不用动。
4.5 一个容易忽略的小细节:连线跟随移动
画布上拖动节点,它的所有连线必须同步改变端点。在我第一版实现里,节点错位、连线弯折得非常难看,原因是我只更新了连线的 source 和 target 引用,但没有重新计算中间转折点。直角折线路径全部变成了僵硬的斜线。
修正方案是在拖拽结束时统一重算所有关联连线的路由。具体会遍历所有以这个节点为 source 或 target 的连线,调用一个routeEdge函数,根据最新的锚点坐标重新计算中间转折点,再更新到模型里。为了性能,拖拽过程中我只让连线端点实时跟随,转折点保持不变,拖拽结束才整体重路由。这个折衷策略让视觉连续性和性能都得到了保证。
5. 一些实操中不太会被写进文档的细节
整个项目做完再回头看,真正拉开体验差距的往往不是某个高深算法,而是一些平凡的细节策略。
第一个是“数据先行”的纪律。不管交互多复杂,所有状态都要围绕同一份 model 数据来组织,不要为了快速实现某个拖拽效果而临时造一个平行数据源。我见过很多人写拖拽,直接在 DOM 上改 left/top,看起来功能正常,但一旦要做缩放、撤销、远程同步,所有逻辑都要推翻重写。diagram-design 从第一天起就规定:任何交互的结果,最终都必须以 model 数据的变更来体现,视图只是 model 的投影。
第二个是“可持续性”的节奏感。一个图编辑器很容易写着写着就在局部交互里加各种补丁。我的建议是每实现一个完整功能后,花时间跑一遍通用操作路径(创建、连线、框选、删除、撤销),任何一环断了立刻修。要做高复现效率的 bug 报告,最好的方式是自己先写一个小型的自动化回归脚本,覆盖核心操作,后面改代码时直接跑一遍。
第三个是“边界条件优先测试”。我踩过的新手坑大多都和数据边界相关:空画布时删除节点、一条连线连到自己身上、两个节点完全重叠时框选命中谁、撤销到底之后继续执行任意操作。这些问题在普通路径下不会出现,但一旦出现就是很难排查的杂症。建议在开发阶段就给这些边界场景建测试用例,越早越好,后期加功能时你会感谢当初自己做的这个决定。
如果后续要扩展 diagram-design,我会优先补两块。一块是节点的样式面板,让用户能改填充色、边框、字体而不只是系统默认样式。第二块是导出功能,把画布内容无损导出为 SVG 或者图片。这两块都不涉及底层架构改动,说明当时的数据模型和交互基座留的扩展余量是够的。也许等这两个功能落地之后,我还会再写一篇补充文章,把样式系统和导出链路的实现经验也沉淀出来。