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

读完 X6 修复源码,我把它抽成了开源库 selection-drag-engine

系列第 3 篇 · 落地篇。读完你能:拿到一份可直接照着接的 adapter 九方法契约,理解 preview/commit 分离如何把"视觉冻结"做到极限,并带走一份亲测过的开源清单。

系列目录:README · 上一篇:X6 修框选拖拽卡顿只动了三处,每一处都值得抄

仓库:github.com/william-xue...(MIT,测试 4/4 通过)

为什么读完修复还要自己写一个

前两篇把 X6 的三层修复拆明白了。但如果你的画布不是 X6------自研 SVG 编辑器、Konva/Fabric 宿主、甚至 PPT 式 DOM 画布------这套思路就只停留在"懂了"。

于是有了 selection-drag-engine:渲染器无关的框选/多选拖拽内核。核心洞察来自 issue 4823,但代码不依赖 X6、DOM 或任何具体渲染器。

定位先说清楚,避免误用:

它是 headless selection kernel,不是编辑器。 只解决"几百上千个对象框选拖拽不卡";不做 resize/rotate/undo/edge routing/spatial index。

设计:三刀如何固化成接口

架构四件套

text 复制代码
SelectionDragEngine  纯状态机:rubberband / drag 两个会话的生命周期
RafScheduler         requestAnimationFrame 的适配层(可注入,测试可控)
SelectionAdapter     宿主注入的能力契约(本文重点)
geometry.js          矩形归一化、相交、包含判断

引擎自身零依赖(无 DOM 引用),全部渲染副作用通过 adapter 出去。这带来一个直接好处:测试不需要浏览器------注入一个 ManualScheduler 和一个 mock adapter,就能断言合帧行为。

三刀 → 三个 API 形状

X6 修复 engine 对应 形状
rAF 合帧 scheduleDragPreview / flushDragPreview move 只记 totalDelta,单飞 rAF,每帧最多一次 flush
拖拽缓存 createDragSnapshot pointerdown 冻结选中对象的可变副本
视觉冻结 beginDragPreview + commitDrag 比 X6 更彻底(见下)

preview/commit 分离:冻结的终极形态

X6 的冻结是"真身动 + 框冒充"------因为它的节点(SVG 真身)必须每帧实时渲染给用户看,高亮框只能用容器 transform 搭便车。

engine 换了个更干净的模型:

text 复制代码
pointerdown:  snapshot = 冻结选中对象副本
              beginDragPreview → 克隆选中元素到独立 preview 层
pointermove:  只累计 delta,rAF 合帧
每帧:         previewDrag → 只移动 preview 层的 transform
              ★ 真身一个像素都不动
pointerup:    commitDrag → delta 一次性写回真实数据 + 真实渲染
              endDragPreview → 销毁预览层

等价于:拖动中用户看到的所有运动都是"假身"演的,"真身"直到松手才跳到最终位置------而用户看不出差别,因为 preview 和最终位置在那一帧是连续的。

这把第 2 篇的账单砍到了理论下限:

  • 账 1 模型位移:拖动中 0 次 ,松手 1 次
  • 账 2 查边:engine 无边概念(边界外),宿主自处理
  • 账 3/4 高亮框 DOM:拖动中 0 次(真身都不动,框更不用动)

Adapter 契约:宿主只需实现这九个方法

引擎零依赖,接入 = 注入一个 adapter 对象。完整契约(参考实现 svg-selection-adapter.js 仅 136 行):

