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

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

三刀层层叠加、缺一不可,配一张三事件账本能说清每一刀砍的是事件数、单次成本还是 DOM 操作。适合被框选拖拽卡顿困扰的图编辑器开发者按图索骥。

X6 修框选拖拽卡顿只动了三处,每一处都值得抄 -----------------------

系列第 2 篇 · 修复篇(rAF 合帧、拖拽缓存、视觉冻结)。读完你能:对着新旧源码说清每一刀砍掉的是"事件次数"、"单次成本"还是"DOM 操作",并拿到一张"什么事件做什么"的三事件账本。

系列目录:README · 上一篇:四笔性能账 · 下一篇:读完 X6 修复源码,我把它抽成了开源库 selection-drag-engine

修复的总思路

上一篇把卡顿拆成四笔账,修复版(@antv/x6@3.1.7,继承自 issue 4823 的实验版本 2.19.0-beta.1)对应三刀:

回答的问题砍掉的账一句话
1 rAF 合帧事件能不能少执行?账单的"× 事件数"系数事件只记账,帧才干活
2 拖拽缓存单次计算能不能少算?账 2(查边)+ 账 1 的重复部分拖中不查图,图在按下时已查完
3 视觉冻结DOM 能不能不动?账 3 + 账 4拖中冻住框,松手一次对齐

三刀必须叠加——只做合帧,每帧内部还是全量重建;只做冻结,帧次数还是事件次数那么多。下面逐刀拆。

第一刀:rAF 合帧——三件套缺一不可

直觉与实现

我的直觉是:mousemove 里别直接干重活,攒着,requestAnimationFrame 里统一干一次。修复版正是这个形状:

<span>// 修复版 updateSelectedNodesPosition(3.1.7)</span>
<span>protected</span> <span>updateSelectedNodesPosition</span>(<span>offset</span>) {
  <span>if</span> (offset.<span>dx</span> === <span>0</span> && offset.<span>dy</span> === <span>0</span>) <span>return</span>

  <span>// ① 累加,不是覆盖</span>
  <span>if</span> (<span>this</span>.<span>dragPendingOffset</span>) {
    <span>this</span>.<span>dragPendingOffset</span>.<span>dx</span> += offset.<span>dx</span>
    <span>this</span>.<span>dragPendingOffset</span>.<span>dy</span> += offset.<span>dy</span>
  } <span>else</span> {
    <span>this</span>.<span>dragPendingOffset</span> = { <span>dx</span>: offset.<span>dx</span>, <span>dy</span>: offset.<span>dy</span> }
  }

  <span>// ② 单飞守卫:已有帧任务在飞就不再 schedule</span>
  <span>if</span> (<span>this</span>.<span>dragRafId</span> == <span>null</span>) {
    <span>this</span>.<span>dragRafId</span> = <span>requestAnimationFrame</span>(<span>() =></span> {
      <span>const</span> toApply = <span>this</span>.<span>dragPendingOffset</span> || { <span>dx</span>: <span>0</span>, <span>dy</span>: <span>0</span> }
      <span>this</span>.<span>dragPendingOffset</span> = <span>null</span>   <span>// ③ 取走就清空</span>
      <span>this</span>.<span>dragRafId</span> = <span>null</span>

      <span>this</span>.<span>applyDraggingPreview</span>(toApply)  <span>// 真正干活</span>
      <span>this</span>.<span>isDragging</span> = <span>true</span>
    })
  }
}

执行次数从"事件数(~120/秒)"降到"帧数(~60/秒)",砍一半,且与节点数无关。但能想到 rAF 和能上线之间,隔着三个精度点——这也是我学习时真实踩过的理解坑:

精度点 1:为什么必须 += 而不是 =

一帧 16ms 内 mousemove 会来 2~4 次,且每次报的位移是相对上一次的增量(参照点每事件更新)。假设一帧内来了 +5、+3、+4(应共移 12px):

用 `=`(覆盖)用 `+=`(累加)
3 次 move 后 pending4(丢了 5+3)12
本帧实际位移4px12px ✓

丢的位移追不回来:下一次增量是相对鼠标位置算的,不是相对节点落后的位置。表现为拖快了节点永远慢一截、松手时停在差一点点的位置。

顺带一提,另一种成立的设计是"mousemove 只记绝对坐标(覆盖安全),rAF 里算差值"。两种都对,铁律只有一条:从事件发生到帧执行,位移一分不能丢。X6 的 offset 已是增量输出,所以必须 +=

精度点 2:单飞守卫不是"锁",是去重

没有 if (dragRafId == null),一帧内 4 次 move 会排 4 个 rAF 回调,下一帧全部执行——第一个取走 pending,剩下 3 个空转或重复干活。一秒 120 个回调堆进帧里 = 合帧白写,还多付调度费。 它保证同一时刻天上最多飞一张"帧任务票"。

