系列第 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 后 pending | 4(丢了 5+3) | 12 |
| 本帧实际位移 | 4px | 12px ✓ |
丢的位移追不回来:下一次增量是相对鼠标位置算的,不是相对节点落后的位置。表现为拖快了节点永远慢一截、松手时停在差一点点的位置。
顺带一提,另一种成立的设计是"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 只是中间的运输层。
第二刀:拖拽缓存——"缓谁的"是一张便签
先回答那个最迷惑的问题:缓谁的缓存?
拖动过程中有三个答案:
- 哪些节点是选中的?——mousedown 定格,拖动中不变
- 它们连着哪些边?——拓扑不变,不变
- 哪些边需要手动移?——判据只看边自己的形状,不变
三个全是常量。但旧版每次 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** | 1 | stopBatch | flush + 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 契约。
三刀层层叠加、缺一不可,配一张三事件账本能说清每一刀砍的是事件数、单次成本还是 DOM 操作。适合被框选拖拽卡顿困扰的图编辑器开发者按图索骥。