自研拖拽式可视化设计器:Canvas架构、数据模型与性能优化实践
diagram-design 这个名字看起来很简单,但它背后是一个很完整的“拖拽式可视化设计器”的活。我做的这个项目,本质上是一个网页端的画布型图形编辑器,类似流程图绘制、拓扑图编排、架构图设计这一类场景的统一底座。你可以在画布上拖出节点、连线条、拖拽调整、缩放平移、框选多选,最终把整个画布内容序列化成一个 JSON 输出给后端保存或分享。它不绑定具体业务,只做图形编辑本身,业务方把自己的元素类型注册进来就能用,相当于一个前端基础设施。
这篇内容会更像是我做这个项目时候的复盘笔记。我会从整体架构到核心数据模型,再到 Canvas 渲染、交互算法、性能优化,一直到踩过的坑,尽量讲得完整、可落地。项目本身已经有几百个节点在画布上操作还算流畅的表现,下面这些经验基本都来自实测,不是纸面推演。
1. 项目定位与整体架构设计
1.1 为什么需要自研一个 diagram 组件
市面上其实有不少现成的方案,比如直接基于某些重型图编辑框架,或者用完全开源的流程图库,但我在实际的项目里发现几个很现实的困境:一是业务方需要的是“既像白板又像编辑器”的体验,节点样式、交互手势、数据结构都需要深度定制,通用库往往在边界情况上很别扭;二是内部有多个不同的产品线都要用“画布+节点+连线”这种模式,如果每条产品线各自引入一套第三方库,最后数据模型、交互习惯、主题风格全都不统一,维护成本很高。
所以 diagram-design 这个项目的定位就很清楚了:做一个内部通用的可视化设计器内核。它不是一个最终产品,而是一层能力底座。在这层底座之上,业务方把自己领域的节点类型、属性面板、校验规则填进来,就能快速搭出一个带可视化编辑能力的页面。这种“内核 + 插件”的思路,比让每一块业务自己造轮子要省太多事。
1.2 整体分层架构
我在做这个项目的时候,一开始就定了很明确的分层原则,因为图形编辑器这种项目最怕的就是代码拧成一团。画布状态、渲染逻辑、交互逻辑、业务数据如果全部揉在一起,改一个拖拽逻辑就可能牵扯出一堆 issue。所以我的分层是下面这样:
- 数据层:维护画布的所有节点、连线、视口变换参数,对外提供增删改查的操作接口,并负责序列化和反序列化。
- 渲染层:只读取数据层的数据,把节点、连线、背景网格画到 Canvas 上。它不管鼠标怎么点,只关心“把这个节点画成什么样”。
- 交互层:监听鼠标、触摸、键盘事件,计算命中检测、拖拽逻辑、框选逻辑,然后调用数据层的接口来变更数据。
- UI 层:工具栏、属性面板、右键菜单、缩略图等纯 UI 组件,通过事件总线与核心层通信。
分层的核心好处在于:渲染层不依赖任何 DOM 事件行为,数据层也不关心节点画出来长什么样。这样后续如果要把 Canvas 换掉,或者要在同一个数据模型上再渲染一个 SVG 版本,都是可行的。而且数据层是纯 TypeScript 对象,单元测试非常好写,连图编辑逻辑的复杂部分也能独立测试。
1.3 渲染与数据分离的代价
分层当然不是没有代价。比如“拖拽节点”这种操作,拆开来看其实是三步:交互层拿到鼠标偏移,数据层更新节点坐标,渲染层绘制新位置。每一步之间如果直接同步调用,看起来代码会很啰嗦。我的做法是在数据层做一个轻量的事件通知机制,节点属性一变,渲染层订阅到变更事件后就自动重绘。
另外还有一点很值得注意:千万不要让渲染层反向去改数据。开始的时候我觉得“既然我已经在渲染层算出了贝塞尔曲线的路径,不如直接把路径存回节点的某个字段里,省得后面再算”,但这个做法在数据量稍微起来之后会非常痛苦。因为曲线是由起点、终点和控制点算出来的,数据变了曲线自然变,你存一份冗余路径,就会导致数据不一致。渲染层永远只做“读数据 -> 绘制”,不做“写数据”这件事。
2. 数据模型设计与序列化协议
2.1 节点、连线、画布三件套
diagram 的数据模型其实特别传统,就是节点(Node)、连线(Edge)、画布(Canvas)三件套。但传统的东西要设计得顺手,细节都在字段定义上。我先说节点:
interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; data: Record<string, unknown>; }这个模型里,x、y 存的是节点的左上角坐标,单位是画布逻辑坐标,而不是屏幕像素。width 和 height 是节点在画布上的尺寸。这里的“画布逻辑坐标”非常关键,因为画布会有缩放和平移,如果直接把屏幕上看到的坐标存下来,一个缩放操作就会污染整个数据模型,后面再做任何计算都非常麻烦。逻辑坐标与屏幕坐标的换算全部交给渲染层,数据层永远只认逻辑坐标,这是整个数据模型设计里我最后悔没有更早坚持的一点。最初我偷懒直接存屏幕坐标,结果做缩放功能的时候差点重写所有历史数据。
data 字段比较灵活,它承载业务自定义的内容,比如流程图节点的分支条件、审批人配置等。节点宽高我允许零值,但实际渲染的时候要注意做个兜底。
连线的模型稍微复杂一点:
interface DiagramEdge { id: string; source: string; target: string; sourceAnchor?: string; targetAnchor?: string; points: Array<{ x: number; y: number }>; }source 和 target 是节点 ID,anchor 表示连的是哪个锚点(节点上可以配置多个连接点)。points 这个数组有两种用法:如果是基础模式,它可以是空数组或只有两个端点,渲染层用贝塞尔曲线补中间路径;如果是自由折线模式,它用来存用户手动拖出来的中间拐点。这个自由度其实挺重要,因为有些拓扑图场景中连线必须走特定路径,如果只允许画直线,业务方会很难受。
2.2 坐标系与视口变换
画布模型要保存一个视口(Viewport)信息:
interface Viewport { scale: number; x: number; y: number; }scale 是缩放比例,x、y 是画布原点的平移偏移量。这其实是 Canvas 里非常经典的模型。从屏幕坐标映射回逻辑坐标,只要做两步就够了:
const logicalX = (screenX - viewport.x) / viewport.scale; const logicalY = (screenY - viewport.y) / viewport.scale;我特意把这个封装成一个 viewport.ts 工具模块,里面只处理坐标变换,不掺杂任何业务。这样交互层在判断鼠标点中了哪个节点的时候,直接调这个模块换算坐标,再做命中检测就行。很多新人会忽略这个小模块,直接在交互层写各种重复的坐标变换代码,最后越写越乱。
2.3 序列化协议的几个隐藏问题
序列化是整个项目里最容易被低估的环节。表面上看就是 JSON.stringify 把整个画布结构存起来,但实际用起来有几个坑。第一是版本兼容,你的数据格式一定会有升级的一天,比如 v1 只有节点和连线,v2 加了分组信息。所以我从第一天开始就在 JSON 结构的最外层放了一个 version 字段,并且写了一个 migrate 函数,加载旧数据的时候做格式转换。
第二是循环引用问题。如果业务方在节点 data 里放了组件实例或函数引用,JSON.stringify 会直接抛异常。我在保存之前会做一个深度遍历,把非 JSON 安全的字段全部剥掉,并且给业务方提供 in/out 钩子,让它们自定义序列化逻辑。
第三是 ID 问题。一开始我用了简单的自增数字做节点 ID,但后来发现多人协同编辑时,A 端新增节点和 B 端新增节点很可能 ID 冲突。换成带时间戳的字符串 ID 之后,虽然数据体积变大了,但冲突概率小了很多。
存储结构上,我建议整个画布数据保持纯 JSON 对象而非数组包的嵌套太深。节点的线关系不内嵌在节点对象里,而是统一放在 edges 数组里,因为后续做图遍历、布局算法都会更方便。
3. 渲染引擎实现与 Canvas 核心绘制
3.1 为什么选 Canvas 而没有选 SVG
这个决策刚启动项目的时候就被反复问过。SVG 在节点量少的时候很好用,拖拽、事件绑定都天然支持,还能直接用 CSS 控制样式。但一旦画布里的节点达到几百个、连线上千条,SVG 会为每个图形生成独立的 DOM 节点,会出现两三个层面的问题:DOM 数量太多导致组件树更新很慢、事件监听的绑定数量膨胀、内存占用明显上升。Canvas 没有这些 DOM 节点,它就是在画布上画像素,性能瓶颈在绘制本身,而不是在 DOM 层级上。
这个项目的目标是做成“通用底座”,必须考虑高负载场景。比如某块业务做了一个 500 个节点的部署架构图,SVG 方案就已经开始卡了,Canvas 方案还能流畅缩放拖拽。所以我毫不犹豫选了 Canvas,并且额外做了一层 Canvas 2D 的渲染封装,把绘制节点、绘制连线的逻辑都收敛起来。
3.2 逻辑坐标与屏幕坐标的转换
说回渲染层最核心的坐标问题。Canvas 的坐标系是屏幕坐标系,一开始绘制的时候做一次矩阵变换即可:
ctx.save(); ctx.translate(viewport.x, viewport.y); ctx.scale(viewport.scale, viewport.scale); // 这里开始绘制所有元素,使用逻辑坐标 ctx.restore();这个 matrix 变换不仅用在主画布上,还用在缩略图预览上。只要统一了坐标变换,渲染层所有的绘制代码都可以直接使用逻辑坐标,不需要逐个节点去换算。有个看起来小但实际很影响体验的细节:缩放的时候如果只用 CSS transform 去缩放 Canvas 元素本身,会导致文字和线条边缘发虚,视觉上非常明显。正确做法是缩放 Canvas 的 width/height 属性,然后用 context.scale 来放大绘制内容,并处理高清屏的 devicePixelRatio。
3.3 节点绘制与状态描边
节点绘制我分了三层,第一层是节点底色和边框。这里要特别处理“选中态”,因为选中态如果直接改节点的 fillStyle,会导致节点原始颜色需要保存一份。我的做法是绘制时传入一个 extraState,选中态只是额外画一个高亮描边层,而不是修改节点数据。这样数据模型里永远是节点的真实样式,交互层负责叠加状态效果。
// 伪代码 const isSelected = selection.has(node.id); if (isSelected) { ctx.strokeStyle = '#409eff'; ctx.lineWidth = 2 / viewport.scale; ctx.strokeRect(node.x - 4, node.y - 4, node.width + 8, node.height + 8); }这个 2 / viewport.scale 的写法很关键,否则选中的描边会跟着缩放变得很粗或者很细。统一除以缩放倍数,才能让描边宽度在屏幕上看永远是 2 像素。这一点如果忘了除以 scale,在放大画布的时候选中框就会看起来特别粗壮。
第二层是节点内容绘制。不同类型的节点,内容差异很大。我做了一个 Renderer 注册表,每种节点类型对应一个绘制函数。绘制函数接收渲染上下文、节点数据、缩放倍数,由具体业务决定画什么。
第三层是文本绘制。Canvas 里文本换行是一个小痛点,fillText不会自动换行,需要自己计算宽度拆分。这个我封装了一个wrapText函数,逐字符累加测量宽度,超过节点宽度就在空格处换行。同时在缩放很小的时候,节点文字大小如果保持不变会互相遮挡,所以我在 scale 小于 0.5 时会把文字强行缩小甚至隐藏,提升整体观感。
3.4 连线的贝塞尔曲线与样式处理
连线默认使用的是三次贝塞尔曲线。对一条从 A 节点到 B 节点的边,我根据 A 的右边框中点和 B 的左边框中点作为起点终点,然后计算控制点,让曲线从水平方向平滑延伸。标准公式是这样的:
// 从 source 指向 target 的曲线路径 const dx = Math.abs(target.x - source.x) * 0.5; ctx.bezierCurveTo(source.x + dx, source.y, target.x - dx, target.y, target.x, target.y);这里有个经验,就是 dx 用两点横向距离的一半来控制曲线弧度。如果 dx 固定是常数,节点离得远的时候曲线会显得弧度很小很僵硬;离得近的时候又会弧度太大。用距离的一半就相对自然。
连接线还可以承载标签文字。标签需要放在曲线中点附近,但这个“中点”并不是曲线两端的算术平均,而是整条贝塞尔曲线的实际长度中点。计算方式比较复杂,我直接用采样法,把曲线分成 100 段,逐段累加长度,然后找到 50% 位置的点来放标签。这个方案虽然说起来不高级,但实测效果非常好,而且性能损耗可以忽略。
4. 核心交互操作与算法实现
4.1 拖拽移动与文本框选择
拖拽一个节点,涉及到的不是单纯改 x、y 坐标,而是要触发一系列联动:节点自身的选中态、连线的端点位置、其他节点是否要对齐吸附、历史记录的变更记录。我先说最普通的拖拽流程:
- 鼠标按下时,通过坐标转换算出当前逻辑坐标,然后遍历节点做矩形命中检测(也可以做像素级检测,但我用矩形足够了)。
- 命中的节点进入“拖拽候选”集合,如果它不在当前选中的集合里,应该先把选中集合切换为这个节点。
- 鼠标移动时,收集 dx、dy,更新所有选中节点的 x、y。
- 鼠标松开时,记录一次历史操作。
这里比较重要的一点是,拖拽过程中每帧都会更新节点的 x、y,如果每个 move 事件都去触发一次全量重绘和状态同步,整体会有卡顿感。我用的办法是 requestAnimationFrame 合并,move 事件只是修改坐标,真正绘制在下一帧统一做掉。实测下来,一次拖拽过程中可以节省一大半的重复计算。
文本框选中策略也很重要,除了点击空白区域取消全选之外,我还加了 Shift 多选这种常规手势。这块交互层代码尽量避免直接依赖 DOM 事件细节,而是抽象出一个 InteractionManager,让事件处理和意图判断分离。
4.2 框选算法与命中检测
框选是图形编辑器最常用的多选方式。实现思路很直接:鼠标拖拽出一个矩形区域,凡是被该矩形“完全包含”或“部分相交”的节点都被加入选中集合。我在这里选了“相交即选中”的策略,因为用户在操作一个密集图的节点时,往往只想选中边缘碰到的节点,而不是必须完整框住。
矩形相交检测的算法很便宜:
const isIntersect = (a, b) => { return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y); };框选的过程会结合 shift 键做差集/交集操作。有一个很小但是容易出错的细节:如果框选的起点在空白区域,而且鼠标拖动的距离很小,误触发的频率很高。我加了一个 5 像素的拖拽阈值判断,只有超过这个阈值才进入框选模式,否则判定为单纯的点击。
连带命中检测的还有节点边界调整,比如拖拽节点右下角来改变宽高。这个需要先判断鼠标是否落在 resize 手柄区域,再做宽高更新,并且至少保证最小宽高不能小于一个像素。
4.3 连线交互与临时连线
连线的交互比拖动节点要繁琐些。用户从一个节点的锚点拖出新线,鼠标移动过程中要实时绘制一条预览线;拖到另一个节点上时,要判断目标是否合法;如果目标合法就落成一条正式边,否则就把这条临时线取消掉。
这个流程里有个关键点:临时线的终点始终跟随鼠标位置,但它的格式要和正式边保持一样,都走坐标变换和贝塞尔绘制,否则预览效果和最终效果不一致,用户会觉得很突兀。
// 临时边数据结构 { sourceId: 'node-a', sourceAnchor: 'output', targetPoint: { x: mouseLogicalX, y: mouseLogicalY } }落到目标节点时,我会检测鼠标当前位置在目标节点矩形内,并且还要判断目标节点与源节点不是同一个,否则会创建自环。自环如果不做阻止,后面布局计算会很麻烦。
锚点本身的可视化也是一个细节。节点平时不显示锚点,只有当鼠标 hover 到节点附近时,锚点才出现。这样画面不至于一直很乱,也降低误触率。
4.4 对齐吸附与辅助线
节点拖拽时的吸附对齐,是“画布类编辑器”体验的高级感来源。移动节点时,我会在它的四条边和中心轴上,与其他节点的对应边做距离比较,如果距离小于某个阈值(通常 8 到 10 个逻辑像素),就让这条边对齐,并绘制辅助线,然后偏移量只保留非吸附方向的差值。
其实实现起来不复杂,就是把所有候选边收集起来做线性遍历:
// 求节点 A 在移动后是否与节点 B 的某个边对齐 const candidates = [ ['x', a.x - b.x], ['x', a.x + a.width - (b.x + b.width)], ['x', a.x + a.width / 2 - (b.x + b.width / 2)], ];吸附方向只在水平方向举例子,垂直方向完全对称。这套逻辑的复杂度在大规模节点时会上升到 O(n2),所以我会用简单网格索引先筛选邻近节点,只对附近一小撮节点做精确计算。
4.5 撤销与重做
撤销重做我采用了“快照 + 命令”的混合方案。因为节点结构比较简单,每次画布内容变更都直接保存整个节点树的快照,入栈快照数量限制在 50 个。操作频繁但节点量不大时非常稳,代码复杂度也低。
超过 50 个快照后,就弹出最旧的快照。有些操作比如“场景缩放”是不需要入历史栈的,否则用户会撤销出一个奇怪的缩放状态。历史记录只需要记录模型变更,不需要记录视口变换。
与快照方案并存的是一个“操作命令日志”,每个命令记录操作类型、节点 ID、变更前后的值。这类命令日志在后续做协同编辑、审计追踪时非常有价值,相当于业务层面的操作留痕。
5. 性能优化与常见问题排查
5.1 Canvas 分层渲染
前面提到过 Canvas 全量重绘的问题。节点量到 800 个以后,每帧都把所有节点和边全都重新绘制一遍,会使帧率明显下降。我的优化方法是把画布分成多个渲染层:
- 背景网格层:静态内容,只在缩放平移结束时重绘。
- 连线层:只重绘连线。
- 节点层:只重绘节点。
- 交互层:绘制框选矩形、辅助线、临时连线,每帧重绘但内容很轻量。
分层之后,拖动节点时只有节点层和交互层重绘,连线层和背景层保持不动。实测下来,拖动节点的帧率从十几帧直接升到六十帧,效果非常明显。分层也不是没有任何代价,层与层之间的坐标对齐必须确保一致,否则会出现连线穿过节点但节点已经移开的情况。
实现方式就是做几个独立的 Canvas 元素叠起来,position 用 absolute 定位,pointer-events 处理要小心:不能让上层 Canvas 拦截下层的事件。我的做法是只让最底层的画布接收鼠标事件,上层都设为 pointer-events: none,实际交互由事件处理层统一处理。
5.2 高清屏与模糊问题
在 Mac 的 Retina 屏幕和 Windows 的高分屏上,如果直接用 CSS 像素尺寸创建画布,绘制出来的线条会明显模糊。原因是 Canvas 的位图像素和物理像素不对等。处理方案是读取 devicePixelRatio,把 Canvas 的实际宽高乘以这个比例,然后用 context.scale 放大绘制空间:
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; ctx.scale(dpr, dpr);这样设置以后,1 个 CSS 像素对应 dpr 个物理像素,线条边缘会非常锐利。这里还有一个坑,就是缩放的时候如果不停修改 canvas.width,会清空画布内容,需要每次重新绘制。所以我在实现缩放时并不会真的去改 Canvas 的位图尺寸,而是只改 viewport 的 scale,让重绘逻辑去适配。
5.3 拖拽卡顿与事件节流
拖拽卡顿的根源,大部分时候不是 Canvas 绘制慢,而是鼠标事件触发的频率太高导致代码每帧都在做大量逻辑计算。鼠标 move 事件一秒可能要触发几十上百次,实际上屏幕刷新率只有 60fps。我做了两级的节流:第一级在事件监听层,用 requestAnimationFrame 收集最新的鼠标位置;第二级在数据更新层,把 move 事件里的 dx、dy 累积到 pendingDx 里,等 RAF 回调时一次性应用。
键盘事件也一样,按住方向键微调节点位置时不需要每按一次就立即重绘一次。可以合并到同一帧内处理。性能优化的原则是:交互层只记录意图,渲染层响应最终状态。绝不要一个事件触发一次全链路执行。
5.4 常见问题排查速查表
我整理了一下做这个项目期间频繁踩到的问题,非常有用:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 缩放后文字模糊 | 没有处理 devicePixelRatio 或直接用 CSS 缩放 | 重设 Canvas 物理尺寸,并按 dpr 缩放绘制上下文 |
| 拖拽节点时连线不跟随 | 连线渲染层没有订阅节点变更事件 | 节点更新时触发统一变更通知,连线层监听后重绘 |
| 框选区域反向选择出全选 | 矩形相交判断没有先做坐标标准化 | 先把起点终点做 min/max 规范化再检测相交 |
| 鼠标在子 Canvas 上无法拖动画布 | 上层 Canvas 拦截了 pointer 事件 | 上层采用 pointer-events: none |
| 历史记录撤销后视图混乱 | 撤销快照只恢复了数据没恢复视口或选中态 | 恢复时同步校验并重建视口与选中态 |
| 连接线颜色与节点状态不匹配 | 连线的样式缓存没有被清理 | 连线绘制免缓存,或节点变更时强制刷新连线缓存 |
5.5 缩放倍率边界处理
缩放不能无限大也不能无限小。我做了一个 0.1 到 4 的钳制范围,并且在缩放按钮的操作上配合视口中心做锚点缩放:鼠标滚轮缩放时以鼠标位置为中心缩放,而不是以画布中心缩放。这个细节会让手感明显好很多。
锚点缩放的公式,核心是保证缩放前后鼠标指向的逻辑坐标点不动:
const oldLogicalX = (mouseScreenX - viewport.x) / viewport.scale; const oldLogicalY = (mouseScreenY - viewport.y) / viewport.scale; // 设置新 scale viewport.scale = clamp(newScale); // 重新计算 viewport.x/y 让旧逻辑坐标点落在鼠标屏幕位置 viewport.x = mouseScreenX - oldLogicalX * viewport.scale; viewport.y = mouseScreenY - oldLogicalY * viewport.scale;这一步是几乎所有画布类编辑器的通用套路,但网上很多教程都没写清楚。如果没有做锚点缩放,滚轮缩放时内容会往一个方向乱飘,体验非常差。如果你们也想做类白板功能,这个公式直接抄就行。
6. 实操过程中踩过的坑与独家经验
6.1 不要在渲染层写业务判断
项目中途我犯过一个很典型的错误。有一个业务方要求“某些特殊类型的节点不允许被删除”,我当时图省事,直接在前端的删除按钮处判断了节点类型,但遗漏了键盘 Delete 删除和右键菜单删除两条路径。后来数据层补上了一个 beforeDelete 的钩子,才把三种删除路径全部统一到这里做校验。
图形编辑器的经验就是这样:所有规则尽量收敛在数据层,而不是散落在交互层各处。删除、更新、连线这些操作的审批逻辑都放到数据层做拦截,这样不管用户从哪个入口触发的操作,都能被统一拦下来。
6.2 事件处理的冒泡陷阱
多个 Canvas 分层叠加以后,事件处理的最外层一定要统一收集坐标。我曾经在每一层 Canvas 上都监听 mousedown、mousemove、mouseup,结果拖拽的时候事件在两三个层之间的顺序不一致,产生很多奇怪的 bug。后来统一做法是:只在最外层容器上监听 pointerdown、pointermove、pointerup,坐标统一基于容器坐标计算。事件绑定的数量也大大减少,代码清晰很多。
另外指针事件比鼠标事件更有优势,因为鼠标事件在触摸设备上不会原生触发,而指针事件同时覆盖触摸和鼠标。做这种通用型画布项目,可以直接用 pointer 事件家族,省去写两套监听。
6.3 数据模型先行比什么优先级都高
我最后想说的经验,是最核心的一条。项目刚开始的时候,因为产品原型还没有完全定下来,我因为急着做出可交互 Demo,直接先写了渲染和交互的代码,把数据模型定义为比较简单的“节点加坐标”。后来需要增加分组、连线锚点、属性面板联动时,所有代码都要一起改,浪费了不少时间。
如果重新做一次,我会花 30% 左右的时间先把数据模型和序列化协议彻底定义清楚:哪些字段是核心的、哪些是业务自定义的、哪些字段允许为空、ID 规则是什么、版本升级时的迁移策略是什么。数据模型一旦稳定下来,渲染和交互都只是顺着数据结构写代码,反而快很多。
6.4 最后分享一个小技巧
调试画布类项目时,Ctrl+Shift+D 这种浏览器自带的元素检查工具看不出 Canvas 里面有什么,调试起来非常痛苦。我给项目加了一个隐藏的 debug 面板,可以实时看到当前视口信息、鼠标逻辑坐标、选中的节点 ID、最后一个操作命令。这个面板对排查坐标变换和历史记录类问题帮助特别大。具体做法就是在全局 store 里维护一个 debug 对象,每次事件触发时更新,面板直接绑定渲染这个对象就行。做完这个功能后,我处理线上工单的时间至少缩短了三分之一。