尧图精选

Tamagui Motion 动画驱动深度解析:WAAPI 动画飞行中打断的原理、motion 原生机制与 workaround 的存废

🕒 发布时间:2026/9/14 4:57:48 📁 来源:尧图网络
Tamagui Motion 动画驱动深度解析WAAPI 动画飞行中打断的原理、motion 原生机制与 workaround 的存废【免费下载链接】tamaguiStyle React fast with 100% parity on React Native, an optional UI kit, and optimizing compiler.项目地址: https://gitcode.com/GitHub_Trending/ta/tamagui本文基于 Tamagui 仓库内code/core/animations-motion包附带的研究文档 MOTION_INTERRUPTION_RESEARCH.md完整还原 motionmotion.dev对 WAAPI 加速属性动画飞行中打断mid-flight interruption的原生处理链路逐层解析NativeAnimation.stop()、MotionValue.start()、KeyframesResolver之间的协作关系并对照 createAnimations.tsx 的实际实现与回归测试说明 Tamagui 历史 workaround 为何冗余、以及当前代码中该问题的最终落地形态。读完本文你将能够判断在什么前提下可以安全移除手写打断处理以及如何在 Playwright 层验证快速 hover 切换/高频重定向场景下的动画平滑性。一、研究结论motion 原生支持 WAAPI 飞行中打断该研究文档的调研对象是当 Tamagui 通过animate(element, diff, options)对一个正在被 WAAPI 动画驱动的 DOM 元素再次发起动画时motion 能否从当前动画中的中间位置平滑接管而不是从旧起点重新播放。结论明确引自研究文档 Executive SummaryMotion.dev原生支持WAAPI 飞行中打断且自framer-motion11.0.172024-03-20 发布起这一能力已深度内建。Tamagui 在createAnimations.tsx中的历史 workaround对运行中的动画手动commitStyles()cancel() 手工构造[committedTransform, targetTransform]关键帧数组对标准 WAAPI 加速属性opacity、transform、clipPath、filter是冗余的且推理上部分不精确——它手动做的事 motion 内部已经做了但 motion 的版本还会额外做速度采样velocity sampling以支撑弹簧动画的平滑续接。需要注意的适用前提该结论依赖版本下限。文档给出的建议是motion 11.0.21弹簧类动画需要额外的type/ease/times保留修复研究当时 Tamagui 依赖的是motion ^12.34.2而当前仓库 package.json 中tamagui/animations-motion2.7.7已声明依赖motion 12.35.1远高于两个关键修复点因此可移除的前提在当前仓库中仍然成立。二、motion 打断处理的完整代码路径六步链路这是研究文档的核心骨架对应 motion 仓库调研时点为 commit8f6ad46d8v12.29.2中的六步调用链。下面按原顺序完整继承每一步。Step 1animate(element, diff, options)入口Tamagui 在flushAnimation()中调用animate(scope.current, fixedDiff, animationOptions)当前实现见 createAnimations.tsx 中startedControls animate(scope.current, waapiDiff, animationOptions)一处。该调用在 motion 内部经过createScopedAnimate() - scopedAnimate() - animateSubject() - animateTarget()对应packages/framer-motion/src/animation/animate/index.ts与subject.ts此为 motion 仓库内路径不在本仓库内。Step 2animateTarget()对每个属性调用value.start()在packages/motion-dom/src/animation/interfaces/visual-element-target.ts中对每个待动画的 CSS 属性motion 从 VisualElement 的 store 中取或创建一个MotionValue并调用value.start(animateMotionValue(key, value, valueTarget, valueTransition, visualElement, isHandoff))Step 3MotionValue.start()总是先停掉上一个动画packages/motion-dom/src/value/index.ts421–436 行start(startAnimation: StartAnimation) { this.stop() // -- 在开始新动画之前先停掉正在运行的动画 // ... this.animation startAnimation(resolve) }this.stop()会调用当前运行的AsyncMotionValueAnimation或NativeAnimationExtended的animation.stop()。这是整个打断机制的触发点任何新动画都隐式先终止旧动画。Step 4NativeAnimation.stop()在 cancel 之前先提交样式packages/motion-dom/src/animation/NativeAnimation.ts150–166 行stop() { if (this.isStopped) return this.isStopped true const { state } this if (state idle || state finished) { return } if (this.updateMotionValue) { this.updateMotionValue() // -- NativeAnimationExtended 覆写了它 } else { this.commitStyles() // -- 把飞行中的值提交到内联样式 } if (!this.isPseudoElement) this.cancel() }源码中的注释167–178 行说得很直白/** * WAAPI doesnt natively have any interruption capabilities. * * In this method, we commit styles back to the DOM before cancelling * the animation. * * This is designed to be overridden by NativeAnimationExtended, which * will create a renderless JS animation and sample it twice to calculate * its current value, previous value, and therefore allow * Motion to also correctly calculate velocity for any subsequent animation * while deferring the commit until the next animation frame. */ protected commitStyles() { if (!this.isPseudoElement) { this.animation.commitStyles?.() } }要点WAAPI 本身没有任何原生的打断能力motion 的策略是先提交commit再取消cancel让 DOM 上的内联样式停留在中断瞬间的真实值上。Step 5NativeAnimationExtended.updateMotionValue()做智能采样NativeAnimationExtendedpackages/motion-dom/src/animation/NativeAnimationExtended.ts覆写了updateMotionValue()。它不是简单调commitStyles()而是用完全相同的动画参数关键帧、缓动、时长、类型等创建一个renderless不渲染的JSAnimation在当前墙钟wall-clock已耗时上采样两次sampleTime - delta和sampleTime调用motionValue.setWithVelocity(prev, current, delta)—— 把 MotionValue 一次性设置为当前动画值 由两次采样算出的速度此后 MotionValue 同时拥有正确的飞行中位置和正确速度。updateMotionValue(value?: T) { const { motionValue, onUpdate, onComplete, element, ...options } this.options if (!motionValue) return if (value ! undefined) { motionValue.set(value) return } const sampleAnimation new JSAnimation({ ...options, autoplay: false, }) const sampleTime Math.max(sampleDelta, time.now() - this.startTime) const delta clamp(0, sampleDelta, sampleTime - sampleDelta) motionValue.setWithVelocity( sampleAnimation.sample(Math.max(0, sampleTime - delta)).value, sampleAnimation.sample(sampleTime).value, delta ) sampleAnimation.stop() }这一步就是弹簧续接平滑的来源位置与速度都被保留。Step 6新动画从当前飞行中位置起步回到animateMotionValue()packages/motion-dom/src/animation/interfaces/motion-value.tsconst options: ValueAnimationOptions { keyframes: Array.isArray(target) ? target : [null, target], // null 读取当前值 velocity: value.getVelocity(), // -- 使用 setWithVelocity() 写入的速度 // ... }随后KeyframesResolver.readKeyframes()解析null首关键帧if (unresolvedKeyframes[0] null) { const currentValue motionValue?.get() // -- 读到的正是飞行中已提交值 if (currentValue ! undefined) { unresolvedKeyframes[0] currentValue } // ... }于是新动画以[currentMidFlightValue, ..., targetValue]的关键帧、并带着正确速度运行得到平滑的弹簧续接。整条链路 stop()提交/采样 →start()先停后启 →null首帧解析 → 速度透传四环相扣这正是motion 原生支持打断的完整证据。三、关键修复提交时间线研究文档给出了打断能力相关的五个关键版本这是判断依赖哪个版本才能安全移除 workaround的事实依据版本日期修复内容framer-motion8.1.82023-01-05打断 WAAPI 动画时对带 delay/repeat 设置的动画的采样framer-motion11.0.22024-01-23修复打断 WAAPI 动画时的速度计算framer-motion11.0.172024-03-20打断 WAAPI 动画时从正确值开始动画—— PR #2575fix/waapi-interruptframer-motion11.0.212024-03-26专门修复打断 WAAPIspring动画的问题需在 resolved 对象中保留type、ease、times才能正确采样—— commitdb77156demotion12.24.112026-01-07修复 CPU 高负载下快速打断时 transform 动画跳变—— 为NativeAnimationExtended增加startedAt墙钟时间戳采样改用time.now()而非 WAAPI 的currentTime—— commitb6841817b奠定核心修复的关键 PR 是#2575合并为 commit3c45b2f79代码后来从 framer-motion 的AcceleratedAnimation.ts重构为 motion-dom 中NativeAnimation/NativeAnimationExtended的拆分。四、Tamagui 的历史 workaround它做了什么、为什么冗余研究文档引用的原始 workaround 位于当时createAnimations.tsx第 333–405 行// WAAPI mid-flight transform interruption workaround // [解释问题与方案的长注释] if (isRunning refs.current.controls fixedDiff.transform (isPopperElement || isEnteringPresenceChild)) { const anims (refs.current.controls as any).animations if (anims) { for (const anim of anims) { try { const raw anim?.animation ?? anim raw?.commitStyles?.() // 手动提交样式 } catch {} } } refs.current.controls.cancel() // 取消旧动画 const committedTransform node.style.transform // 读取已提交值 if (committedTransform) { fixedDiff.transform [committedTransform, fixedDiff.transform] // 构造关键帧数组 } }文档指出这等于手动重做了 motion 在元素上存在运行中 motion 动画时调用animate()已内置的行为且存在三个缺陷值精度差从commitStyles()之后的node.style.transform读回 CSS 字符串表示而 motion 的 MotionValue 内部跟踪的精确数值可能与该字符串表示不一致关键帧手工化motion 的KeyframesResolver在首关键帧为null时会自动完成[当前值, 目标值]的构造workaround 属于重复劳动不保留速度因此弹簧动画在打断时丢失速度——而 motion 的NativeAnimationExtended.updateMotionValue()通过双采样正确捕获了速度。至于当时注释里motion 处理不了这个的说法文档给出了合理解释注释写于 workaround 引入之时彼时 motion 对 Tamagui 的特定用法模式通过useAnimate()的 scope 调animate(htmlElement, {transform: ...}, options)而非motion.div variant props支持可能尚不充分。关键在于 VisualElement 是否跨调用持久化——答案是肯定的见下一节。结论对motion 11.0.17弹簧则 11.0.21该 workaround 是冗余的。五、关键前提useAnimate()路径下 VisualElement 确实跨调用复用这是workaround 冗余结论能否成立的枢纽文档第 4 节专门论证了它Tamagui 使用useAnimate()返回scope然后调用animate(scope.current, ...)。当scope.current是HTMLElement时animateSubject()调用resolveSubjects()拿到元素检查visualElementStore中是否已有该元素的 VisualElement若没有通过createDOMVisualElement(element)创建一个随后animateTarget()遍历每个属性并调用value.start()。visualElementStore是一个以元素为键的WeakMapsubject.ts第 137 行的检查所以同一元素的 VisualElement及其每属性的 MotionValue会在多次animate(element, ...)之间被复用。这意味着 motion 的打断机制在该用法路径下完整生效。另有一个时序细节值得注意AsyncMotionValueAnimation是异步解析关键帧的通过frame.read排到下一帧。如果在关键帧解析前再次调用animate()第一个动画的keyframeResolver会被取消第二个动画直接接管——此时旧动画根本没启动 WAAPI不存在需要提交的飞行中值自然也不会出现跳变。六、CPU 高负载下的跳变修复motion 12.24.11commitb6841817b2026-01-07专门修复如下场景主线程被阻塞CPU 高负载WAAPI 的currentTime落后于真实经过时间打断采样若基于currentTime会取到错误的动画进度点元素表现为一瞬跳到错误位置。修复方式动画启动时记录this.startedAt time.now()采样改用time.now() - this.startedAt墙钟经过时间而不是 WAAPI 的currentTime。该修复随motion12.24.11发布Tamagui 声明的依赖研究当时motion ^12.34.2当前 package.json 为motion 12.35.1均已包含它。这也解释了为什么快速 hover 切换类问题必须在高负载条件下回归验证。七、建议与迁移路径文档第 6 节给出了明确的操作建议此处完整继承workaround 是否应移除——应该针对transform属性场景。只要满足两个条件motion 对所有 WAAPI 加速值transform、opacity、clipPath、filter都能正确打断使用motion 11.0.21弹簧动画需要 resolved 对象额外保留type/ease/times元素的 VisualElement 在多次调用之间持久化通过visualElementStore的 WeakMap 机制成立。motion 比 workaround 强的两点速度保留NativeAnimationExtended.updateMotionValue()双采样 JS 等价动画算出速度并以velocity: value.getVelocity()传给下一个动画实现尊重当前运动方向与速率的平滑弹簧续接。Tamagui workaround 不保留速度数值精度motion 从内部 MotionValue 读取精确动画值而不是把node.style.transform的 CSS 字符串解析回来避免了浮点转换问题。迁移路径文档原方案删除createAnimations.tsx第 333–431 行的整个 workaround 块Part 1 与 Part 2animate(scope.current, fixedDiff, animationOptions)调用保持不变用现有动画回归测试验证TabHoverPositionSmooth.animated.test.tsx、TooltipPositionJump.animated.test.tsx、PopoverAnimatePosition.animated.test.tsx、PopoverHoverable.test.tsx其中 TooltipPositionJump.animated.test.tsx 在当前仓库 code/kitchen-sink/tests/ 中仍然存在特别关注 CPU 高负载下的快速 hover 切换场景12.24.11 修复的目标场景。不需要上游修复motion 本身已正确处理机制就是第二节那条完整链路。八、当前仓库中的落地形态workaround 如何演化对照当前 createAnimations.tsx 源码可以看到研究结论之后的实际演化方向——文档中冗余 workaround已被更精细的分路策略取代而非简单删除popper 定位改走 motion value 弹簧路径。当前实现中带data-popper-animate-position属性的元素tooltip/popover 位置其 translate x/y 不再走 WAAPI 的cancel-freeze-restart而是通过PopperPositionAnimsWeakMapHTMLElement, PopperPositionAnim持有两个MotionValuenumber每次重定向调用animateMotionValue(entry.x, target.x, positionTransition)重设弹簧目标——这正是文档第 6 步链路的直接应用每次 retarget 都从实时位置 实时速度续接。注释明确解释了动机WAAPI 每次只能从静止处 cancel restart会让共享 tooltip 在指针快速跨越触发器时明显卡顿并落后。对含 rotate/scale/skew 的 transformparseTranslate返回 null 的情况则回落到 WAAPI 路径exit 路径仍保留显式关键帧。非 popper 场景下当前代码在isCurrentlyExiting时先refs.current.controls.stop()并用getComputedStyle抓取的midFlightValues为 transform 显式构造[midFlightValues.transform, waapiDiff.transform]关键帧——但仅在已拆除上一个动画时popper cancel 或 exit stop才 pin 这个from矩阵否则交给 motion 的 resolver 从实时值插值回归测试即文档建议的第 3 步。LogoDotInterruptCase.tsx 忠实复刻了 tamagui.dev 首页 logo 圆点被鼠标快速左右扫过、每帧打断 transform 动画的场景对应回归测试 LogoDotInterrupt.animated.test.tsx 逐帧采样getBoundingClientRect().left断言单帧位移maxDelta 90px注释说明从过期/零基准重启会在单帧内产生全程距离的瞬移而平滑的 medium transition 每帧最多约 15–25px。代码中的注释也直接引用了这条测试名说明 ungating 导致回归的事故正是靠它兜住的。另外TooltipPositionJumpNotes.md 记录了同一问题域的完整排查史包括一个最终根因并非动画驱动的教训页面加载后首跳源于withStaticProperties用Object.assign变异共享Tooltip.Content身份导致 PopperContent 子树被整体替换并瞬移——这提醒读者单帧跳变未必来自打断逻辑本身版本前提已满足。当前 package.jsontamagui/animations-motion2.7.7声明motion 12.35.1覆盖研究文档列出的全部修复点README 中描述的Hybrid EngineJS WAAPI、弹簧物理、合成线程等特性与本文讨论的 WAAPI 打断机制互为表里。九、可复用的工程结论WAAPI 没有原生打断能力任何cancel 重启策略都必须先解决从哪个值、以什么速度起步。motion 的答案是commitStyles()/ 双采样setWithVelocity()null首关键帧解析 速度透传四者缺一不可手写 workaround 的常见误区读回 CSS 字符串精度损失、不采样速度弹簧断速、与库内部机制重复维护成本验证打断平滑性的正确姿势是逐帧采样渲染位置并断言单帧位移上界如 LogoDotInterrupt.animated.test.tsx 的maxDelta 90而不是只检查动画最终是否到达目标排查单帧跳变时除动画驱动本身外还应检查 React 元素身份变化导致的子树重建与定位瞬移见 TooltipPositionJumpNotes.md 中的withStaticProperties案例。适用前提与限制本文结论基于 Web 端的tamagui/animations-motion驱动Web-only依赖 WAAPImotion 源码引用以研究文档记录时的 v12.29.2commit8f6ad46d8为准行号与文件路径位于 motion 仓库而非本仓库仓库内的验证路径均以 code/core/animations-motion/ 与 code/kitchen-sink/tests/ 下实际存在的文件为据。【免费下载链接】tamaguiStyle React fast with 100% parity on React Native, an optional UI kit, and optimizing compiler.项目地址: https://gitcode.com/GitHub_Trending/ta/tamagui创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →