动画的选型问题通常问错了方向:"用 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 场景 |
三条要点:
- 媒体查询是兜底,不是全部。CSS 里改掉
transition-duration只能管 CSS 动画;GSAP 与 WAAPI 走 JS,必须在 JS 里判断,否则偏好为reduce的用户仍会看到满屏位移。 - 不要"完全无反馈"。把动画时长压到 0 会让点击像是没生效,保底给一个短促的透明度变化。
- 偏好是系统设置,可能在使用中变化。
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. 自测
- 判断"要不要动画库"的三个问题分别是什么?
- 为什么
width和box-shadow的过渡比transform贵? will-change长期挂着会发生什么?- GSAP 相对 WAAPI 不可替代的三项能力是什么?
- 只做 CSS 媒体查询为什么不足以覆盖"减少动效"偏好?
mode="out-in"为什么会让切页显得慢,TransitionGroup少了哪个类会瞬移?