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

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 只是中间的运输层。

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

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

拖动过程中有三个答案:

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

三个全是常量。但旧版每次 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 契约。

相关推荐
夏天要喝冰可乐1 小时前
Trae 每天自动签到:Serverless 定时任务完整复盘
前端·python
用户7783366132111 小时前
前端开发别拿生产 Key 刷数据:用 MSW 给搜索接口做 Mock
前端·api
光影少年1 小时前
RN启动流程(bundle加载→Bridge初始化→首屏渲染)
前端·react native·react.js
超人气王1 小时前
Agent 提示词工程:从「写 Prompt」到「设计行为控制系统」
前端·前端框架
恋猫de小郭1 小时前
Flutter Golden Tests:给 AI Agent 的 UI 测试系统
android·前端·flutter
特级业务专家1 小时前
X6 框选拖拽性能内幕:从 issue 4823 到开源内核(系列 3 篇)之三
前端
OpenTiny社区1 小时前
码道结合 OpenTiny 智能化 Skill,让页面会“听话”!
前端·人工智能·开源
kyriewen1 小时前
我手写了一版 React Compiler:AI 最常漏的 3 个 memo 场景
前端·javascript·ai编程
IT_陈寒1 小时前
Python的列表推导式差点让我加班到凌晨
前端·人工智能·后端