我最初决定写这个 diagram-design 项目是因为团队里每周都有三四个画图需求架构图、流程图、部署拓扑图、数据链路图来回换工具太痛苦而且想要在图上做交互、拖拽、联动、导出现成的图形工具基本不让我定制。折腾了两周我决定自己撸一套浏览器端的拖拽式设计器。这个项目做下来最大的感受是画一个静态图容易做一个能自由拖拽、连线、缩放、撤销重做的设计器真正的工程难点不在渲染而在数据模型和事件链路上。这篇文章会把我在 diagram-design 里踩过的坑、最终落地的设计方案、以及一步步排查问题的完整过程整理出来给也在做类似图表编辑器、流程编排器、低代码画布的同学一些参考。1. 为什么是自研现成方案和真实需求之间的落差1.1 团队需要的不是画图而是可交互的图在动手之前我先列了一下我们真实的使用场景。除了最基础的节点展示大家频繁提到的需求是节点能拖来拖去调整位置、节点之间有连线、连线可以标记方向和文字、画布支持放大缩小、做完之后能导出图片、部分节点要能映射到真实系统状态。这类场景在项目里其实更接近编辑器而不是绘图输出。拿这个目标和现成方案对比就很清晰了。传统绘图工具擅长的是在固定画布上创作重心放在画布本身的表达能力上但几乎不提供运行时交互专业图编辑库能力很强但学习曲线陡为了做到高可定制要把底层渲染机制理解得很透彻而且后续每次想改交互都要深入到很深的抽象层去动逻辑。我们真正需要的是一个图形化建模工具的底座可以自定义节点类型、自定义连线规则、自定义工具面板而这些需求如果基于通用库二次开发改造量并不比从零写一个精简的渲染内核小。1.2 项目范围收敛我到底要做什么为了避免一开始就失控我把自研范围收敛得很明确核心是一个基于浏览器的 diagram 设计器引擎不依赖重量级图形库用纯 DOM SVG 完成渲染交互上至少支持节点拖拽、锚点连线、画布平移缩放、框选、撤销重做数据上支持导出 JSON 和图片也支持从 JSON 恢复画布。这个范围能覆盖团队绝大多数内部建模和文档需求也为后续扩展留了空间。功能边界确定后整个项目就好拆分了。我把 diagram-design 拆成了三块数据层、渲染层、交互层。数据层维护画布上的所有元素和关系渲染层负责把数据变成页面上的可视元素交互层捕获鼠标、键盘事件把用户操作转成数据层的变更。这个划分在后面所有环节都被证明是正确的因为一旦遇到问题我总能快速定位是数据问题、渲染问题还是事件问题。2. 数据模型整个设计器的地基决定了后续所有功能的上限2.1 节点、连线、画布三要素的抽象方式我第一时间做的不是写渲染而是先把画布的数据结构定下来。因为所有交互、渲染、导出最终都要落回对这个数据模型的操作。节点相对简单我把它定义成一类节点共用一个模板模板决定节点长什么样、有哪些锚点、有哪些可配置属性每个具体节点保存实例数据比如坐标、尺寸、名称、业务属性。连线则维护一个独立的表记录起点节点和锚点、终点节点和锚点以及连线的样式、标签、路由方式。画布本身也是数据的一部分包含宽高、背景网格配置、当前缩放比例、视口偏移量。关键设计是所有坐标在数据层都使用逻辑坐标不考虑缩放和偏移只有渲染层在做坐标换算。这就像地图上标注一个地点的经纬度不管你在手机屏幕上把地图放大多少倍经纬度不应该变。这个原则是后面避免缩放抖动问题的关键。2.2 为什么采用数据驱动渲染而不是命令式操作项目早期我试过命令式的做法拖拽一个节点时直接改它的 DOM 坐标。当时感觉见效快代码直白。但很快发现连线和锚点位置需要联动节点上如果有端口端口位置也要跟着变框选和撤销操作要遍历大量 DOM再加上缩放平移坐标换算散落在各处每次维护都像在补洞。后来我彻底改为数据驱动。交互层只负责更新数据模型渲染层监听数据变化统一重新计算所有元素的位置、连线的路径、画布的偏移。这个改动初期看起来多了一些约束但从长期看收益是巨大的数据驱动与命令式对比维度命令式直接操作DOM数据驱动统一渲染缩放平移适配每次操作都要手动换算渲染层统一换算一份撤销重做快照和恢复逻辑分散只需恢复数据模型连线联动需要额外更新所有相关DOM数据变化后自动重算新增功能扩展容易牵一发动全身只接数据和渲染两个口实际操作下来撤销重做、自动排列、批量格式化这些功能在数据驱动模式下都能很自然地写出来。因为所有状态都收敛在一个 store 里任何操作完成后只需要保存一份数据快照撤销就是恢复上一个快照重做就是恢复下一个快照。2.3 锚点Port如何设计才能支撑灵活连线锚点是连线的起点和终点它不直接属于某个节点而属于某个节点的模板。比如一个数据库节点模板可以定义四个锚点上下左右各一个一个自定义节点模板甚至可以定义十五个锚点表示不同的业务入口。每个锚点有自己的相对坐标、方向和允许连接的锚点类型。连线数据里只记录锚点的引用不记录具体坐标。为什么要这样因为节点一挪动连线的路径就要重算如果连线里存的是坐标那每次节点移动都要去改一堆连线数据数据模型里到处都是无效冗余。引用锚点以后渲染层在画每条连线前根据节点当前坐标、锚点相对坐标、缩放比例重新计算路径数据层始终是干净的。这也是我推荐的通用做法。节点的数据形态和连线的数据形态最终稳定成下面这个样子代码能说明一切// 节点数据 const node { id: node-a, type: db-node, name: 订单库, position: { x: 120, y: 240 }, width: 180, height: 80, properties: { dbType: mysql, status: running } }; // 连线数据 const edge { id: edge-1, source: { nodeId: node-a, portId: bottom }, target: { nodeId: node-b, portId: top }, label: 写入, routeType: bezier, style: { stroke: #1677ff, strokeWidth: 2 } };这么设计之后我在后文遇到的视图不更新问题归根结底也是因为某处代码绕过数据模型直接改了 DOM和这个设计原则一对照问题立刻暴露。3. 渲染层与交互层把数据变化变成看得见的反馈3.1 画布结构为什么节点用 DOM 而不是 SVG画布渲染方式我做了个折中画布背景、连线、锚点标识用一层 SVG 负责节点主体和节点上的文本用 DOM 层负责。原因是节点内部往往有较复杂的结构状态标识、自定义图片、表单控件、右侧属性面板的联动高亮DOM 在这些场景下开发效率和可维护性都要高得多。连线需要精确计算路径和方向箭头SVG 天生适合。两层叠在同一个坐标系下通过一个 transform 容器同步缩放和平移。节点的宽高始终是逻辑尺寸缩放时通过 CSS transform 整体变化这样节点内部的布局逻辑不用重复处理。这个方案的另外一个好处是后续如果要接入图形引擎替代 SVG 连线替换成本也能控制住。3.2 拖拽一条链路从 mousedown 到数据更新拖拽是编辑器最基本的能力但一根简单的拖动链路上埋着不少细节。我最终实现的节点拖拽流程是这样的用户在节点上按下鼠标左键时捕获起点坐标同时把当前画布缩放值 zoom、画布偏移 pan 一起缓存下来鼠标移动时用当前鼠标坐标减去起点坐标再除以 zoom得到逻辑坐标下的位移量然后用这个位移量更新节点的 position 数据渲染层监听到数据变化后更新 DOM 位置同时把与这个节点相关的连线标记为待重算。这里有三个容易犯错的小点。第一事件监听最好挂在窗口上而不是节点本身否则拖太快移出节点区域时后续 mousemove 事件会丢失第二位移量必须除以 zoom不然在放大 200% 的画布上拖动节点会比鼠标慢半拍第三按下时就要记录初始位置每次在初始位置上加偏移而不是在当前位置上加偏移否则位移误差会累积成越来越严重的抖动。我在实际项目中第一次就是因为第三点没有注意拖几下手感就明显发飘。3.3 连线交互锚点命中与临时连线的渲染连线在交互上主要有两个步骤命中锚点和绘制临时连线。鼠标在拖拽连线时我要判断当前坐标是否落在某个锚点的热区里。锚点的逻辑尺寸很小但在缩小时如果还用逻辑尺寸判断用户会很难点中。所以我的命中半径也随 zoom 调整逻辑命中半径设置为 12实际检测距离等于 12 除以 zoom等效于无论画布放大缩小多少用户在屏幕上看到的可点击热区大小是一致的。临时连线我单独维护一份数据它不属于实际的 edge 列表而是只存在交互状态里。用户拖拽过程中临时连线从起点锚点画到鼠标当前位置鼠标进入某个可接受锚点热区时临时连线吸附到那个锚点松开后把临时连线正式写入 edge 列表并触发一次数据快照用于撤销。整个过程里临时连线始终不污染正式数据让我免掉了如果用户拖到一半按了取消怎么回滚这种麻烦问题。锚点方向对连线路径的计算也很关键。我对比了几种连线路由规则正交连线比较适合流程图和拓扑图但路由算法要考虑避让贝塞尔曲线视觉效果更柔和适合表达复杂关系。最后我默认选择了三次贝塞尔曲线起点和终点的控制点方向由锚点的 direction 决定比如上方锚点的控制点朝上延伸这样画出来的连线有自然的方向引导作用用户一看就知道数据是从哪头流向哪头。3.4 缩放与平移一套统一的坐标换算逻辑缩放和平移的实现遵循一个口诀逻辑坐标不变视口坐标计算。画布当前的 zoom 和 pan 都保存在数据层中渲染层在任何地方需要把鼠标坐标转成画布坐标时统一走一个工具函数function screenToCanvas(screenX, screenY, zoom, pan) { return { x: (screenX - pan.x) / zoom, y: (screenY - pan.y) / zoom }; }这个换算逻辑我只有一个版本所有事件处理器都必须通过它转换坐标不允许在组件里自行加减乘除。当初定这个约束是想避免在两个地方用不同的换算方式导致坐标越来越偏。后面在排查缩放后拖拽节点抖动的 bug 时问题最终就确定在某个代码分支没有走统一换算函数直接用了没有除 zoom 的偏移量。滚轮缩放我以鼠标所在位置为缩放中心这样做符合用户直觉。实现方式是在缩放因子变化前后先记录鼠标在画布上的逻辑坐标再保证这个逻辑坐标在缩放前后仍对应鼠标的屏幕位置不变由此反推出新的 pan 值。这个数学关系稍微绕一下但本质上就是一次坐标反解核心调用也就是上面那个工具函数倒着用一遍。4. 性能优化节点一多就卡问题往往不在渲染而在事件4.1 从整图重绘到局部更新功能全部跑通后我做了个压力测试在画布上放三百个节点、五百条连线然后开始拖拽。果不其然帧率掉得厉害。初步排查时我以为 SVG 渲染是瓶颈实际上真正的问题是每次拖拽都会触发重算画布上所有连线的路径这个操作三百个节点的画布一条节点拖动会连带触发上百条连线重算哪怕只有两条连线真正和这个节点相关。优化方案并不玄妙就是把全量重算改成局部重算。每一个节点维护一个依赖连线表记录哪些连线把它作为起点或终点节点位置变化时只重算依赖表里的连线其余的路径全部保持原样。这一步优化之后三百节点的场景下拖拽帧率提升很明显从明显卡顿变成了基本流畅。另外一次拖拽期间节点位置变化的频率很高其实不需要在每一帧都重算连线我加了一个 16ms 的批量更新缓冲把同一帧内的多次变更合并成一次重算渲染。4.2 事件节流高频交互不能每次都走完整链路高频交互里的节流是性能优化的第二板斧。鼠标移动事件在快速拖动时触发频率很高没必要每次都走完整的点击命中检测、光标状态计算、多画布联动这些操作可以放到渲染帧里统一处理。我利用 requestAnimationFrame 做了一层调度mousemove 事件只负责记录最新坐标渲染层在一个帧周期内读取最新坐标并执行所有需要更新的逻辑避免同一帧内重复计算中间过程。这个模式下还顺带解决了一个潜在问题如果用户在很短的时间内连续做了两次拖拽中间态被合并渲染用户感知不到差异但系统压力小得多。类似地画布缩放时每次滚轮事件我也会标记一次需要更新网格线而不是立刻重新绘制网格等渲染帧到来时统一重绘。表格化对比一下优化前后的变化性能项优化前优化后关键手段拖拽节点时的连线重算全图所有连线仅依赖节点连线依赖连线表帧内重复计算多次计算合并为一次requestAnimationFrame网格线重绘每次滚轮触发帧内统一重绘脏标记位坐标换算各组件自行实现统一工具函数集中维护4.3 大数据量下的渲染思路虚拟化与裁剪后来我又继续压规模把节点数量提升到三千以上单纯靠局部更新已经不够了因为环境中所有 DOM 节点和连线元素都真实存在浏览器渲染引擎光处理这么多元素就喘不过气。到这个阶段我采用了可视区裁剪只渲染画布视口范围内的节点和连线超出视口的部分从 DOM 中移除或标记为不可见。可视区裁剪的关键是计算当前视口在逻辑坐标下的范围然后将节点的包围盒和这个视口范围做交集判断。批量操作后我马上更新可见性。这个方案让渲染复杂度从画布元素总数降为视口内元素数量。对于使用场景以中大型图为目标的工具来说这基本是必须做的设计不是可选项。5. 踩坑实录三个严重问题完整排查链路复盘5.1 缩放后拖拽节点抖动问题不在拖拽在坐标换算分支项目联调期测试反馈一个严重问题把画布放大到 200% 后拖拽节点时节点会时不时跳一格尤其在快速拖动时位置会偏离鼠标。刚开始我怀疑是浮点精度问题因为缩放后数值确实会产生小数误差于是先把所有坐标做了四舍五入但问题依旧间歇性出现。我决定一点点跟踪坐标链路。在拖拽过程中打印了每一步的坐标鼠标屏幕坐标、转换后的画布坐标、节点目标坐标。对比后发现当拖拽事件来源于画布背景上某个特定区域时转换后的坐标明显偏大而那个区域恰好重叠了一个子面板。我翻遍了画布组件的事件监听发现那个子面板在处理自己的拖拽逻辑时也注册了鼠标移动事件并且在自己的坐标系里更新了一个元素的位置。这样一来由它发出的坐标更新没有经过统一的 screenToCanvas 换算直接以屏幕像素值写入了节点数据。修复方式也很直白将所有的拖拽坐标更新收敛到统一的事件处理入口任何组件的拖拽逻辑都必须调用统一的换算函数不允许单独操作画布节点坐标。这个经验也让我养成了一个习惯自定义事件对象里绝不透传已转换或未转换的值事件统一携带屏幕坐标由数据层统一换算避免不同组件对同一坐标来源的认知不一致。5.2 撤消重做导致视图不更新引用类型快照的陷阱撤销重做在数据驱动模式下本来很简单每次操作完成后把数据模型做一次深拷贝存入历史栈撤销时取出历史快照覆盖当前数据。但我遇到了一个诡异的 bug点击撤销后数据里某些节点的属性确实回去了但界面上的节点位置没有变化。我一开始以为是渲染监听的时机问题可数据明明已经变化。我用了一个最小的复现案例来定位画布上只放一个节点拖动它一次然后撤销。断点打到渲染层的更新逻辑它确实收到了新的数据对象但在比较节点位置时发现新旧坐标完全相同。进一步查才发现问题出在我做历史快照的方式上。为了性能考虑我用了结构化浅拷贝而不是深拷贝节点的 position 对象被多个快照共用。于是撤销时看似赋值了新的数据对象实际节点位置引用还是同一个对象只是这个对象在原位被改回了旧值。修复方案是对涉及节点位置和尺寸的数据上下文一律使用深拷贝同时历史栈里不只保存数据对象还保存该对象操作前的前置版本确保每次撤销不只是恢复引用而是恢复完整状态。这个坑属于序列化与引用类型的经典问题排查过程走了一遍才意识到深拷贝的消耗对比 bug 的代价完全可以忽略。错误做法表现正确做法结构化浅拷贝快照撤销后数据“看似”恢复视图不动对坐标、尺寸等嵌套对象使用深拷贝操作后直接保存当前对象多个快照互相影响保存操作前的前置版本比较新旧对象用 永远相等比较关键字段或引用不可变对象5.3 导出图片内容错位容器尺寸和视口偏移的动态平衡导出功能的需求很明确把画布可视区域内所有内容导出为图片并且图片内容和屏幕看到的完全一致。我第一次实现时直接让画布临时隐藏掉网格背景把 SVG 内容序列化后放到一个离屏容器再用手动触发的浏览器绘制接口导出。导出结果出来后图片顶部出现了一段错误的白边部分连线偏移。排查后发现根本原因是离屏容器的尺寸和缩放比例与画布当前视口不一致。用户缩放过画布也平移过视口但导出容器里的内容仍然用旧视口参数等于把原来的视口偏移也旋转进了导出内容里。我在导出流程的最开始先把画布的 pan 与 zoom 置为初始值等图片绘制完成后恢复原值导出容器则按照数据模型中所有节点的包围盒计算尺寸保证内容完整。如果没有先重置视口参数导出内容里的偏移量就和企业里某些系统截图产生的白边是一个性质信息看起来都还在但布局整体错位。这个坑也让我意识到导出的本质不是截屏而是重新进行一次脱离视口的数据渲染导出过程应该完全基于逻辑坐标而不受当前视口影响。5.4 排查方法论复盘快速定位三类常见问题的套路这几个问题处理完我总结了一套定位 diagram 类 bug 的方法。第一先判断是数据问题还是渲染问题打开控制台打印数据模型如果数据正确而界面不正确问题在渲染层如果数据就不正确问题在交互层。第二制作一个最小的复现实例把无关元素全部移除缩小范围。第三如果是视图不更新优先检查数据是否发生了引用级变更而不是值级变更。6. 可选配置如何让设计器变成半成品平台6.1 节点模板与插件化的扩展思路当 diagram-design 的核心功能稳定后我开始考虑如何让它服务更多场景。团队里已经有伙伴提出要画不同风格的图有人要画系统架构图节点是服务和工作负载有人要画流程图节点是步骤和状态还有人想画网络拓扑节点是设备。如果每个场景都复制一份设计器代码未来维护量会失控。于是我把节点模板做成了注册制每个模板包含渲染结构、尺寸、锚点定义、属性面板配置和序列化规则。新场景只需要注册一个新模板不需要改动核心代码。连线的样式和路由规则也可以按场景注册比如流程图场景注册正交路由架构图场景注册贝塞尔曲线。这个插件化的边界设定让项目从一个工具变成了一个可承载多种图的平台层。延伸下去插件机制还支持注册一种行为比如鼠标双击节点时执行自定义动作按键 Delete 时执行批量删除逻辑。行为可以在不侵入核心代码的情况下挂载到设计器的事件生命周期里。社区里很多成熟的设计器也是差不多的思路早期做对插槽式设计后面扩展起来会省很多事。6.2 数据导出、导入与协作前景数据模型和渲染分离做完后导入导入几乎不需要额外加成。JSON 序列化做的是一次完整的数据导出包括画布视口、节点、连线、模板标记和业务属性。反序列化则负责从 JSON 恢复出完整编辑状态。这个能力也天然带了协作的接口因为两个人的不同编辑操作本质上就是对同一份数据模型的不同变更合并变更时核心逻辑就是 JSON diff。我目前设计了 preview 模式只读渲染数据模型不响应编辑用于分享展示和只读文档。后续如果想做多人协同编辑基础条件已经具备整个数据模型是可序列化的状态变化可以通过事件广播。这些扩展能顺利推进本质还是因为第一节说的那个原则一切都围绕数据模型转而不是围绕 DOM 转。7. 我做完一版之后想对你说的事如果你也准备做一个类似 diagram-design 的设计器我的建议是把重心放在数据模型和交互链路上而不是急着堆功能。我自己前面有一版写起来很快但越往后越难改返工的时间都花在理顺坐标和事件上。这一版最满意的决策有三点逻辑坐标和视口坐标严格分离所有状态都由数据模型驱动事件层只做捕获和分发不做任何业务决策。这三点在初期看起来像约束后期全是回报。比如想在网格上做吸附只需要在数据层做一次坐标取整不需要考虑控件怎么改想给节点加右键菜单只需要在事件层增加一个菜单不需要知道节点怎么渲染。设计器这类工具用户看得见的是画布真正决定体验上限的是底层的数据流动方式这句话在我做完这个项目之后感受格外深。回路过来看diagram-design 从一个临时解决团队画图痛点的内部项目慢慢变成了一个可以继续扩展的通用图形建模底座。我以后如果再做一个类似项目还是会从这里开始先把数据模型这个地基打牢再谈渲染和交互。