系列第 1 篇 · 病灶篇。读完你能:沿着一次
mousemove的调用链,把"框选拖拽卡顿"拆成四笔可数的账,并说出为什么本地测 20 个节点永远测不出来。系列目录:README · 下一篇:X6 修框选拖拽卡顿只动了三处,每一处都值得抄
一个真实的场景
电力一次接线图、SCADA 画面、工厂布局图这类工业前端,画布上几百上千个设备符号是常态。用户框选一片区域,按住拖走——20 个节点丝般顺滑,200 个节点开始发粘,500 个节点几乎不可用。
这不是猜想,是 AntV X6 社区真实 issue(issue 4823)的形态。我最初以为答案会藏在"节点渲染"里——图形多了当然慢。但把修复前后的 selection 插件源码(@antv/x6-plugin-selection@2.1.6 vs @antv/x6@3.1.7)并排读完之后,发现真正的病灶离渲染很远,就在最不起眼的一行事件处理里。
这篇文章讲清楚病在哪。怎么修,下一篇讲。
第一步:校准心智模型——这是两个手势,不是一个
排查前我先复述了自己的理解,结果发现有一处根本性偏差:我把"框选"和"拖走"揉成了一段流程。实际上代码里它们是两个独立手势,用一个字段区分:
<span>action</span>: <span>'selecting'</span> <span>// 手势A:框选(空白处按下,拖出橡皮筋)</span>
<span>action</span>: <span>'translating'</span> <span>// 手势B:拖走(在选中框上按下,移动整群节点)</span>
配套的鼠标三连也容易记错名字——不是 mouseover,而是:
| 事件 | 触发频率 | 在这个流程里的角色 |
|---|---|---|
| `mousedown` | 1 次/手势 | 记起点 |
| **`mousemove`** | **60~120+ 次/秒** | **干活的主力,也是病灶所在** |
| `mouseup` | 1 次/手势 | 收尾结账 |
关键区分:
- 手势 A(框选):
mousemove只负责把橡皮筋画大,节点不动;"哪些节点在框里"是mouseup时一次性 hit test 算出来的。这一步 O(n) 扫一遍完全便宜,不是性能问题。 - 手势 B(拖走):
mousemove里把整群选中节点全部移动。频率高、单次工作量随节点数线性涨——四笔账全长在这条链上。
先把两个手势分清,后面的账才算得明白。
旧版的一次 mousemove:五层嵌套,逐层看
修复前的版本(2.1.6)里,每次 mousemove 的完整调用链是这样的:
document 事件 mousemove
└→ adjustSelection(evt) // 事件入口
└→ case 'translating': // 手势B分支
└→ updateSelectedNodesPosition(offset) // 拿到位移
└→ translateSelectedNodes(dx, dy) // ★ 真正干活
├→ 遍历每个选中 cell
├→ 重算子孙排除表
├→ ★ getConnectedEdges(cell) // 每个节点查一次连接边
└→ edge.translate(...) // 移动相关边
└→ updateSelectionBoxes() // 刷高亮框
代码本体(2.1.6,updateSelectedNodesPosition)直白得惊人:
<span>protected</span> <span>updateSelectedNodesPosition</span>(<span>offset</span>) {
<span>const</span> { dx, dy } = offset
<span>if</span> (dx || dy) {
<span>this</span>.<span>translateSelectedNodes</span>(dx, dy) <span>// ← 立刻全量执行,没有任何"攒一攒"</span>
<span>// ...随后同步刷新高亮框</span>
}
}
没有 rAF、没有缓存、没有延迟——事件到达即全量执行。这是最自然的写法,本地跑 20 个节点屁事没有,X6 维护者最初也是这么写的。问题在于这笔账会随两个变量相乘放大:选中节点数 × 事件频率。
四笔账:卡顿的完整分解
把上面的调用链按开销类型拆开,一次 mousemove(假设选中 200 节点)的账单是:
账 1:模型层全量位移
translateSelectedNodes 对每个选中节点调 cell.translate()。200 个节点 = 200 次坐标写入,且每次 translate 都会派发事件(node:change:position 等),选中集合上还挂着监听器。
量级:O(n) 模型写 × 每事件。
账 2:逐节点现场查边(最隐蔽的一笔)
同一函数里,每个节点都要问一次图:
<span>this</span>.<span>graph</span>.<span>model</span>.<span>getConnectedEdges</span>(cell) <span>// "你牵着哪些边?"</span>
.<span>forEach</span>(<span>(<span>edge</span>) =></span> { ... edge.<span>translate</span>(...) })
getConnectedEdges 要在模型的边集合里扫描匹配。200 节点 = 每帧 200 次扫描,而且——这是关键——从按下鼠标的那一刻到松手,答案一次都没变过。选中名单没变、接线关系没变,这道题每帧重答一遍,纯属浪费。
量级:O(n × E) 现场查询,零缓存。
账 3:高亮框 DOM 重建
每个选中节点外面有一个高亮框(DOM div)。旧版刷框的实现是销毁重建:
<span>confirmUpdate</span>(<span></span>) {
<span>for</span> (<span>const</span> box <span>of</span> <span>this</span>.<span>$boxes</span>) {
<span>Dom</span>.<span>remove</span>(box) <span>// 撕掉旧框</span>
<span>// getBBox 重新量位置</span>
<span>document</span>.<span>createElement</span>(<span>'div'</span>) <span>// 重新裁一张</span>
<span>// 设样式、append</span>
}
}
200 个节点 = 每帧删 200 个 div、建 200 个 div,每个还要问一次几何。DOM 删增是浏览器最贵的操作之一(引发 layout 树大改),而且触发它的入口不止一条。
量级:O(n) DOM 创建销毁 × 每帧。
账 4:布局级样式写入
走不到重建分支的路径也好不到哪去:逐个读改框和容器的 left/top。改布局属性会触发布局流水线(layout → paint),而不是走 GPU 合成层。
量级:O(n) 样式读写 × 每帧,每次可能触发强制同步计算。
账单汇总
| 账目 | 旧版每次 mousemove | 放大系数 |
|---|---|---|
| 1 模型位移 | O(n) 写入 + 事件派发 | × 事件数 |
| 2 查连接边 | O(n × E) 扫描,无缓存 | × 事件数 |
| 3 高亮框 | O(n) DOM 删建 | × 事件数 |
| 4 样式写入 | O(n) left/top,触发布局 | × 事件数 |
一秒 60~120 次事件 × 200 节点 × 四笔账——单帧预算 16ms 被轻松打爆,这就是你手指下面那层"粘"。
一句话:卡的根源不是"节点多",是"高频事件 × 每次全量"。
为什么这类 bug 本地永远测不出来
值得单独说一下,因为它是这类性能问题最阴的地方:
慢的写法和快的写法,在小数据量下没有区别。 20 个节点时,四笔账加起来可能只有零点几毫秒,两种实现都流畅。账单是 n × 事件频率 的乘积——n 小的时候乘不出来。
所以它只在客户的真实数据(几百上千设备)下爆发。等你复现的时候,问题已经到用户手里了。这类 bug 的防线不是"跑一遍 demo",而是读代码时就按公式估算:把 O(n) 的工作放进高频事件处理器之前,先问一句"n 到 1000 时这笔账是多少"。
病灶确认清单
到这一步,四条结论可以直接带进下一篇:
- ✅ 框选(selecting)不是问题——命中测试只在 mouseup 做一次。
- ✅ 病灶全在拖走(translating)的
mousemove链上。 - ✅ 四笔账:模型全量位移、逐节点查边、高亮框 DOM 重建、布局级写入。
- ✅ 修复的思考方向必然是回答三个问题——事件能不能少执行?(合帧)单次计算能不能少算?(缓存)DOM 能不能不动?(冻结)
这三个问题就是下一篇的三刀。
系列导航:目录 · 本文为第 1 篇 · 下一篇:X6 修框选拖拽卡顿只动了三处,每一处都值得抄
如果这篇帮你定位了问题,欢迎在评论区留下你的画布项目规模——我想收集真实场景下的 n 值。
把性能问题拆成可数的四笔账,并给出「高频事件 × 每次全量」的估算思路,适合做图编辑器、工业组态类前端的人在大规模数据上线前先读一遍。