- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
本文基于 Motion 仓库中的实现计划 plans/028-frameloop-same-step-cancel.md 展开,围绕
cancelFrame在“同一帧、同一 Step”内取消回调时失效一次的调度器缺陷,完整讲解其根因、双缓冲队列机制、一行代码的修复方案、先写失败测试的回归策略,以及整套验证命令与影响面评估流程。读完本文,你将掌握 Motion 帧循环调度器的内部语义,并能独立复现、修复与验证这一类调度时序类 Bug。
一、问题概述:一个"取消后仍会执行一次"的调度缺陷
在 Motion 的帧循环(frameloop)中,cancelFrame(callback)是撤销frame.*系列调度 API(如frame.update、frame.read、frame.render)所安排任务的唯一入口。然而此前的实现存在一个边界缺陷:
cancelFrame(callback)只会把回调从每个 Step 的下一帧队列(nextFrame)中移除。当一个 Step 正在执行(processing)时,其任务已经在这一帧开始时被整体交换进了当前帧集合(thisFrame)—— 因此,如果某个回调是被同一 Step 中更早执行的回调取消的,它仍会在被取消之后多执行一次。
这个缺陷的典型触发场景如下(见计划文档 “Why this matters” 一节):
- 动画在
updateStep 上以keepAlive 任务的形式逐帧 tick,对应源码 packages/motion-dom/src/animation/drivers/frame.ts 中的frameloopDriver:start: (keepAlive = true) => frame.update(passTimestamp, keepAlive)stop: () => cancelFrame(passTimestamp)
- 若动画 A 的
onUpdate回调里调用了动画 B 的stop(),而 B 的 tick 恰好排在同一帧update传递(pass)的更靠后位置,那么 B 仍然会在这一帧 tick 一次,并在已被停止之后向它的 motion value 写入一个值——即一次"一帧过期写入",可能覆盖停止方刚刚设置的值。 - 同样的现象适用于任何在
frame.*上调度、又在同一 Step 内部被取消的工作:手势(gestures)、useAnimationFrame消费者停止动画等场景都会中招。
该 Bug 在计划中的风险评级为MED(对调度器语义的微妙改动,通过动画/投影系统影响面较广),优先级P2,工作量S(极小,修复仅一行)。
二、帧循环调度器原理:双缓冲队列与 Step 交换
要理解这个 Bug,必须先弄清 Motion 帧循环调度器的数据结构与执行流程。核心实现在 packages/motion-dom/src/frameloop/render-step.ts。
2.1 双缓冲Set:thisFrame与nextFrame
// createRenderStep 内部(render-step.ts) let thisFrame = new Set<Process>() let nextFrame = new Set<Process>()源码注释明确说明了两组队列的设计动机:复用两个Set以避免在运行多帧后触发 GC。当前帧执行的是thisFrame中的任务,下一帧要执行的任务先进入nextFrame。
2.2process():交换、执行、清理
process: (frameData) => { latestFrameData = frameData // 若正在处理中又被触发(如 flushSync 场景),标记 flushNextFrame 延迟到帧末 if (isProcessing) { flushNextFrame = true return } isProcessing = true // 交换 thisFrame 与 nextFrame,避免 GC const prevFrame = thisFrame thisFrame = nextFrame nextFrame = prevFrame // 执行本帧任务 thisFrame.forEach(triggerCallback) // 清理,避免帧循环长时间不运行时产生内存泄漏 thisFrame.clear() isProcessing = false if (flushNextFrame) { flushNextFrame = false step.process(frameData) } }关键在于:交换发生在process()开始时。一旦某个 Step 开始执行,任务就已经从nextFrame整体搬入了thisFrame。此时若想取消一个"正在这一帧排队"的回调,只删除nextFrame是够不着的——它就在thisFrame里。
2.3triggerCallback与 keepAlive 重排
function triggerCallback(callback: Process) { if (toKeepAlive.has(callback)) { nextFrame.add(callback) runNextFrame() } callback(latestFrameData) }keepAlive 任务(例如动画的逐帧 tick)在执行前会先被重新排入nextFrame,并确保下一帧继续运行。这一点对理解修复边界很重要:自取消(回调在自己执行过程中取消自己)不受此 Bug 影响——因为 keepAlive 任务在callback()执行之前就已重排进nextFrame,而旧的cancel实现恰好就是从nextFrame中删除的。
2.4 八个 Step 与批处理器
packages/motion-dom/src/frameloop/order.ts 定义了八个 Step 的执行顺序:
export const stepsOrder: StepId[] = [ "setup", // Compute "read", // Read "resolveKeyframes", // Write/Read/Write/Read "preUpdate", // Compute "update", // Compute "preRender", // Compute "render", // Write "postRender", // Compute ]packages/motion-dom/src/frameloop/batcher.ts 中的createRenderBatcher为每个 Step 创建一个createRenderStep,并在processBatch中按此顺序展开(unrolled)逐 Step 调用process():
setup.process(state) read.process(state) resolveKeyframes.process(state) preUpdate.process(state) update.process(state) preRender.process(state) render.process(state) postRender.process(state)而cancelFrame则是createRenderBatcher返回的cancel函数——它会遍历所有 Step,逐个调用各 Step 的cancel:
const cancel = (process: Process) => { for (let i = 0; i < stepsOrder.length; i++) { steps[stepsOrder[i]].cancel(process) } }公共入口定义在 packages/motion-dom/src/frameloop/frame.ts,导出frame(schedule)、cancelFrame(cancel)、frameData(state)、frameSteps(steps)。此外,packages/motion-dom/src/frameloop/microtask.ts 用queueMicrotask和allowKeepAlive = false构建了独立的微任务批处理器(microtask/cancelMicrotask),与 rAF 驱动的帧循环相互独立。
三、根因定位:旧cancel实现只清理了nextFrame
计划文档明确给出了修复前的现场(对应 packages/motion-dom/src/frameloop/render-step.ts 中的cancel函数):
/** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { nextFrame.delete(callback) toKeepAlive.delete(callback) },问题一目了然:它没有处理thisFrame。
3.1 为什么现有测试没暴露这个 Bug
计划文档解释得很透彻:现有的取消相关测试全部是跨 Step 取消,例如 packages/motion-dom/src/frameloop/tests/index.test.ts 中:
"cancels callbacks"(第 29–39 行):在updateStep 中取消一个renderStep 的回调;"correctly cancels"(第 89–97 行):在readStep 中取消一个updateStep 的回调;"correctly cancels a keepAlive process"(第 107–130 行):keepAlive 回调在执行中自取消。
跨 Step 取消时,目标 Step尚未开始交换,目标回调仍留在该 Step 的nextFrame中,旧的cancel实现可以正常命中。只有同一 Step 内的取消才是坏的——这正是测试盲区。
3.2 为什么删除thisFrame是安全的
计划文档给出了两个关键的安全论据:
- ECMAScript 规范保证:
Set.prototype.forEach不会访问在它被遍历到之前就已删除的元素。因此在thisFrame.forEach(triggerCallback)迭代中途删除某个回调,可以可靠地阻止该回调执行。 - 空闲场景是安全空操作:
thisFrame与nextFrame是复用Set,每次process()结束时都会thisFrame.clear()。因此在非处理状态下thisFrame恒为空,无条件地对它执行delete是一个无副作用的 no-op,不会破坏空闲状态下的任何语义。
3.3 对"取消后仍执行一次"语义的影响面
自取消场景不受影响(见 2.3 的 keepAlive 重排机制);跨 Step 取消原本就正常;唯一改变的语义是:同帧同 Step 内的取消从"延迟一帧生效"变为"立即生效"。
四、修复方案:一行代码
计划文档给出的修复(Step 3)是在cancel中增加一行thisFrame.delete(callback):
/** * Cancel the provided callback from running on the next frame. */ cancel: (callback) => { thisFrame.delete(callback) nextFrame.delete(callback) toKeepAlive.delete(callback) },三个Set职责互不重叠(thisFrame存正在执行的当前帧任务、nextFrame存下一帧任务、toKeepAlive标记 keepAlive 身份),删除顺序无关紧要。
计划文档还特别指出:本仓库对库代码体积敏感(见 CLAUDE.md 中 “Prioritise small file size” 约定),因此"修复仅一行新增代码"完全符合仓库惯例,不会带来可感知的体积开销。
4.1 变更范围(Scope)
计划文档严格划定了改动边界:
| 范围 | 文件 | 说明 |
|---|---|---|
| In scope | packages/motion-dom/src/frameloop/render-step.ts | 仅修改cancel函数 |
| In scope | packages/motion-dom/src/frameloop/tests/index.test.ts | 新增回归测试 |
| Out of scope | batcher.ts | 属于 Plan 027 的领地 |
| Out of scope | render-step.ts中的process/triggerCallback | 本修复无需改动 |
| Out of scope | packages/motion-dom/src/animation/drivers/frame.ts 及动画/投影消费者 | 修复点在调度器,而非调用方 |
| Out of scope | 公共类型(types.ts) | cancel签名不变 |
五、回归测试:先写失败测试,再实施修复
计划采用TDD 式“先红后绿”策略:先在 packages/motion-dom/src/frameloop/tests/index.test.ts 中新增两个针对同 Step 取消的测试,遵循该文件既有的 promise 风格。
5.1 测试一:同一帧内取消同 Step 回调
it("cancels a callback scheduled in the same step within the same frame", () => { return new Promise<void>((resolve, reject) => { const callback = () => reject(new Error("should have been cancelled")) frame.update(() => cancelFrame(callback)) frame.update(callback) frame.render(() => resolve()) }) })依赖Set的插入顺序:取消者先被调度,因此在updatepass 内先执行(此时队列已完成交换,目标回调位于thisFrame,而旧cancel不处理它),从而复现"被取消后仍触发"的缺陷。
5.2 测试二:同 Step 取消 keepAlive 任务
it("cancelling a keepAlive process from the same step prevents its tick", () => { return new Promise<void>((resolve, reject) => { let ticks = 0 const tick = () => ticks++ frame.update(() => cancelFrame(tick)) frame.update(tick, true) frame.render(() => (ticks === 0 ? resolve() : reject(new Error(`ticked ${ticks}x`)))) }) })该测试直接对应真实故障模式:keepAlive 任务(如动画 tick)在同 Step 被取消后,ticks必须保持为 0,证明它没有多 tick 一次。
5.3 失败验证与修复后验证
- 修复前:
npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop"→恰好这 2 个新测试失败(被取消的回调仍然触发),其余既有测试全部通过。若任一新测试在修复前就通过,说明测试写错了,应停止(STOP)。 - 修复后:同一命令 → 全部测试通过,包括 2 个新测试与全部既有取消测试(
"cancels callbacks"、"correctly cancels"、"correctly cancels a keepAlive process"),证明原有跨 Step 取消与自取消语义均被完整保留。
六、影响面验证(Blast-Radius Verification)
由于动画、投影(projection)、手势、motion value 全都经由这套调度代码注册任务,此次语义改动的影响面不能仅靠单元测试判断。计划文档给出了完整的验证命令序列,并强调无构建步骤——单元测试通过 ts-jest 直接针对src/运行:
| 目的 | 命令(仓库根目录执行) | 期望结果 |
|---|---|---|
| 帧循环单元测试 | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop" | 全部通过 |
| motion-dom 全量测试 | npx jest --config packages/motion-dom/jest.config.json --max-workers=2 | 与修复前基线完全一致的通过/失败集合 |
| framer-motion 客户端测试 | cd packages/framer-motion && yarn test-client | 与基线一致(use-velocity.test.tsx存在已知的修复前既有失败) |
| 类型检查 | npx tsc --noEmit -p packages/motion-dom/tsconfig.json | 退出码 0 |
| Lint | yarn lint | 退出码 0 |
计划中的执行顺序为:
- Step 1 记录基线(baseline):在干净工作树上保存
npx jest --config packages/motion-dom/jest.config.json --max-workers=2与cd packages/framer-motion && yarn test-client的结果尾部,作为完成标准(done criteria)的对比基准。规划时 frameloop 套件为 9/9 全绿。 - Step 2 先写失败测试(见第五节)。
- Step 3 实施一行修复。
- Step 4 全量验证:依次运行上表全部命令并与基线比对;特别关注 animation、projection、
use-transform/use-velocity套件(它们直接使用cancelFrame与frameSteps)。
计划文档对验证结果给出了重要的判断准则:如果某个既有测试失败,先读失败原因再反应——若某个测试断言的是"停止后还多更新一次",那它其实是在把这个 Bug 固化成预期行为,应在总结中上报而非削弱修复本身;但行为性失败(结束值错误、promise 挂起)则视为 STOP 条件。
七、完成标准与 STOP 条件
7.1 完成标准(Done Criteria,全部机器可查)
- 两个新测试在实施 Step 3 修复前被观察到失败(需在报告中声明)
npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop"退出码 0- motion-dom 全量套件与 Step 1 基线一致(无新增失败)
cd packages/framer-motion && yarn test-client与基线一致(无新增失败)npx tsc --noEmit -p packages/motion-dom/tsconfig.json退出码 0yarn lint退出码 0git diff --stat只触及 2 个 in-scope 文件,且render-step.ts的差异仅为新增的thisFrame.delete(callback)一行- 更新 plans/README.md 中 028 计划的状态行
7.2 STOP 条件(出现即停止上报,不得自行发挥)
- 漂移检查(drift check)发现
render-step.ts的cancel函数已与计划中的摘录不一致; - 任一新测试在修复实施前就通过;
- 修复后任一既有 frameloop 测试失败;
- framer-motion 套件出现新的行为性失败(挂起测试、动画结束值错误)——而非显式断言"取消后多 tick 一次"的测试;
- 发现自己想给
thisFrame的删除加上isProcessing条件,或重构process()——这超出本计划的契约范围。
八、维护要点与语义变更说明
计划文档在 “Maintenance notes” 中给出了几个需要随 PR 一并记录的关键点:
8.1 需要写进 PR 的语义变更
cancelFrame现在对已排入当前正在执行帧 Step 的回调也立即生效。此前同 Step 取消会让回调再多执行一次;今后不再有这种"先执行一次再取消"的行为。任何未来希望"先跑一次再取消"的代码,应改用一次性(非 keepAlive)任务——keepAlive 任务在每次 tick 后都会重排,只有一次性任务才会在下一帧自然消失。
8.2 与 Plan 027 的关系
Plan 027 在同一文件render-step.ts中用 try/finally 包裹process();两个计划修改的是不同函数、没有重叠行,后落地者可以零冲突 trivial rebase。漂移检查时,027 对batcher.ts与render-step.ts:process的改动属于预期漂移,只有cancel函数本身发生变化才构成 STOP 条件。
8.3 E2E 与评审关注点
- CI 的 Cypress/Playwright E2E 是浏览器路径(投影打断、拖拽停止流程)的最终闸门——JSDOM 无法覆盖这些场景。本次改动不涉及 compositor/WAAPI 路径,因此单元测试 + CI E2E 的深度已经足够,不必为本修复额外新增 E2E 测试。
- 评审者应确认:三个
Set的删除顺序无关紧要(它们职责不相交),并排查没有任何调用方依赖"被取消但仍执行一次"的行为(如有疑虑可检索 PR / issue)。
九、总结:一行代码背后的调度语义课
本次修复虽小,但它是理解 Motion 帧循环架构的一把钥匙:双缓冲队列的交换时机决定了"取消"的生效边界。cancelFrame不再只删除nextFrame,而是同时清理正在执行的thisFrame,配合 ECMAScriptSet.prototype.forEach的删除语义,实现了同帧同 Step 内的即时取消。修复后的行为更符合直觉——停止动画就应该立即停止,而不是在停止后的同一帧里再被写入一次过期值。
对于希望继续深入本仓库的读者,建议按以下路径研读:
- 调度器核心:packages/motion-dom/src/frameloop/render-step.ts(本次修改点)与 packages/motion-dom/src/frameloop/batcher.ts(批处理与 Step 编排);
- 公共 API 与 Step 顺序:packages/motion-dom/src/frameloop/frame.ts、packages/motion-dom/src/frameloop/order.ts;
- 测试基线:packages/motion-dom/src/frameloop/tests/index.test.ts(含本次两个新增回归测试与既有跨 Step 取消测试);
- 受影响的真实调用方:packages/motion-dom/src/animation/drivers/frame.ts(动画 keepAlive tick 的 start/stop 对)。
- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
相关推荐
Motion frameloop 异常恢复机制解析:让帧循环在回调抛错后存活(Plan 027 深度剖析)
Motion frameloop 异常恢复机制解析:让帧循环在回调抛错后存活(Plan 027 深度剖析) 导读 Motion( motion dom 包)的核
前端UI组件requestAnimationFrame 怎么写出稳定的动画循环?delta time 与帧率同步
requestAnimationFrame 怎么写出稳定的动画循环?delta time 与帧率同步 在浏览器里用 JavaScript 写动画时,常见问题有两
教程前端文档spin.js中的requestAnimationFrame:动画帧调度
spin.js中的requestAnimationFrame:动画帧调度 你是否曾遇到网页加载时的卡顿动画?是否想让加载指示器如丝般顺滑?本文将深入解析spin
UI组件前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考