方法 调用时机 职责
hitTest(rect, { mode }) 仅 pointerup 返回框选命中 id;`mode: 'intersects'
setSelection(ids) 选择集变化 应用新选择集
getSelection() 可选 当前选择集;不实现则用引擎内部状态
createDragSnapshot(ids) pointerdown 冻结选中对象的可变副本(结构引擎不解释,只透传)
beginDragPreview(snapshot) pointerdown 后 建预览层(clone/遮罩/半透明,宿主自定)
previewDrag(snapshot, delta) 每帧最多 1 次 只更新预览层 transform;禁止写真实模型
commitDrag(snapshot, delta) 仅 pointerup delta 应用到真实数据与渲染;全拖拽唯一一次
endDragPreview(snapshot) pointerup / cancel 销毁预览层、恢复源对象状态
`renderRubberband(rect null)` 框选期间每帧最多 1 次

三条实现守则(违反任何一条,性能保证作废)

  1. previewDrag 里只改 transform(或等价廉价属性),不遍历业务数据、不发业务事件。
  2. 所有真实写入收敛到 commitDrag------这是"真身拖动中不动"的全部含义。
  3. hitTest 只在抬起时跑 ------O(n) 只付一次;引擎已经在 endRubberband 里保证了时机。

最小接入示意:

js 复制代码
import { SelectionDragEngine } from 'selection-drag-engine'

const engine = new SelectionDragEngine({
  adapter: myAdapter,                 // 实现上表九方法
  onMetricsChange: (m) => panel.update(m),  // 可选:指标回调
})

svg.addEventListener('pointerdown', (e) => {
  const p = myAdapter.getLocalPoint(e)
  if (命中选中对象) engine.startDrag(p)
  else engine.startRubberband(p)
})
svg.addEventListener('pointermove', (e) => {
  engine.state === 'drag' ? engine.moveDrag(p) : engine.moveRubberband(p)
})
// pointerup → engine.endDrag(p) / engine.endRubberband(p)

测试与 demo:怎么证明它真的合帧

测试策略:注入确定性时钟

rAF 的麻烦是"不确定什么时候执行"。测试里注入 ManualScheduler,把帧的主动权拿回来:

js 复制代码
test('drag moves are coalesced into one preview per animation frame', () => {
  engine.startDrag({ x: 0, y: 0 })
  engine.moveDrag({ x: 10, y: 0 })
  engine.moveDrag({ x: 30, y: 5 })

  assert.equal(scheduler.requestCount, 1)      // 两次 move 只 schedule 了 1 个帧任务
  assert.equal(adapter.calls.previewDrag.length, 0)  // flush 前 0 次渲染

  scheduler.flush()                             // 手动"到帧了"
  assert.equal(adapter.calls.previewDrag.length, 1)  // 恰好 1 次
  assert.deepEqual(adapter.calls.previewDrag[0].delta, { dx: 30, dy: 5 })  // 两次增量已合并

  engine.endDrag({ x: 40, y: 10 })
  assert.equal(adapter.calls.commitDrag.length, 1)   // 真身只提交 1 次
})

另一个测试钉住框选时机:move 多次 hitTest 调用数为 0,endRubberband 时恰好 1 次------"命中只在抬起时算"从承诺变成断言。

demo 指标面板

demos/svg-selection-demo.html 渲染 1000 个 SVG 对象,面板实时显示七个指标,核心三个:

  • pointerMove vs rafFlush:预期 ~120 vs ~60,合帧生效的直接证据
  • commit:整个拖拽恒为 1,preview/commit 分离的证据
  • avg ms / max ms:每帧 flush 耗时

开源清单(亲测,含踩坑)

从"能跑的本地代码"到"敢放 GitHub 的公开仓库",做了这几件事:

  1. LICENSE(MIT)------没有 LICENSE = 默认保留所有权利,别人看可以、合法用不了。这是硬阻塞。
  2. 密钥扫描 ------grep api_key|secret|token|ghp_|sk-|PRIVATE KEY 等模式,确认无残留。
  3. .gitignore ------node_modules/、.vite/、*.log、.DS_Store。
  4. package.json 补元信息 ------license、description、repository、keywords;去掉 private: true。
  5. README 写清 adapter 契约 + 边界------别人能不能接起来,全看这张表;"不做什么"和"做什么"一样重要,能过滤错误期待。
  6. 独立仓库 ------如果代码在 monorepo/工作区里,git init 到子目录开独立仓,别把整个工作区(可能含公司代码、笔记)推上去。

一个真实的坑:我在嵌套目录里 git init 时连续搞错工作目录,把提交打到了外层仓库------靠"确认 git rev-parse --show-toplevel 是不是目标目录"纠正。开源前最后一道检查:git log 的根、remote 的 URL、仓库页面的文件列表,三者必须指向同一处。

边界与下一步

现在有:框选、多选、合帧、snapshot/preview/commit、指标回调、SVG adapter 参考实现、4 项单测、1000 对象 demo。

明确没有(按需自加,不预先抽象):

  • DOM/Canvas adapter------契约已验证可移植(SVG adapter 就是证明),等真实需求再写
  • spatial index------hitTest 当前 O(n),百~千级够用;万级对象再上四叉树
  • resize/rotate/undo/edge------这是往完整选择系统走,超出 kernel 定位

下一步最值的一件事:benchmark 脚本(100/1000/5000 对象 × 120 帧的 flush 耗时),把 demo 面板的观察变成可复现的数字。

系列回顾

text 复制代码
第1篇 病灶:mousemove × 五层嵌套 × 四笔账 = 高频事件 × 每次全量
第2篇 修复:合帧砍次数、缓存砍计算、冻结砍DOM ------ 三刀叠加,mouseup 闭环
第3篇 落地:三刀固化为 状态机 + adapter 契约 + preview/commit 分离

性能问题的通用方法论其实就一句:把每个计算放回它的事件循环里,问它"这次的答案,下次会变吗?"不变的搬去低频事件,高频事件里只留记账。 X6 用三行结构回答了这句话,engine 把它编译成了接口。


系列导航 :目录 · 上一篇:X6 修框选拖拽卡顿只动了三处,每一处都值得抄 · 仓库:selection-drag-engine

觉得有用的话,求个 GitHub star------我会继续补 benchmark 和 DOM 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的列表推导式差点让我加班到凌晨
前端·人工智能·后端