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,完整还原 motion(motion.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 Summary):
Motion.dev原生支持WAAPI 飞行中打断,且自
framer-motion@11.0.17(2024-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-motion@2.7.7已声明依赖motion >=12.35.1,远高于两个关键修复点,因此"可移除"的前提在当前仓库中仍然成立。
二、motion 打断处理的完整代码路径(六步链路)
这是研究文档的核心骨架,对应 motion 仓库(调研时点为 commit8f6ad46d8,v12.29.2)中的六步调用链。下面按原顺序完整继承每一步。
Step 1:animate(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 2:animateTarget()对每个属性调用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 3:MotionValue.start()总是先停掉上一个动画
packages/motion-dom/src/value/index.ts(421–436 行):
start(startAnimation: StartAnimation) { this.stop() // <-- 在开始新动画之前,先停掉正在运行的动画 // ... this.animation = startAnimation(resolve) }this.stop()会调用当前运行的AsyncMotionValueAnimation(或NativeAnimationExtended)的animation.stop()。这是整个打断机制的触发点:任何新动画都隐式先终止旧动画。
Step 4:NativeAnimation.stop()在 cancel 之前先提交样式
packages/motion-dom/src/animation/NativeAnimation.ts(150–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 doesn't 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 5:NativeAnimationExtended.updateMotionValue()做智能采样
NativeAnimationExtended(packages/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.ts):
const 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-motion@8.1.8 | 2023-01-05 | 打断 WAAPI 动画时,对带 delay/repeat 设置的动画的采样 |
framer-motion@11.0.2 | 2024-01-23 | 修复打断 WAAPI 动画时的速度计算 |
framer-motion@11.0.17 | 2024-03-20 | "打断 WAAPI 动画时从正确值开始动画"—— PR #2575fix/waapi-interrupt |
framer-motion@11.0.21 | 2024-03-26 | 专门修复打断 WAAPIspring动画的问题(需在 resolved 对象中保留type、ease、times才能正确采样)—— commitdb77156de |
motion@12.24.11 | 2026-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是一个以元素为键的WeakMap(subject.ts第 137 行的检查),所以同一元素的 VisualElement(及其每属性的 MotionValue)会在多次animate(element, ...)之间被复用。这意味着 motion 的打断机制在该用法路径下完整生效。
另有一个时序细节值得注意:AsyncMotionValueAnimation是异步解析关键帧的(通过frame.read排到下一帧)。如果在关键帧解析前再次调用animate(),第一个动画的keyframeResolver会被取消,第二个动画直接接管——此时旧动画根本没启动 WAAPI,不存在需要提交的飞行中值,自然也不会出现跳变。
六、CPU 高负载下的跳变修复(motion 12.24.11+)
commitb6841817b(2026-01-07)专门修复如下场景:
- 主线程被阻塞(CPU 高负载);
- WAAPI 的
currentTime落后于真实经过时间; - 打断采样若基于
currentTime,会取到错误的动画进度点; - 元素表现为一瞬跳到错误位置。
修复方式:动画启动时记录this.startedAt = time.now(),采样改用time.now() - this.startedAt(墙钟经过时间)而不是 WAAPI 的currentTime。该修复随motion@12.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 2); animate(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",而是通过PopperPositionAnims(WeakMap<HTMLElement, PopperPositionAnim>)持有两个MotionValue<number>,每次重定向调用animateMotionValue(entry.x, target.x, positionTransition)重设弹簧目标——这正是文档第 6 步链路的直接应用:每次 retarget 都从"实时位置 + 实时速度"续接。注释明确解释了动机:WAAPI 每次只能从静止处 cancel + restart,会让共享 tooltip 在指针快速跨越触发器时"明显卡顿并落后"。对含 rotate/scale/skew 的 transform(parseTranslate返回 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.json(
@tamagui/animations-motion@2.7.7)声明motion >=12.35.1,覆盖研究文档列出的全部修复点,README 中描述的"Hybrid Engine(JS + 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,依赖 WAAPI),motion 源码引用以研究文档记录时的 v12.29.2(commit8f6ad46d8)为准,行号与文件路径位于 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),仅供参考