精度点 3:松手必须 flush——销户前先取款

rAF 要等下一帧才执行。如果 mouseup 恰好落在"pending 有数、rAF 还没到点"的窗口:

<span>// mouseup 处理(translating 分支)</span>
<span>if</span> (<span>this</span>.<span>dragPendingOffset</span>) {
  <span>const</span> toApply = <span>this</span>.<span>dragPendingOffset</span>
  <span>this</span>.<span>dragPendingOffset</span> = <span>null</span>
  <span>this</span>.<span>applyDraggingPreview</span>(toApply)   <span>// ★ 把攒的最后一笔补上</span>
}
<span>if</span> (<span>this</span>.<span>dragRafId</span> != <span>null</span>) {
  <span>cancelAnimationFrame</span>(<span>this</span>.<span>dragRafId</span>)  <span>// 再取消在飞的取件单</span>
  <span>this</span>.<span>dragRafId</span> = <span>null</span>
}

只 cancel 不 flush,最后 <16ms 的位移就永远丢了。入口用 += 防丢数,出口用 flush 防丢数,rAF 只是中间的运输层。

第二刀:拖拽缓存——"缓谁的"是一张便签

先回答那个最迷惑的问题:缓谁的缓存?

拖动过程中有三个答案:

  1. 哪些节点是选中的?——mousedown 定格,拖动中不变
  2. 它们连着哪些边?——拓扑不变,不变
  3. 哪些边需要手动移?——判据只看边自己的形状,不变

三个全是常量。但旧版每次 mousemove 都重新问一遍(上一篇的账 2)。缓存 = 把"只答一次的题"从每帧账单挪到 mousedown 一次性付清。

我学习时用的模型是"拖拽便签":

<span>// mousedown:写便签(唯一一次推导)</span>
<span>startTranslating</span>(<span>evt</span>) {
  <span>// ...记起点</span>
  <span>this</span>.<span>prepareTranslatingCache</span>()   <span>// ★ 名单在此定格</span>
}

<span>// 构建:selectedNodes + nodeIdSet + edgesToTranslate</span>
<span>prepareTranslatingCache</span>(<span></span>) {
  <span>const</span> selectedNodes = collection.<span>filter</span>(isNode)
  <span>const</span> nodeIdSet = <span>new</span> <span>Set</span>(ids)                    <span>// O(1) 查询用</span>
  <span>// 扫全图边——就这一次</span>
  graph.<span>getEdges</span>().<span>forEach</span>(<span>(<span>edge</span>) =></span> {
    <span>if</span> (连着选中节点 && <span>needsTranslate</span>(edge)) edgesToTranslate.<span>add</span>(edge)
  })
  <span>this</span>.<span>translatingCache</span> = { selectedNodes, nodeIdSet, edgesToTranslate }
}

<span>// mousemove:只读便签</span>
<span>const</span> cachedEdges = <span>this</span>.<span>translatingCache</span>?.<span>edgesToTranslate</span>
<span>if</span> (cachedEdges) {
  cachedEdges.<span>forEach</span>(<span>(<span>edge</span>) =></span> edgesToTranslate.<span>add</span>(edge))  <span>// 查表,零扫描</span>
}

<span>// mouseup:撕便签</span>
<span>this</span>.<span>translatingCache</span> = <span>null</span>   <span>// 下次拖拽重写——选中集和拓扑可能已变</span>

生命周期一行版:

mousedown: 写便签(1 次全量扫描)
mousemove: 看便签干活(0 次查询)× N 帧
mouseup:   撕便签

一个更隐蔽的细节:边还分两种

便签里的 edgesToTranslate 不是"所有连接边",而是被 needsTranslate 过滤过的子集:

<span>const</span> <span>needsTranslate</span> = (<span>edge</span>) =>
  edge.<span>getVertices</span>().<span>length</span> > <span>0</span> ||       <span>// 有折点</span>
  !edge.<span>getSourceCellId</span>() ||             <span>// 源端悬空</span>
  !edge.<span>getTargetCellId</span>()                <span>// 目标端悬空</span>

为什么 node-to-node 直线边不用动?因为边是端点驱动的:端点坐标来自两端节点,节点一动,直线自动重画。真正属于边自己的"裸坐标"只有两种:

  • 折点(vertices)[{x, y}, ...] 是死数据,节点动它不动——不搬,形状就烂(折点钉在原地,线被拉成奇怪的折角)。
  • 悬空端点:一端没挂节点,坐标同理是死的。

所以修复版对便签里的边调 edge.translate(dx, dy) 补刀,直线边根本不进来。这一步既是正确性(防折点畸变),也是性能(从"每帧移全部连接边"缩到"只移带裸坐标的少数边")。

对账

旧版 2.1.6修复版
查边次数(拖 2 秒 ≈120 帧 × 200 节点)~24000 次扫描mousedown **1 次**,拖中 **0 次**
边位移范围全部连接边,每帧仅带折点/悬空端的边,靠缓存

