3381 字
约 11 分钟
16
S14-X08 · 动画库选型:CSS 过渡、GSAP 与减少动效偏好

动画的选型问题通常问错了方向:"用 GSAP 还是 CSS?"——真正决定选型的是三件事:这段动画由谁触发、要不要中途打断、有没有时间轴序列。触发简单、无需打断的,CSS 一行搞定;需要暂停/倒放/编排的,写 CSS 会写成一堆 cubic-bezier 与 setTimeout 的混战。

这篇按五步走:四种方案的边界 → 性能开销 → 库的取舍 → 减少动效偏好 → Vue 内置过渡,末尾列坑。


1. 四种方案的能力边界

方案 触发 中断/暂停 时间轴编排 体积 适用
CSS transition / animation 类名、状态 难(靠类名切换) 弱(animation-delay 堆) 0 悬停、显隐、状态切换
Web Animations API JS 调用 原生支持 pause()/cancel() 弱 0 需要程序控制但不想引库
GSAP JS 调用 强(timeline 可整体控制) 强 数十 KB 级 多元素序列、滚动联动
@vueuse/motion / Framer Motion 声明式(指令/组件) 中 中 中 交互驱动的入场/布局动画

判断顺序只有三问:能只用 CSS 吗 → 需要程序控制吗 → 需要序列编排吗。第一问为是就别往下看;只有走到第三问才值得引入动画库。

什么时候动画库是过度设计,典型信号:

  • 只做"淡入淡出 + 位移",却引入了带时间轴引擎的库;
  • 页面上总共两处动画,且都不需要打断;
  • 引入库只为用 spring 缓动——CSS 的 cubic-bezier 或 linear() 已经够用;
  • 为了动画效果引入了整个框架生态的运行时(如只为了一个组件而拉进动画依赖)。

反过来说,该用库的信号也很明确:滚动驱动的多段动画、需要暂停/倒放/时间轴对齐、需要按顺序编排十来个元素、需要精确的 stagger 与 stagger 反向。


2. 性能:只动 transform 与 opacity

/* ✅ 合成层动画:不触发布局与重绘 */
.card {
  transition: transform 200ms cubic-bezier(.2, .8, .2, 1), opacity 200ms linear;
  will-change: transform;   /* 仅在真正会动期间挂上 */
}
.card:hover { transform: translateY(-4px) scale(1.01); }

/* ❌ 每帧触发布局:宽度变化会重排整个祖先链 */
.panel { transition: width 200ms linear; }

/* ❌ 触发布局 + 重绘 + 昂贵栅格化 */
.shadow { transition: box-shadow 200ms linear; }
属性 触发的阶段 相对开销
transform 合成 低
opacity 合成 低
filter(部分) 合成/栅格化 中到高
background-color 重绘 中
width/height/top/left/margin 布局 → 重绘 → 合成 高
box-shadow 重绘(大元素上很贵) 高

要动尺寸,用 transform: scale() 并在动画结束后写回真实尺寸——这是"FLIP"手法的核心,代价是视觉中间态与真实布局不一致,只在必要时用。

will-change 的误用是最常见的性能自伤:

  • 写在全局或大量元素上,会为每个元素分配图层,显存暴涨,反而更慢;
  • 长期挂着不摘,图层永不回收;
  • 正确做法是在即将动画前挂、结束后摘(或用 will-change: auto 复位),并且只在真的观察到掉帧时才加。
// 交互期间临时挂 will-change,结束后复位
function withAcceleration(el: HTMLElement, run: () => Promise<void>) {
  el.style.willChange = 'transform'
  return run().finally(() => { el.style.willChange = 'auto' })
}

3. Web Animations API:不引库的程序控制

const el = document.querySelector<HTMLElement>('.toast')!

const anim = el.animate(
  [
    { transform: 'translateY(8px)', opacity: 0 },
    { transform: 'translateY(0)', opacity: 1 },
  ],
  { duration: 220, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'both' },
)

anim.pause()
anim.playbackRate = 0.5

// finished 是可等待的 Promise,且会在 cancel 时 reject
await anim.finished.catch(() => {})
anim.cancel()

WAAPI 的价值在于把"程序控制"从库里拿出来:pause()、reverse()、playbackRate、finished 都是原生能力。只需要"能取消、能等结束"的场景,不必引 GSAP。

能力 WAAPI GSAP
基本补间 ✅ ✅
时间轴序列编排 ❌(要手写 delay 计算) ✅
缓动函数库 有限(ease/cubic-bezier/steps) 丰富(含 spring)
滚动驱动 ❌(另配 ScrollTimeline,支持面有限) ✅
体积 0 数十 KB 级

4. GSAP:时间轴与序列编排

import { gsap } from 'gsap'

