X6 框选拖拽性能内幕:从 issue 4823 到开源内核(系列 3 篇)之一

文章来源声明: 原文作者:特级业务专家; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688571406654062638; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把性能问题拆成可数的四笔账,并给出「高频事件 × 每次全量」的估算思路,适合做图编辑器、工业组态类前端的人在大规模数据上线前先读一遍。

X6 框选 200 个节点就卡死?翻完 1100 行源码,病灶是四笔账 -----------------------------------

系列第 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 时这笔账是多少"。

病灶确认清单

到这一步,四条结论可以直接带进下一篇:

  1. ✅ 框选(selecting)不是问题——命中测试只在 mouseup 做一次。
  2. ✅ 病灶全在拖走(translating)的 mousemove 链上。
  3. ✅ 四笔账:模型全量位移、逐节点查边、高亮框 DOM 重建、布局级写入。
  4. ✅ 修复的思考方向必然是回答三个问题——事件能不能少执行?(合帧)单次计算能不能少算?(缓存)DOM 能不能不动?(冻结)

这三个问题就是下一篇的三刀。


系列导航目录 · 本文为第 1 篇 · 下一篇:X6 修框选拖拽卡顿只动了三处,每一处都值得抄

如果这篇帮你定位了问题,欢迎在评论区留下你的画布项目规模——我想收集真实场景下的 n 值。