ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Motion 动画库帧循环调度修复:让 `cancelFrame` 对同帧同 Step 内已入队回调即时生效

Motion 动画库帧循环调度修复:让 `cancelFrame` 对同帧同 Step 内已入队回调即时生效 前端UI组件【免费下载链接】motionA 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 中的frameloopDriverstart: (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 双缓冲SetthisFrame与nextFrame// createRenderStep 内部render-step.ts let thisFrame new SetProcess() let nextFrame new SetProcess()源码注释明确说明了两组队列的设计动机复用两个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 的cancelconst cancel (process: Process) { for (let i 0; i stepsOrder.length; i) { steps[stepsOrder[i]].cancel(process) } }公共入口定义在 packages/motion-dom/src/frameloop/frame.ts导出frameschedule、cancelFramecancel、frameDatastate、frameStepssteps。此外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 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.tscancel签名不变五、回归测试先写失败测试再实施修复计划采用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 Promisevoid((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 Promisevoid((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 --testPathPatternframeloop→恰好这 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 --testPathPatternframeloop全部通过motion-dom 全量测试npx jest --config packages/motion-dom/jest.config.json --max-workers2与修复前基线完全一致的通过/失败集合framer-motion 客户端测试cd packages/framer-motion yarn test-client与基线一致use-velocity.test.tsx存在已知的修复前既有失败类型检查npx tsc --noEmit -p packages/motion-dom/tsconfig.json退出码 0Lintyarn lint退出码 0计划中的执行顺序为Step 1 记录基线baseline在干净工作树上保存npx jest --config packages/motion-dom/jest.config.json --max-workers2与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 --testPathPatternframeloop退出码 0motion-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组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐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动画帧调度 你是否曾遇到网页加载时的卡顿动画是否想让加载指示器如丝般顺滑本文将深入解析spinUI组件前端上一篇nn-zero-to-hero 完整指南手写反向传播一步步搭出神经网络下一篇March7thAssistant自动化竞赛展示你的创意自动化方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表