系列第 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` 隐藏 |
三条实现守则(违反任何一条,性能保证作废)
previewDrag里只改 transform(或等价廉价属性),不遍历业务数据、不发业务事件。- 所有真实写入收敛到
commitDrag——这是"真身拖动中不动"的全部含义。 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 对象,面板实时显示七个指标,核心三个:
pointerMovevsrafFlush:预期 ~120 vs ~60,合帧生效的直接证据commit:整个拖拽恒为 1,preview/commit 分离的证据avg ms / max ms:每帧 flush 耗时
开源清单(亲测,含踩坑)
从"能跑的本地代码"到"敢放 GitHub 的公开仓库",做了这几件事:
- LICENSE(MIT)——没有 LICENSE = 默认保留所有权利,别人看可以、合法用不了。这是硬阻塞。
- 密钥扫描——grep
api_key|secret|token|ghp_|sk-|PRIVATE KEY等模式,确认无残留。 .gitignore——node_modules/、.vite/、*.log、.DS_Store。- package.json 补元信息——
license、description、repository、keywords;去掉private: true。 - README 写清 adapter 契约 + 边界——别人能不能接起来,全看这张表;"不做什么"和"做什么"一样重要,能过滤错误期待。
- 独立仓库——如果代码在 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。
把一次性能修复沉淀成可复用的 headless 内核,adapter 契约清晰、边界克制,适合自研画布或 Konva/Fabric 宿主按需接入,而非追求大而全的编辑器。