ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Motion 无限循环动画中断后循环相位丢失的根因剖析:基于 issue-2714 的 keyframes 解析机制与 pause/play 方案

Motion 无限循环动画中断后循环相位丢失的根因剖析:基于 issue-2714 的 keyframes 解析机制与 pause/play 方案 前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载导读当你在 Motionpackages/framer-motion 与 packages/motion-dom中执行animate{{ rotate: 360, transition: { repeat: Infinity } }}在动画运行到 180° 时因 hover 等交互将其停止并重新触发结果动画会永远在 180°→360° 之间循环而不是恢复原先 0°→360° 的完整周期——这是很多人误以为的Bug。本文以仓库计划文档 plans/issues/issue-2714.md 为主线结合 KeyframesResolver.ts 与 JSAnimation.ts 的源码实现讲清楚这一现象背后的 keyframes 解析与 repeat 迭代机制并给出保留循环相位的 pause/play 正确用法与显式双关键帧的替代方案。一、现象复现问题到底长什么样1.1 用户报告的场景报告者在元素上配置了motion.div animate{{ rotate: 360 }} transition{{ repeat: Infinity }} onHoverStart{/* 停止动画 */} onHoverEnd{/* 重新开始动画 */} /操作路径是动画在 0→360° 周期中运行到180°时如 hover 触发执行停止随后再次启动动画。预期行为是从 180° 继续到 360°然后回到 0° 继续下一轮 0→360° 周期实际行为却是动画永远在180°→360°这个片段之间循环报告者还观察到translateX/translateY存在完全相同的现象。1.2 直觉与现实的冲突直觉上停止并重新开始应当只是临时中断动画内部应该还记得自己的进度。但 Motion 的处理方式与直觉不同stop 会终结这条动画而再次触发animate会创建一条全新的动画。这条新动画的初始关键帧不再是你最初写的 0而是从元素当前的状态180°解析而来——这是理解整个问题的关键。二、根因一新动画的初始关键帧从当前值解析2.1 省略首帧时如何补全当你只写rotate: 360而没有显式给出起始关键帧时Motion 的 keyframes 解析器会用一个null占位首帧并尝试把它解析成真实值。核心逻辑位于 KeyframesResolver.ts 的readKeyframes()// If initial keyframe is null we need to read it from the DOM if (unresolvedKeyframes[0] null) { const currentValue motionValue?.get() // TODO: This doesnt work if the final keyframe is a wildcard const finalKeyframe unresolvedKeyframes[unresolvedKeyframes.length - 1] if (currentValue ! undefined) { unresolvedKeyframes[0] currentValue } else if (element name) { const valueAsRead readOrigin( element.readValue(name, finalKeyframe), name, finalKeyframe ) if (valueAsRead ! undefined) { unresolvedKeyframes[0] valueAsRead } } if (unresolvedKeyframes[0] undefined) { unresolvedKeyframes[0] finalKeyframe } if (motionValue currentValue undefined) { motionValue.set(unresolvedKeyframes[0] as T) } }解析优先级非常明确如果该值绑定了一个MotionValue且motionValue.get()返回非undefined直接取当前值作为首帧——本例中就是 180否则从元素上读取计算值element.readValue(name, finalKeyframe)经由readOrigin归一化兜底若仍解析不出则回退为末帧。这意味着animate({ rotate: 360 })这类单值写法其实是隐含的双关键帧动画[当前值, 360]。第一次运行时当前值是 0于是解析为[0, 360]而在 180° 处 stop 后再触发新动画的解析结果就变成了[180, 360]。2.2 通配符关键帧的填充机制readKeyframes()末尾调用fillWildcards(unresolvedKeyframes)其实现位于 fill-wildcards.tsexport function fillWildcards( keyframes: ValueKeyframe[] | UnresolvedValueKeyframe[] ) { for (let i 1; i keyframes.length; i) { keyframes[i] ?? keyframes[i - 1] } }通配符wildcard / implied-initial keyframes的语义是继承前一个关键帧与上述从当前值解析首帧一样属于长期存在的、有意的设计行为。它保证了多关键帧书写如[0, 90, 360]中缺省项可以自然补全也让rotate: 360的简洁写法拥有从当前状态出发的连续性。正是这套机制让从当前值解析成为 load-bearing承重语义不能被轻易改变。三、根因二repeat 重复的是已解析的关键帧而非原始周期3.1 迭代进度完全由时间推导即便新动画的关键帧是[180, 360]如果 repeat 逻辑还记得原始周期问题也不会出现。但 JSAnimation.ts 的迭代推导完全基于时间 ÷ 单次迭代时长if (repeat) { /** * Get the current progress (0-1) of the animation. If t is * than duration well get values like 2.5 (midway through the * third iteration) */ const progress Math.min(this.currentTime, totalDuration) / resolvedDuration /** * Get the current iteration (0 indexed). For instance the floor of * 2.5 is 2. */ let currentIteration Math.floor(progress) /** * Get the current progress of the iteration by taking the remainder * so 2.5 is 0.5 through iteration 2 */ let iterationProgress progress % 1.0 /** * If iteration progress is 1 we count that as the end * of the previous iteration. */ if (!iterationProgress progress 1) { iterationProgress 1 } iterationProgress 1 currentIteration-- currentIteration Math.min(currentIteration, repeat 1) /** * Reverse progress if were not running in normal direction */ const isOddIteration Boolean(currentIteration % 2) if (isOddIteration) { if (repeatType reverse) { iterationProgress 1 - iterationProgress if (repeatDelay) { iterationProgress - repeatDelay / resolvedDuration } } else if (repeatType mirror) { frameGenerator mirroredGenerator! } } elapsed clamp(0, 1, iterationProgress) * resolvedDuration }关键点在于resolvedDuration calculatedDuration repeatDelaytotalDuration resolvedDuration * (repeat 1) - repeatDelay见 JSAnimation.tsprogress currentTime / resolvedDuration再通过Math.floor(progress)得到第几次迭代、progress % 1.0得到迭代内进度frameGenerator.next(elapsed)的输入就是迭代内进度乘以时长——生成器本身不记得任何原始周期。因此repeat: Infinity重复的永远是这条动画已解析出的关键帧解析为[180, 360]就 180→360→180→360 无限重复解析为[0, 360]才 0→360 循环。报告者观察到的 translateX/Y 现象同理只是把值换成了位移。3.2 记住上次起点为何不可行有人会想让 repeat 记住上一条动画的起点不就行了但正如计划文档 plans/issues/issue-2714.md 的 Verdict 所强调的这属于破坏性的语义变更breaking semantic change而不是修复从当前值解析首帧wildcard/implied-initial keyframes是文档化、长期承重的基础行为广泛存在于 Motion 的隐式动画、交叉过渡、拖拽复位等场景一旦新动画从当前值出发变成新动画记住并沿用上一条动画的起点所有依赖当前值作为动画起点的场景都会产生歧义甚至错误因此该 issue 的官方裁定是INVALID / SUPPORTby design分类为 support/close优先级 P3无需改动任何源码。四、正确方案用 pause()/play() 保留循环相位4.1 源码级的相位保存证明如果你希望停在 180°恢复后继续走完 360° 并正常回到 0°正确的工具不是 stop 重启而是对同一条动画调用 pause() 与 play()。源码明确证明这会精确保存循环相位pause()把当前时间存入holdTimeJSAnimation.tspause() { this.state paused this.updateTime(time.now()) this.holdTime this.currentTime }play()用holdTime反推startTime使绝对时间轴无缝衔接JSAnimation.tsif (this.state finished) { this.updateFinished() this.startTime now } else if (this.holdTime ! null) { this.startTime now - this.holdTime } else if (!this.startTime) { this.startTime startTime ?? now } ... this.holdTime null而updateTime()在holdTime ! null时直接让currentTime holdTimeJSAnimation.ts配合tick()中基于currentTime / resolvedDuration的迭代推导动画会精确回到 180° 所在的迭代进度继续前进180→360→0→360→……循环相位分毫不差。仓库的 Playwright 测试也覆盖了 pause 语义tests/animate/animate.spec.ts 中.pause()、.pause() before keyframe resolution、.pause() before keyframe resolution, after set time三个用例分别验证了暂停后状态、暂停发生在关键帧解析前的边界以及暂停后再设置时间能精确落到预期进度如 boundingBox.x 接近 50。4.2 React 与原生 API 两种落地方式方式一React 中使用 useAnimate 的 scope 控制use-animate.ts 返回[scope, animate]其中animate由createScopedAnimate生成所有由它启动的动画都会被登记进scope.animations并在组件卸载时统一stop()见该文件useUnmountEffect。典型写法function Spinner() { const [scope, animate] useAnimate() return ( motion.div ref{scope} onHoverStart{() { // 停止排队中的新动画但保留当前动画以 pause animate(scope.current, { rotate: 360 }, { duration: 0 }) scope.animations.forEach((a) a.pause()) }} onHoverEnd{() { scope.animations.forEach((a) a.play()) }} / ) }要点用 hover 事件驱动时hover 结束会重新触发animate({ rotate: 360 })创建新动画——这正是问题来源。因此更稳妥的做法是把动画挂在whileHover/animate之外的一次性启动上让同一条动画实例贯穿始终只切换它的 pause/play 状态。方式二原生 animate() 返回的 controlsanimate()返回的控件实现了AnimationPlaybackControlsWithThen接口定义于 motion-dom/src/animation/types.ts可直接持有import { animate } from motion const controls animate(el, { rotate: 360 }, { repeat: Infinity }) // hover 进入 controls.pause() // hover 离开 controls.play()由于从未调用stop()动画实例与它的生成器始终存活holdTime与startTime的换算让恢复行为完全无缝。4.3 方案对比与注意事项方案循环相位适用场景注意点stop() 重新animate({ rotate: 360 })丢失永远 180→360不关心周期连续性的场景行为符合设计勿当作 Bugpause()/play()同一条动画完整保留需精确续跑、与交互状态联动不要中途 stophover 期间避免重新触发 animate显式rotate: [0, 360]重启每次从 0 重新开始接受重启归零的循环需求若在 180° 停止再重启仍会从 0 而非 180 开始补充说明显式写出两个关键帧rotate: [0, 360]后readKeyframes()中首帧不再是null不会走从当前值解析分支因此每次重启都是确定的 0→360 周期——若从 0 重新循环可以接受这是最省事的写法。五、设计启示implicit keyframes 与 repeat 的组合边界5.1 这条设计的价值从当前值解析首帧 repeat 重复已解析关键帧的组合看似让人困惑实则支撑了大量正常功能animate{{ x: 100 }}这类从当前位置出发的隐式动画不必每次手写起点交叉过渡layout animations、AnimatePresence 进出场中新元素需要从元素此刻的真实状态平滑起步readOrigin()还会把数值字符串归一化为数字并把不可动画的none转换为与目标同形态的可动画零值见 KeyframesResolver.ts保证解析结果可被混合器消费。从源码结构看这套设计把关键帧解析KeyframeResolver与时间驱动的迭代JSAnimation.tick彻底解耦解析器只负责产出确定的关键帧数组动画引擎只按时间机械地推进——两者组合产生了上述行为但任何一方的单独变更都会破坏现有生态。5.2 给使用者的经验法则需要可中断并续跑的循环动画 → 用pause()/play()且保证整个交互过程中动画实例不消亡需要重启即从起点循环 → 显式双关键帧[0, 360]遇到看似异常的循环行为先检查是否同时使用了stop()与repeat: Infinity这是最常见的触发条件该 issue 的官方处理方式是关闭并解释closed asnot_planned仓库计划文档 plans/issues/issue-2714.md 中给出了完整的裁定理由、复现路径与建议回复文案可作为同类问题repeat 中断的标准应答参考。结语rotate: 360搭配repeat: Infinity在停止后重启时只循环 180→360并非 Motion 的缺陷而是新动画首帧从当前值解析 repeat 重复已解析关键帧两条设计规则的直接推论。正确的续跑姿势是对同一条动画使用pause()/play()——holdTime与startTime的换算会在源码层面保证循环相位无缝衔接若愿意接受归零重启显式rotate: [0, 360]同样可行。理解这三者你就能在中断恢复、hover 联动、轮播等真实场景中精准选择动画控制方式不再被相位丢失困扰。赞分享前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐终极指南解决OpenMC中RegularMesh表面过滤器无限循环的完整方案终极指南解决OpenMC中RegularMesh表面过滤器无限循环的完整方案 OpenMC作为一款强大的Monte Carlo粒子输运模拟工具其网格 tal科学计算高性能计算科研如何为开源项目express-useragent贡献代码更新Bot清单与编写测试的完整清单如何为开源项目express useragent贡献代码更新Bot清单与编写测试的完整清单 express useragent 是一个专为 Express 打开发工具Motion 中 display: none 动画失效问题的根因分析与 v11.2.0 修复方案issue-2563 复盘Motion 中 display: none 动画失效问题的根因分析与 v11.2.0 修复方案issue 2563 复盘 本文以仓库内的 plans/前端UI组件上一篇3步搞定网盘限速难题LinkSwift开源神器实战指南下一篇网盘直链下载助手八大网盘一键解析告别限速困扰创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表