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 里统一干一次。修复版正是这个形状:
ts
// 修复版 updateSelectedNodesPosition(3.1.7)
protected updateSelectedNodesPosition(offset) {
if (offset.dx === 0 && offset.dy === 0) return
// ① 累加,不是覆盖
if (this.dragPendingOffset) {
this.dragPendingOffset.dx += offset.dx
this.dragPendingOffset.dy += offset.dy
} else {
this.dragPendingOffset = { dx: offset.dx, dy: offset.dy }
}
// ② 单飞守卫:已有帧任务在飞就不再 schedule
if (this.dragRafId == null) {
this.dragRafId = requestAnimationFrame(() => {
const toApply = this.dragPendingOffset || { dx: 0, dy: 0 }
this.dragPendingOffset = null // ③ 取走就清空
this.dragRafId = null
this.applyDraggingPreview(toApply) // 真正干活
this.isDragging = true
})
}
}
执行次数从"事件数(~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 还没到点"的窗口:
ts
// mouseup 处理(translating 分支)
if (this.dragPendingOffset) {
const toApply = this.dragPendingOffset
this.dragPendingOffset = null
this.applyDraggingPreview(toApply) // ★ 把攒的最后一笔补上
}
if (this.dragRafId != null) {
cancelAnimationFrame(this.dragRafId) // 再取消在飞的取件单
this.dragRafId = null
}
只 cancel 不 flush,最后 <16ms 的位移就永远丢了。入口用 += 防丢数,出口用 flush 防丢数,rAF 只是中间的运输层。
第二刀:拖拽缓存------"缓谁的"是一张便签
先回答那个最迷惑的问题:缓谁的缓存?
拖动过程中有三个答案:
- 哪些节点是选中的?------mousedown 定格,拖动中不变
- 它们连着哪些边?------拓扑不变,不变
- 哪些边需要手动移?------判据只看边自己的形状,不变
三个全是常量。但旧版每次 mousemove 都重新问一遍(上一篇的账 2)。缓存 = 把"只答一次的题"从每帧账单挪到 mousedown 一次性付清。
我学习时用的模型是"拖拽便签":
ts
// mousedown:写便签(唯一一次推导)
startTranslating(evt) {
// ...记起点
this.prepareTranslatingCache() // ★ 名单在此定格
}
// 构建:selectedNodes + nodeIdSet + edgesToTranslate
prepareTranslatingCache() {
const selectedNodes = collection.filter(isNode)
const nodeIdSet = new Set(ids) // O(1) 查询用
// 扫全图边------就这一次
graph.getEdges().forEach((edge) => {
if (连着选中节点 && needsTranslate(edge)) edgesToTranslate.add(edge)
})
this.translatingCache = { selectedNodes, nodeIdSet, edgesToTranslate }
}
// mousemove:只读便签
const cachedEdges = this.translatingCache?.edgesToTranslate
if (cachedEdges) {
cachedEdges.forEach((edge) => edgesToTranslate.add(edge)) // 查表,零扫描
}
// mouseup:撕便签
this.translatingCache = null // 下次拖拽重写------选中集和拓扑可能已变
生命周期一行版:
text
mousedown: 写便签(1 次全量扫描)
mousemove: 看便签干活(0 次查询)× N 帧
mouseup: 撕便签
一个更隐蔽的细节:边还分两种
便签里的 edgesToTranslate 不是"所有连接边",而是被 needsTranslate 过滤过的子集:
ts
const needsTranslate = (edge) =>
edge.getVertices().length > 0 || // 有折点
!edge.getSourceCellId() || // 源端悬空
!edge.getTargetCellId() // 目标端悬空
为什么 node-to-node 直线边不用动?因为边是端点驱动的:端点坐标来自两端节点,节点一动,直线自动重画。真正属于边自己的"裸坐标"只有两种:
- 折点(vertices) :
[{x, y}, ...]是死数据,节点动它不动------不搬,形状就烂(折点钉在原地,线被拉成奇怪的折角)。 - 悬空端点:一端没挂节点,坐标同理是死的。
所以修复版对便签里的边调 edge.translate(dx, dy) 补刀,直线边根本不进来。这一步既是正确性(防折点畸变),也是性能(从"每帧移全部连接边"缩到"只移带裸坐标的少数边")。
对账
| 旧版 2.1.6 | 修复版 | |
|---|---|---|
| 查边次数(拖 2 秒 ≈120 帧 × 200 节点) | ~24000 次扫描 | mousedown 1 次 ,拖中 0 次 |
| 边位移范围 | 全部连接边,每帧 | 仅带折点/悬空端的边,靠缓存 |
第三刀:视觉冻结------三层套娃,别只记住最里层
这刀我最初理解偏了,以为就是"用 translate3d 走合成层"。实际上是三层,从外到里:
text
① 冻住:拖动中 200 个高亮框的更新全部不干(isDragging 直接 return)
← 大头:砍掉"每帧 200 次 DOM"
② 冒充:用户要看到框跟着走 → 容器 1 次 transform 整体位移
← 正确性:不冒充,冻结就成了"框钉在原地"
③ 质地:那 1 次 transform 用 translate3d 而非 left/top
← 只优化"这 1 次"的写入质量(合成层免布局)
② 的机制:子元素 left 不变,屏幕位置却变了
高亮框不是散装的,全挂在一个容器下。子框的 left/top 是相对父容器算的:
text
子框屏幕坐标 = 父容器坐标 + 子框自己的 left
所以给容器加一行 transform: translate3d(dx, 0, 0),200 个子框的屏幕坐标全部 +dx------数学上等价于每个框都 +dx,但写入只发生一次。类比:200 个人站在船上,推船(1 次力),所有人相对岸都位移了,但每个人腿都没迈。
每个子框自己的 style.left 全程一个字没改------这就是"冒充":用户看到的"框跟着走了"是推船推出来的视觉效果,数据上框还停在拖前位置,直到松手才补真账。
代码形状
ts
// ① 开关:拖动中谁调我我都不干
updateSelectionBoxes() {
if (this.isDragging) return // ★ 冻结
// 非拖动路径:16ms 节流后才刷新
setTimeout(() => this.refreshSelectionBoxes(), 16)
}
// ② 每帧就写这一行
updateContainerPosition(offset) {
this.containerLocalOffsetX += offset.dx
Dom.css(this.container, 'transform',
`translate3d(${x}px, ${y}px, 0)`) // ③ 合成层
}
// 松手:同一帧内完成换防(中间不给浏览器插帧)
stopSelecting() {
this.isDragging = false // 解冻
flush(...) // 第二刀的收尾
cancelAnimationFrame(...)
this.resetContainerPosition() // transform 清零
this.translatingCache = null // 第三刀:撕便签
this.repositionSelectionBoxesInPlace() // 逐框写真实坐标
}
顺序有讲究 :先 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 契约。