☰
Motion 动画库帧循环调度修复:让 `cancelFrame` 对同帧同 Step 内已入队回调即时生效
2026/9/30 6:38:44 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】motion

A modern animation library for React and JavaScript

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载

本文基于 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是安全的

计划文档给出了两个关键的安全论据:

  1. ECMAScript 规范保证:Set.prototype.forEach不会访问在它被遍历到之前就已删除的元素。因此在thisFrame.forEach(triggerCallback)迭代中途删除某个回调,可以可靠地阻止该回调执行。
  2. 空闲场景是安全空操作: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 scopepackages/motion-dom/src/frameloop/render-step.ts仅修改cancel函数
In scopepackages/motion-dom/src/frameloop/tests/index.test.ts新增回归测试
Out of scopebatcher.ts属于 Plan 027 的领地
Out of scoperender-step.ts中的process/triggerCallback本修复无需改动
Out of scopepackages/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
Lintyarn lint退出码 0

计划中的执行顺序为:

  1. 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 全绿。
  2. Step 2 先写失败测试(见第五节)。
  3. Step 3 实施一行修复。
  4. 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退出码 0
  • yarn lint退出码 0
  • git 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

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载
上一篇:nn-zero-to-hero 完整指南:手写反向传播,一步步搭出神经网络
下一篇:March7thAssistant自动化竞赛:展示你的创意自动化方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询