第三刀:视觉冻结——三层套娃,别只记住最里层

这刀我最初理解偏了,以为就是"用 translate3d 走合成层"。实际上是三层,从外到里:

① 冻住:拖动中 200 个高亮框的更新全部不干(isDragging 直接 return)
   ← 大头:砍掉"每帧 200 次 DOM"
② 冒充:用户要看到框跟着走 → 容器 1 次 transform 整体位移
   ← 正确性:不冒充,冻结就成了"框钉在原地"
③ 质地:那 1 次 transform 用 translate3d 而非 left/top
   ← 只优化"这 1 次"的写入质量(合成层免布局)

② 的机制:子元素 left 不变,屏幕位置却变了

高亮框不是散装的,全挂在一个容器下。子框的 left/top相对父容器算的:

子框屏幕坐标 = 父容器坐标 + 子框自己的 left

所以给容器加一行 transform: translate3d(dx, 0, 0),200 个子框的屏幕坐标全部 +dx——数学上等价于每个框都 +dx,但写入只发生一次。类比:200 个人站在船上,推船(1 次力),所有人相对岸都位移了,但每个人腿都没迈。

每个子框自己的 style.left 全程一个字没改——这就是"冒充":用户看到的"框跟着走了"是推船推出来的视觉效果,数据上框还停在拖前位置,直到松手才补真账。

代码形状

<span>// ① 开关:拖动中谁调我我都不干</span>
<span>updateSelectionBoxes</span>(<span></span>) {
  <span>if</span> (<span>this</span>.<span>isDragging</span>) <span>return</span>          <span>// ★ 冻结</span>
  <span>// 非拖动路径:16ms 节流后才刷新</span>
  <span>setTimeout</span>(<span>() =></span> <span>this</span>.<span>refreshSelectionBoxes</span>(), <span>16</span>)
}

<span>// ② 每帧就写这一行</span>
<span>updateContainerPosition</span>(<span>offset</span>) {
  <span>this</span>.<span>containerLocalOffsetX</span> += offset.<span>dx</span>
  <span>Dom</span>.<span>css</span>(<span>this</span>.<span>container</span>, <span>'transform'</span>,
    <span>`translate3d(<span>${x}</span>px, <span>${y}</span>px, 0)`</span>)   <span>// ③ 合成层</span>
}

<span>// 松手:同一帧内完成换防(中间不给浏览器插帧)</span>
<span>stopSelecting</span>(<span></span>) {
  <span>this</span>.<span>isDragging</span> = <span>false</span>                        <span>// 解冻</span>
  <span>flush</span>(...)                                      <span>// 第二刀的收尾</span>
  <span>cancelAnimationFrame</span>(...)
  <span>this</span>.<span>resetContainerPosition</span>()                  <span>// transform 清零</span>
  <span>this</span>.<span>translatingCache</span> = <span>null</span>                    <span>// 第三刀:撕便签</span>
  <span>this</span>.<span>repositionSelectionBoxesInPlace</span>()          <span>// 逐框写真实坐标</span>
}

顺序有讲究:先 flush 再清 transform(账没结不能销户);先解冻再对齐(否则对齐被 isDragging 拦截);清 transform 和写真坐标必须在同一次 JS 执行里(顺序反了会闪一帧错位)。三刀的出口全部汇在 mouseup 这一个函数里——这就是闭环。

三事件账本(全文核心表)

把三刀按事件归位,"什么事件做什么"一目了然:

事件次数(拖 2 秒)旧版 2.1.6 干什么修复版 3.1.7 干什么
**mousedown**1记起点记起点 **+写便签(全量扫描 1 次)**
**mousemove**~120位移+**每次查边**+**重建/挪 200 个框**只记账 `+=`
**rAF 回调**~60(不存在)读便签移节点/移折线边 **+容器 1 行 transform**
**mouseup**1stopBatchflush + cancel rAF + 清 transform + 撕便签 + 逐框对齐

一句话分工:mousemove 起头记账,rAF 干活,mouseup 结账。

版本注记

  • 性能分水岭在 2.1.6 → 2.19.0-beta.1(issue 4823 实验线),不是 beta → 3.1.7。
  • 3.1.7 保留全部三刀,增量是交互/正确性向:选区内部起拖、draggingPreviewMode(translate/geometry 双预览模式)、movingRouterFallback(拖动中临时降级边路由防抖动)等——不是性能必需
  • 3.1.7 还加了 getSelectingRect 等 API 重构,读源码时对不上行号属正常,对结构(三刀的形状)即可。

系列导航目录 · 上一篇:四笔性能账 · 下一篇:读完 X6 修复源码,我把它抽成了开源库 selection-drag-engine

三刀的思路不绑定 X6——下一篇把它抽成独立内核,附可直接接入的 adapter 契约。