diagram-design 这个项目说起来其实是被实际业务逼出来的。当时我们团队在做一款业务中台类的产品开发和交付过程中需要大量绘制架构图、流程图、部署拓扑图但工具散落在各处有人用通用绘图软件有人直接在文档里用文本拼还有人临时用协作白板画完截图贴进文档。结果就是图跟代码严重脱节一改需求就要重画线上文档里的图和实际系统架构经常对不上维护成本越来越高。所以这个项目的目标从一开始就很明确做一个能嵌入业务系统的绘图组件让用户直接在页面上拖拽节点、画连线、导出图片同时能把图的数据结构序列化存储下来下次打开还能完整还原。它不是一个独立的绘图软件而是可以被其他业务模块复用的绘图能力。这篇文章我会把整个 diagram-design 项目的拆解思路、技术选型、核心实现和踩坑记录完整写一遍。如果你正准备做类似的绘图工具、流程图编辑器、拓扑图面板或者只是对这类前端交互密集型项目感兴趣这篇文章应该能给你一些可以直接抄作业的参考。1. 项目背景与需求拆解1.1 先把绘图工具这个词拆开看diagram-design 听起来像是一个偏设计感的项目但实际上它要构建的是一个可交互的图形编辑器核心能力拆成三块画布、图形、交互。画布负责承载所有图形元素负责缩放、平移、坐标系转换图形指节点矩形、圆形、菱形、自定义组件、连线直线、折线、曲线、分组容器这些可绘制对象交互则包含拖拽移动、框选、缩放节点、连接锚点、撤销重做这套操作体系。这三块不是并列关系而是有清晰依赖的交互层依赖图形层的几何信息图形层依赖画布层的坐标系统数据层则独立于渲染层存在。把这条依赖链理清楚整个项目的架构就成功了一半。我们后期所有新功能的扩展速度都取决于当初这条链路划分得干不干净。1.2 需求边界什么该做什么坚决不做立项时的第一件事不是列功能而是划边界。我们当时明确了两条原则。第一不做复杂的自动布局算法。自动布局树形布局、力导向布局听起来很酷但需求调研发现绝大多数用户在使用绘图工具时对布局有很强的主观控制欲——他们希望自己摆位置而不是让算法替他们决定。自动布局可以作为后续的加分项但绝不能作为核心依赖否则项目一开始就会陷入算法地狱。第二不追求 PPT 级别的炫酷渲染。绘图组件的核心价值是准确表达关系和结构而不是好看。所以我们把大量精力花在坐标计算、连线路径、命中检测这些硬核功能上而不是做光影、渐变、粒子效果。这个判断在后面被证实是划算的很多用户反馈里提到的都是连线对不对齐缩放会不会卡没有一个人说节点阴影不够柔和。这样划完边界之后功能清单就清晰了基础画布无限画布、缩放0.2x 到 4x、平移、网格吸附节点系统矩形/圆形/菱形/图片节点、自定义节点尺寸、文字标签、边框样式连线系统直线、直角折线、曲线贝塞尔、锚点自动吸附、连线标签交互系统拖拽、框选、多选、右键菜单、快捷键Delete、CtrlC/V、CtrlZ/Y数据能力JSON 序列化反序列化、导入导出对外接口事件回调选中变化、元素变更、只读模式、自定义渲染插槽2. 核心技术选型每一步都是取舍2.1 渲染方案SVG、Canvas 还是 DOM这是绘图工具类项目必须回答的第一个问题。我见过不少项目在这里拍脑袋做了决定后期想改已经来不及了。我们当时做了三轮方案评估分别用三种技术各写了一个最小原型跑了同样的压力测试。结论如下维度SVGCanvas 2DDOM CSS Transform节点数量超过500时性能明显卡顿事件绑定过多流畅但需要自己做命中检测卡顿DOM节点过多事件处理原生绑定简单直接需手动计算命中原生绑定但层级管理复杂样式灵活性高CSS 可直达中等需要重绘高文本编辑原生支持需要额外做输入层原生支持图片导出序列化 SVG 方便导出 PNG 方便复杂实现复杂度中等偏高偏低最终我们选了 SVG 为主渲染方案原因有三个。第一我们需要支持用户在图上直接编辑文本SVG 的 text 元素配合 DOM 事件做起来最顺手。第二节点数量在绝大多数真实场景下不会超过两三百个SVG 的性能完全够用。第三SVG 天然支持事件委托可以在根节点上统一监听所有子元素的事件不用像 Canvas 那样自己维护事件队列和命中检测逻辑。但这里有一个很重要的补充如果你确定业务场景里会出现单画布超过 1000 个节点的情况建议直接用 Canvas。我们后续在做性能优化时发现SVG 的瓶颈不在于绘制本身而在于 DOM 事件绑定数量和重绘成本的不可控。方案没有绝对的好坏只有适不适合你的真实场景。2.2 状态管理数据与渲染必须分离绘图工具的复杂度很大一部分来自状态管理。节点位置变了、缩放比例变了、选中了哪些元素、当前拖拽的是谁……这些状态如果散落在各处全局变量里项目很快会变成一团乱麻。我们采用了单一数据源 发布订阅的模式。整个画布的状态收敛为一个 JSON 对象结构大致如下{ canvas: { zoom: 1, offsetX: 100, offsetY: 50 }, nodes: [ { id: node_1, type: rect, x: 120, y: 80, width: 160, height: 60, style: { fill: #ffffff, stroke: #333333 }, text: 订单服务 } ], edges: [ { id: edge_1, source: node_1, target: node_2, sourceAnchor: right, targetAnchor: left } ] }任何操作移动、缩放、删除、编辑文本都通过统一的 action 修改这个数据源然后由渲染层根据数据源的变化增量更新 SVG。这样做的收益是整个项目期都在持续兑现的撤销重做只需要保存操作前后的数据快照或者操作日志序列化和反序列化就是 JSON 的 stringify 和 parse调试时可以随时打印出整个画布的状态未来做多人协作也只需要同步这一个数据源。2.3 交互状态机拖拽、缩放不打架交互是绘图工具最容易出 bug 的地方。节点拖拽和画布拖拽冲突、框选和节点拖拽冲突、双击编辑和单击选中的区分……这些问题如果不从架构层面解决写出来的代码会是一堆 if-else 补丁而且每加一个交互就会把之前的逻辑弄坏。我们的做法是引入了一个简单的交互状态机。每次鼠标按下时根据按下的位置和当前的工具模式决定进入哪个状态idle无操作状态draggingNode拖拽单个或多个节点panning平移画布rubberBand框选resizing调整节点尺寸connecting从锚点拖出连线每个状态对应一个独立的处理器鼠标移动和鼠标抬起的事件由当前状态的处理器接管。状态切换有明确的条件比如按下时点中的是节点空白区域且当前工具是选择工具才进入 rubberBand点中的是节点本体才进入 draggingNode。这个设计直接让代码的可维护性提升了一个档次后面加橡皮筋框选、自由绘制工具都只是新增一个状态不用改动已有逻辑。3. 核心机制剖析数据、渲染与交互三层分离3.1 数据层元素模型的通用化设计绘图工具里的元素形态各异矩形、圆形、菱形、文本、图片甚至未来的自定义组件。如果我们为每种元素单独写一套数据结构代码会大量重复每加一个类型就要动一遍主逻辑。所以数据层做了一个通用化的设计。所有元素共享一个基础结构class ElementModel { id uuid(); type base; x 0; y 0; zIndex 0; selected false; attrs {}; // 尺寸、半径等几何属性 style {}; // 描边、填充、字号等样式属性 data {}; // 业务自定义数据永远不参与渲染 toJSON() { ... } static fromJSON(json) { ... } }type 字段用来区分具体类型每种类型注册自己的渲染函数、几何计算函数、属性面板配置。这样新增一种节点类型时不需要动主框架只需要注册一个新的 type。这个模式在后期接入业务方自定义节点时立了大功——业务方甚至不需要理解整个框架只需要按约定的接口实现一个渲染函数节点就能在画布上正常工作。这里有一个实践要点data 字段和 attrs 字段必须严格分开。data 存放业务数据比如节点代表的服务器 IP、进程名、监控指标attrs 只负责几何信息。如果混在一起后期做数据联动和序列化兼容时会非常痛苦。你永远不希望因为某个业务数据变了导致整个画布重新渲染或者因为渲染参数混进了业务数据连线的样式被莫名改掉。3.2 渲染层增量更新与批量调度SVG 的渲染层核心是如何把数据源的变化映射到 DOM 变更。最粗暴的做法是每次数据变化都把整个 SVG 重新生成一遍但这样在大画布上会造成明显的卡顿和闪烁点击一个节点整个画布都重绘视觉上非常不稳。我们采用的是元素级增量更新策略。每一个元素模型对应一个 SVG 节点渲染层维护一个映射表const svgElementMap { node_1: rect ..., node_2: g.../g }当数据变化时不是重绘整个画布而是找到变化的元素只更新那个元素的 SVG 属性。移动一个节点只改它的 x/y 属性缩放一个节点只改它的 width/height删除元素时只 remove 对应的 SVG 节点。这个思路跟浏览器渲染里的脏矩形检查异曲同工只重绘变化的部分而不是全部。配合这个机制还需要一个调度器。当一个操作比如拖拽移动会高频触发数据变化时不要每次数据变化都立刻操作 DOM而是用一个 requestAnimationFrame 批量处理。实测下来50 个节点同时拖拽时帧率从 30fps 提升到了稳定的 60fps。批量调度这块是整个渲染层性能优化的最大单项收益一定要做。3.3 交互层坐标转换是核心中的核心画布有缩放和平移意味着屏幕坐标和画布坐标之间永远存在一层转换。这个转换做错了所有交互都会出问题。而且这类 bug 有个特点早期功能简单时不容易暴露等节点多了、缩放频繁了各种错位问题会集中爆发。核心公式其实只有两个// 屏幕坐标转画布坐标鼠标在某元素上的位置 const canvasX (clientX - rect.left - offsetX) / zoom; const canvasY (clientY - rect.top - offsetY) / zoom; // 画布坐标转屏幕坐标渲染元素时 const screenX canvasX * zoom offsetX; const screenY canvasY * zoom offsetY;offsetX/offsetY 是画布在当前视口中的偏移量zoom 是缩放比例。看起来简单但实际工程里有三个容易踩的坑。第一鼠标事件里拿到的 clientX/clientY 是视口坐标必须先减去 SVG 元素相对视口的 offsetLeft/offsetTop再进入坐标转换。第二缩放时如果以鼠标为中心缩放需要在缩放前后重新计算 offsetX/offsetY保证鼠标下的那个画布点不漂移。第三所有命中检测点击选中的是哪个节点都必须基于画布坐标不能用屏幕坐标直接判断否则缩放后点击区域会错位。我们有个线上 bug 就是这三个坑里的第一个引起的排查了整整两天最后发现就是差了一个 rect.left。3.4 命中检测看不见的底层能力命中检测是绘图工具里最容易被忽略、但做不好就处处难受的功能。SVG 虽然自带事件命中但是在缩放极小、节点密集、图形重叠的场景下默认命中表现并不符合预期。我们的命中检测框架是这样的每个元素类型注册一个 hitTest 函数输入画布坐标输出是否命中以及命中的具体位置是本体、是锚点、是缩放手柄。对于矩形用 AABB 检测x node.x x node.x width圆形用距离检测点到圆心的距离小于半径文字用 bbox 检测。这里有一个经验细节所有命中检测都基于画布坐标而不是屏幕坐标。因为画布坐标不随缩放变化在这个坐标系里做几何运算最简单、最稳定。而且我们要求所有元素的命中检测函数必须注册到同一个表里交互层只负责遍历顺序从顶层往下这样新增元素类型时命中检测天然可用不会出现画得出来但点不中的尴尬。4. 实操落地核心功能的完整实现步骤4.1 第一步搭建画布骨架画布容器我建议用两层结构外层 div 负责滚动和事件捕获内层 SVG 负责渲染。这样滚动条和绘图区互不干扰后续做事件委托也只挂在 SVG 根节点上。初始化时需要设置 SVG 的 viewBox 和尺寸。我们的实现里SVG 的宽度和高度等于视口大小画布的无限延伸通过修改 offsetX/offsetY 和 zoom 来实现而不是给 SVG 设一个巨大的宽高。这个设计的优势是无论画布内容有多少浏览器只需要维护一个视口大小的 SVG 节点性能开销非常小。如果给 SVG 设一个 10000x10000 的宽高虽然可以容纳更多内容但每个像素都有布局计算成本卡顿是必然的。事件监听上我们把 mousedown 挂在 SVG 根节点上mousemove 和 mouseup 挂在 window 上。这样即使鼠标拖拽速度太快移出了 SVG 区域松手的事件也不会丢避免出现拖出去了就卡住的经典问题。4.2 第二步实现节点的渲染与选中渲染一个矩形节点核心是生成一段 SVG 并绑定事件。我们封装了一个渲染函数接收元素模型输出 SVG DOM。这里直接上一个简化后的核心代码function renderNode(model) { const g createSvgElement(g); g.setAttribute(data-id, model.id); g.setAttribute(data-type, node); const rect createSvgElement(rect); rect.setAttribute(x, model.x); rect.setAttribute(y, model.y); rect.setAttribute(width, model.attrs.width); rect.setAttribute(height, model.attrs.height); rect.setAttribute(fill, model.style.fill); rect.setAttribute(stroke, model.style.stroke); g.appendChild(rect); const text createSvgElement(text); text.setAttribute(x, model.x model.attrs.width / 2); text.setAttribute(y, model.y model.attrs.height / 2); text.setAttribute(text-anchor, middle); text.setAttribute(dominant-baseline, central); text.textContent model.text; g.appendChild(text); return g; }值得展开讲一下的是选中态的处理。选中一个节点后通常要显示四个角上的缩放手柄和一个虚线边框。我们的做法是每个节点渲染成一组g元素里面包含主图形、文字、选中态的手柄元素。选中态默认隐藏只有在 selected 为 true 时才显示。这里有个性能细节手柄元素不能滥用 CSS 的 display 切换因为每次切换都会触发重排。我们直接使用 SVG 的 visibility 属性来隐藏和显示手柄实测在快速连续点击多个节点时闪烁问题明显减少。而且缩放手柄的渲染也是动态的只有节点宽度和高度大于一定阈值时才显示四角手柄节点小到没必要缩放时就隐藏避免误操作。4.3 第三步连线的锚点与路径计算连线是绘图工具里最容易出 bug 的部分没有之一。节点可以移动线必须跟着动节点有多个锚点线要连到最近的锚点线的路径有直线、折线、曲线三种模式。而且连线出问题时往往很隐蔽——不是不能用而是路径看起来别扭、拐弯角度怪异、缩放后锚点错位。锚点系统我们第一版做的是固定锚点方案矩形的四边中点各有一个锚点圆形有上下左右四个锚点。连线时记录 sourceAnchor 和 targetAnchor当节点移动时重新计算锚点的坐标并更新路径。这个过程必须走纯函数方便测试。路径计算上曲线连线用的是三次贝塞尔控制点的计算规则是从 source 锚点出发控制点沿着锚点法线方向延伸一段距离距离等于两个节点距离的一半限制在 40 到 200 之间。直角折线则是先水平走一段再垂直走一段再水平走到目标锚点。这个规则写出来不难难点在边界条件两个节点重叠时距离为 0控制点距离要兜底到 40两个节点水平对齐时垂直段长度为 0要避免产生 0 长度的无效路径。这里有一个非常实用的经验连线的路径更新不要直接改 SVG 的 d 属性而是把 d 的计算封装成纯函数并单独维护。我们把 path 生成函数单独拎出来测了几十个场景确保锚点位置、节点尺寸、缩放比例的各种组合下路径都不会出现明显的断点、回折或交叉。这类几何计算函数是最适合单测的一次跑通以后就基本不用管了。4.4 第四步缩放与平移缩放功能的体验细节比想象中多。我们用的是以鼠标位置为中心的缩放方式也就是鼠标指到哪里哪里就保持不动画布围绕这个点缩放。这是主流绘图工具的基本交互习惯用户从其他工具迁移过来不需要重新学习。实现公式为function zoomAt(clientX, clientY, newZoom) { const rect container.getBoundingClientRect(); const prevZoom state.canvas.zoom; // 先计算鼠标所指的画布坐标 const wx (clientX - rect.left - state.canvas.offsetX) / prevZoom; const wy (clientY - rect.top - state.canvas.offsetY) / prevZoom; // 应用新的缩放比例 state.canvas.zoom clamp(newZoom, 0.2, 4); // 反解偏移量保证 wx/wy 位置不动 state.canvas.offsetX clientX - rect.left - wx * state.canvas.zoom; state.canvas.offsetY clientY - rect.top - wy * state.canvas.zoom; }wx/wy 是缩放前鼠标所指的画布坐标缩放后反解 offset保证鼠标位置下的画布点坐标不变。缩放范围我们限制在 0.2 到 4 倍低于 0.2 时节点会太小无法操作高于 4 倍时边缘模糊没有意义。平移的实现用鼠标中键按住拖动或者按住空格键切换到临时手形工具再左键拖动。注意两个细节中键拖拽的过程中要阻止默认行为和文本选择否则会出现滚动条跳动和文字选中的问题光标样式也要跟着状态切换拖拽时显示 grabbing让用户明确感知当前处于平移模式。4.5 第五步撤销重做撤销重做我们用了一个很朴素却很稳定的实现——命令模式。每次用户的操作移动、新增、删除、改样式都会生成一个命令对象class Command { constructor(label, doFn, undoFn) { this.label label; this.doFn doFn; this.undoFn undoFn; } execute() { this.doFn(); } undo() { this.undoFn(); } }操作发生时执行 doFn并把命令压入 undoStack同时清空 redoStack。撤销时弹出最后一条命令执行 undoFn压入 redoStack。重做时反向操作。这个模式看起来简单但有一个隐蔽的坑命令对象里捕获的变量必须是操作前的快照值。举个例子移动节点时 doFn 把节点从 (x1,y1) 移到 (x2,y2)undoFn 需要把节点移回 (x1,y1)。如果 undoFn 里写的是读取当前坐标再减 delta第二次撤销就会出错。正确做法是让 undoFn 直接闭包捕获 (x1,y1) 和 (x2,y2)每次撤销都设置到固定值而不是做增量计算。相比直接保存 JSON 快照命令模式的优点是内存占用小、支持细粒度撤销用户不想因为移动了 20 个像素就撤销掉整轮操作而且可以为每条命令记录一个友好的描述文本用于在撤销面板里展示已撤销移动节点。我们后期还基于这套命令体系做了一个操作历史时间线几乎零成本实现。5. 踩坑记录与排查清单5.1 高频问题画布拖动和节点拖动打架开发过程中遇到的第一个高频问题是在节点上按下鼠标时既想选中节点又不想让画布跟着平移。解决方式是事件触发顺序判断在 mousedown 时先做命中检测如果点中了节点则本次交互状态是 draggingNode画布平移相关的监听器不再响应如果点中的是画布空白区域则进入 rubberBand 或 panning。这里还要处理一个边界节点很小的时候比如缩放低于 0.3用户很难点中体验很差。我们的方案是对命中检测做了扩界处理——对节点实际尺寸至少增加 8 像素的命中半径让节点在缩小后依然容易被选中。但扩界只影响选中不影响拖拽后的贴边视觉效果否则看着都点中了拖起来却对不齐反而更难受。5.2 高频问题缩放后导出图片模糊导图功能上线后有用户反馈导出的 PNG 在高分屏上模糊。原因很简单导出时没有考虑 devicePixelRatio。修复方案是导出前临时把 SVG 的宽高放大 dpr 倍渲染后再缩放回来。但要注意这样做会触发一次额外的重绘大画布导出时可能会有短暂的画面跳动建议导出期间加一个 loading 遮罩。这个问题的另一个教训是任何涉及图片导出的绘图工具建议一开始就把 dpr 纳入设计否则后期改导出逻辑时会影响一大批依赖导出结果的周边功能比如分享图生成、文档嵌入。5.3 性能笔记大数据量下的卡顿优化一次客户压测单画布塞入了 1200 个节点、1500 条连线卡顿明显。我们做了三轮优化。第一轮把所有事件监听器从每个 SVG 节点上移除改成在根 SVG 上做事件委托用 closest 找到目标元素。这一轮减少了约 60% 的监听器数量。之前每个节点都绑了自己的 click、mousedown、mouseenter 等五六个事件1200 个节点就是六千多个监听器光是注册和销毁就是一笔大开销。第二轮优化连线的路径更新。原来每条线的路径变化都会立刻更新 DOM我们加了一个批量更新队列把同一帧内的多次路径更新合并为一次。因为拖拽一个节点会同时触发几十条连线重算如果每条线各自更新一帧内会有几十次 DOM 写入合并后只有一次。第三轮降低网格吸附的计算频率。原来每次 mousemove 都做网格吸附计算我们改成每 16ms 只计算一次吸附的磁性手感几乎不受影响但 CPU 占用明显下降。网格吸附这块是典型的高频低变化操作非常适合做节流处理。5.4 常见问题速查表现象可能原因解决方案节点拖拽跟不上鼠标渲染层更新太重或未批量更新改用 rAF 批量更新 DOM缩放后点击位置偏差坐标转换未考虑视口偏移检查转换公式中的 rect.left/top连线断开不跟随锚点坐标未在节点移动时更新节点变更后统一触发 edge 路径重算撤销时状态错乱redoStack 未在新增操作时清空所有 do 操作前清空 redoStack中键拖拽时弹出浏览器滚动未阻止默认行为mousedown 中调用 preventDefault导出图片模糊未考虑 devicePixelRatio导出前按 dpr 放大画布快速双击时文本编辑状态异常单击选中与双击编辑状态竞争在双击处理器里取消单击的延迟选中6. 个人体会绘图项目的工程复杂度比想象中高整个 diagram-design 项目从立项到第一个可用的版本我们用了大概六周但这六周里最耗时间的不是写渲染代码而是处理交互细节。绘图工具这种项目看起来就是个画框框、连箭头实际做下来你会发现坐标转换、命中检测、状态切换、事件优先级每一个环节都是一个独立的微型工程任何一个环节的粗糙处理都会在用户的高频操作里被无限放大。我最大的体会是这类项目一定要把数据层和渲染层彻底分开。凡是图形编辑类项目最终都会遇到同一个数据多种视图的需求——可能是缩略图、只读预览、打印视图也可能是协作时的远程光标。如果渲染逻辑和业务数据耦合在一起这些需求每一个都会让你改到崩溃。我们因为提前做了数据驱动渲染后面加只读模式和缩略图功能时几乎没动主框架只是加了两个新的视图订阅者而已。另外一个建议是如果你要做类似的项目先把坐标系转换和命中检测这两个基础模块吃透然后立刻写单元测试。这两个模块是整个系统的地基地基稳了上面盖什么楼都踏实。我们后期修的绝大多数 bug最后定位到的根因都在坐标转换的边界条件上。不是其他模块没问题而是其他模块出问题一眼就能看出来坐标转换出问题却常常表现为点击位置差一点拖拽有点歪这类很难描述的玄学问题排查成本极高。最后分享一个小技巧开发阶段在调试面板里把当前坐标、当前缩放、命中元素 ID实时显示出来排查问题的速度会快非常多。这个看似不起眼的调试信息帮我们省下了大量反复 console.log 的时间。如果你也在做类似的工具项目强烈建议从第一天就加上这个调试面板等出了问题再加就有点晚了。