const tl = gsap.timeline({ defaults: { duration: 0.4, ease: 'power2.out' } })

tl.from('.hero-title', { y: 24, opacity: 0 })
  .from('.hero-sub', { y: 16, opacity: 0 }, '-=0.2')          // 与上一段重叠 0.2s
  .from('.card', { y: 20, opacity: 0, stagger: 0.06 }, '<0.1')

tl.pause()
tl.timeScale(1.5)      // 整体调速,无需改各处 duration
tl.reverse()

GSAP 不可替代的能力就三条:时间轴的整体控制(timeScale、seek、reverse 作用于全部子动画)、相对时间定位('-=0.2'、'<0.1' 这种写法让编排可读)、stagger 与反向 stagger。手写这些需要维护一套时间基准表,改一处动全身。

代价要如实承认:多一个运行时依赖、需要处理注册与清理(组件卸载时 tl.kill())、与 Vue 的响应式系统没有天然联动——GSAP 直接操作 DOM,Vue 的 diff 不知道你在动它,两者叠加时容易在列表增删场景互相打架。


5. 声明式方案:@vueuse/motion

<script setup lang="ts">
import { MotionPlugin } from '@vueuse/motion'
import type { MotionVariants } from '@vueuse/motion'

const variants: MotionVariants = {
  initial: { opacity: 0, y: 12 },
  enter: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 260, damping: 24 } },
  leave: { opacity: 0, y: -8, transition: { duration: 160 } },
}
</script>

<template>
  <div v-motion="variants" :initial="{ opacity: 0 }" :enter="{ opacity: 1 }">
    声明式入场
  </div>
</template>

声明式的优势是状态与动画绑定:v-if/v-show 切换、v-for 增删会自动触发对应阶段,不用手写 enter/leave 逻辑。代价是控制粒度与调试能力弱于命令式——想精确 seek 到某个时间点,声明式方案给不了你。

需求 命令式(GSAP/WAAPI) 声明式(@vueuse/motion)
与 Vue 状态绑定 需手动 watch 自动
精确时间点控制 ✅ ❌
复杂序列编排 ✅ 中
代码可读性(简单场景) 一般 好
调试 时间轴可视化 靠 props 与日志

6. 尊重"减少动效"偏好

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
// composables/useMotionSafe.ts:JS 侧检测,用于决定是否启用 GSAP 时间轴
import { computed } from 'vue'
import { usePreferredReducedMotion } from '@vueuse/core'

export function useMotionSafe() {
  const pref = usePreferredReducedMotion()          // 'reduce' | 'no-preference'
  const enabled = computed(() => pref.value !== 'reduce')

  function run(anim: () => unknown, fallback?: () => unknown) {
    return enabled.value ? anim() : fallback?.()     // 降级为直接设终态
  }
  return { enabled, run }
}
策略 做法 适用
全部关闭 时长压到 0.01ms 通用兜底,最省事
换成淡入淡出 位移/缩放改为 opacity 变化 需要保留"有反馈"的感知
保留功能性动画 加载指示、进度条保留,装饰性动画关闭 内容型产品
JS 侧不启动时间轴 run() 里直接设终态 GSAP 场景

三条要点:

  1. 媒体查询是兜底,不是全部。CSS 里改掉 transition-duration 只能管 CSS 动画;GSAP 与 WAAPI 走 JS,必须在 JS 里判断,否则偏好为 reduce 的用户仍会看到满屏位移。
  2. 不要"完全无反馈"。把动画时长压到 0 会让点击像是没生效,保底给一个短促的透明度变化。
  3. 偏好是系统设置,可能在使用中变化。usePreferredReducedMotion 返回的是响应式 ref,用 computed 派生即可跟上变化。

7. 可访问性:闪烁频率与焦点

风险 阈值/要求 处理
闪烁诱发不适 一秒内闪烁不超过 3 次(WCAG 2.3.1 三闪阈值) 避免高频闪烁;必须闪时降到低频并允许关闭
无限循环动画 会分散注意力、耗电 加"暂停动效"开关或限次播放
动画中的焦点丢失 元素移动后 focus() 落在旧位置 动画开始前先移动焦点,或动画结束后重新 focus()
弹层出现的焦点陷阱 键盘用户被卡在弹层外 弹层动画结束(transitionend)后再 focus() 首个可聚焦元素
滚动劫持 破坏键盘与读屏导航 不用 CSS 强制 scroll-behavior 做劫持

焦点管理与动画的时序是容易被忽略的一处:如果 v-if 触发的弹层在动画期间就 focus(),元素可能仍在 opacity: 0 或位移态,部分浏览器会滚动到"错误位置"。稳妥做法是等过渡结束:

