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

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

把一次性能修复沉淀成可复用的 headless 内核,adapter 契约清晰、边界克制,适合自研画布或 Konva/Fabric 宿主按需接入,而非追求大而全的编辑器。

读完 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。

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

架构四件套

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 换了个更干净的模型:

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' | 'contains'`
`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 次画/隐藏橡皮筋;`null` 隐藏

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

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

最小接入示意:

<span>import</span> { <span>SelectionDragEngine</span> } <span>from</span> <span>'selection-drag-engine'</span>

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

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

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

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

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

<span>test</span>(<span>'drag moves are coalesced into one preview per animation frame'</span>, <span>() =></span> {
  engine.<span>startDrag</span>({ <span>x</span>: <span>0</span>, <span>y</span>: <span>0</span> })
  engine.<span>moveDrag</span>({ <span>x</span>: <span>10</span>, <span>y</span>: <span>0</span> })
  engine.<span>moveDrag</span>({ <span>x</span>: <span>30</span>, <span>y</span>: <span>5</span> })

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

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

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

另一个测试钉住框选时机: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 补元信息——licensedescriptionrepositorykeywords;去掉 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 面板的观察变成可复现的数字。

系列回顾

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

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


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

觉得有用的话,求个 GitHub star——我会继续补 benchmark 和 DOM adapter。