async function openDialog() {
  visible.value = true
  await nextTick()
  // 等过渡真正结束再聚焦,避免聚焦到中间态位置
  await new Promise((r) => setTimeout(r, 200))
  dialogEl.value?.querySelector<HTMLElement>('input')?.focus()
}

8. 性能分析:在 Performance 面板里看什么

观察项 说明 结论
帧率是否稳定 Frames 轨道出现红色长帧 掉帧,需定位
长任务落在哪个阶段 展开 Main 轨道看 Layout / Paint / Composite Layout 多 → 在动布局属性
Recalculate Style 频繁 每帧都在算样式 动画中改了大量类名或读写了布局属性
图层数量 Rendering 面板勾 Layer borders 图层过多 → will-change 被滥用
Paint flashing Rendering 面板开启 动画期间大面积重绘 → 换 transform/opacity

排查顺序:先确认掉帧 → 看长任务阶段 → 看是否在动布局属性 → 再看图层数量。绝大多数"动画卡"的根因都是第二、三步:在动 width/top/box-shadow,或每帧都在读 offsetHeight 触发强制同步布局。


9. Vue 内置过渡:<Transition> 与 <TransitionGroup>

<template>
  <Transition name="fade-slide" mode="out-in" appear :duration="{ enter: 220, leave: 160 }">
    <component :is="currentView" :key="currentView" />
  </Transition>

  <TransitionGroup name="list" tag="ul" appear>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </TransitionGroup>
</template>

<style>
.fade-slide-enter-active { transition: opacity 220ms ease, transform 220ms ease; }
.fade-slide-enter-from { opacity: 0; transform: translateY(6px); }
.fade-slide-leave-active { transition: opacity 160ms ease; }
.fade-slide-leave-to { opacity: 0; }
</style>
参数 作用 注意
name 决定生成的类名前缀 类名四件套:-enter-from/-enter-active/-leave-from/-leave-active
mode="out-in" 先出后进 避免两元素同时占位导致跳动,代价是总时长叠加
mode="in-out" 先进后出 少见,容易在高度变化时抖动
appear 初次渲染也播放 列表首屏需要入场时用
:duration 显式指定时长 内容高度不定、过渡无法靠 transitionend 判断时用
css: false 关闭 CSS 检测,走 JS 钩子 配 WAAPI/GSAP 时用
TransitionGroup 的 tag 包裹元素标签 不加 tag 则不渲染包裹节点

两条实践要点:mode="out-in" 的总时长是两段之和,切页时如果感觉"慢半拍",量一下两段时长而不是怪组件;TransitionGroup 的列表增删需要 .list-move 类才能有位移过渡效果,只写 enter/leave 的话相邻项会瞬移。


10. 排错:六个现象

现象 原因 处理
动画卡顿 在动布局属性 改 transform/opacity,或走 FLIP
加 will-change 后更卡 图层过多、显存压力大 只在动画期间挂,结束后 auto
JS 动画在 reduce 偏好下仍在播 只做了 CSS 媒体查询 JS 侧用 usePreferredReducedMotion 判断
TransitionGroup 删除时无过渡 缺 -leave-active 或 .list-move 补类名与 tag
切页时两元素同时闪 未设 mode 用 out-in
动画结束后布局抖动 用 transform 位移但没写回真实布局 动画结束同步数据/类名,或改用布局属性并接受开销

11. 边界:这些情况本就不该上动画库

场景 更合适 理由
悬停、显隐、状态切换 CSS transition 零依赖、零 JS
需要"能取消、能等结束" WAAPI 原生能力足够
滚动视差与多段联动 GSAP + ScrollTrigger 手写时间基准成本高
仅入场出现一次 CSS animation + @keyframes 不用 JS
长列表大量元素动画 虚拟滚动 + 减少动画数量 动画数量决定成本,不是库决定的
追求极致包体 不用库 动画库体积无法 tree-shake 到 0

一句话选择:能 CSS 就 CSS,要控制用 WAAPI,要编排用 GSAP,要跟状态绑定用声明式。并且无论选哪个,prefers-reduced-motion 都要给出降级路径。


12. 自测

  1. 判断"要不要动画库"的三个问题分别是什么?
  2. 为什么 width 和 box-shadow 的过渡比 transform 贵?
  3. will-change 长期挂着会发生什么?
  4. GSAP 相对 WAAPI 不可替代的三项能力是什么?
  5. 只做 CSS 媒体查询为什么不足以覆盖"减少动效"偏好?
  6. mode="out-in" 为什么会让切页显得慢,TransitionGroup 少了哪个类会瞬移?
S14-X08 · 动画库选型:CSS 过渡、GSAP 与减少动效偏好
https://www.clxhxhhr.top/posts/5718/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。