跳到主要内容

Vue 过渡与动画

Vue 提供了两个内置组件来处理状态变化时的过渡和动画效果:<Transition><TransitionGroup>。这两个组件让你能够以声明式的方式为应用的交互增添流畅的视觉反馈,提升用户体验。

为什么需要过渡动画?

在用户界面中,状态的突然变化往往会让人感到突兀。例如,一个元素突然消失或出现,会让用户难以追踪发生了什么。过渡动画的作用就是弥补这种视觉上的断层:

  • 引导注意力:动画可以告诉用户"注意,这里发生了变化"
  • 建立空间关系:元素从哪里来、到哪里去,动画能让用户理解变化的前后关系
  • 提供反馈:操作后的动画反馈让用户确认操作已被执行
  • 提升体验:流畅的动画让应用感觉更加精致和专业

Transition 组件

<Transition> 是一个内置组件,无需注册即可在任何组件的模板中使用。它可以为进入和离开 DOM 的元素或组件应用动画效果。

触发过渡的条件

以下场景会触发过渡效果:

  • 使用 v-if 进行条件渲染
  • 使用 v-show 进行条件显示
  • 使用 <component> 动态切换组件
  • 改变特殊的 key 属性

基本用法

<script setup>
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button @click="show = !show">切换</button>

<Transition>
<p v-if="show">Hello Vue!</p>
</Transition>
</template>

<style>
/* 进入和离开的过渡效果 */
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}

.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

过渡是如何工作的?

<Transition> 中的元素被插入或移除时,Vue 会执行以下步骤:

  1. 检测过渡类型:Vue 会自动检测目标元素是否应用了 CSS 过渡或动画
  2. 添加/移除类名:在适当的时机添加和移除 CSS 过渡类
  3. 调用 JavaScript 钩子:如果提供了 JavaScript 钩子监听器,会在适当的时机调用
  4. 立即执行:如果没有检测到 CSS 过渡/动画,也没有提供 JavaScript 钩子,DOM 的插入/移除操作将在下一帧立即执行
重要提示

<Transition> 只支持单个元素或组件作为插槽内容。如果内容是组件,该组件也必须有且只有一个根元素。如果需要过渡多个元素,可以使用 <TransitionGroup> 或嵌套多个 <Transition>

CSS 过渡类详解

六个过渡类

Vue 为进入和离开过渡提供了六个 CSS 类。理解每个类的添加和移除时机,是掌握 Vue 过渡系统的关键:

进入过渡(Enter)

类名时机用途
v-enter-from元素插入添加,插入一帧后移除定义进入的起始状态
v-enter-active元素插入添加,过渡结束后移除定义进入过渡的持续时间、缓动函数
v-enter-to元素插入一帧后添加,过渡结束后移除定义进入的结束状态

离开过渡(Leave)

类名时机用途
v-leave-from离开触发时立即添加,一帧后移除定义离开的起始状态
v-leave-active离开触发时立即添加,过渡结束后移除定义离开过渡的持续时间、缓动函数
v-leave-to离开触发一帧后添加,过渡结束后移除定义离开的结束状态

类的应用时序图

进入过渡时间线:
┌─────────────────────────────────────────────────────────────────┐
│ 时间点 v-enter-from v-enter-active v-enter-to │
├─────────────────────────────────────────────────────────────────┤
│ 插入前 ✓ 添加 ✓ 添加 ✗ │
│ 插入后第1帧 ✗ 移除 ✓ 保持 ✓ 添加 │
│ 过渡进行中 ✗ ✓ 保持 ✓ 保持 │
│ 过渡结束 ✗ ✗ 移除 ✗ 移除 │
└─────────────────────────────────────────────────────────────────┘

离开过渡时间线:
┌─────────────────────────────────────────────────────────────────┐
│ 时间点 v-leave-from v-leave-active v-leave-to │
├─────────────────────────────────────────────────────────────────┤
│ 离开触发时 ✓ 添加 ✓ 添加 ✗ │
│ 触发后第1帧 ✗ 移除 ✓ 保持 ✓ 添加 │
│ 过渡进行中 ✗ ✓ 保持 ✓ 保持 │
│ 过渡结束 ✗ ✗ 移除 ✗ 移除 │
└─────────────────────────────────────────────────────────────────┘

为什么需要三个类?

你可能会疑惑,为什么需要三个类而不是两个?这种设计源于 CSS 过渡的工作原理:

  1. *-from:定义过渡开始时的状态,仅在第一帧存在
  2. *-active:全程存在,用于定义 transition 属性(持续时间、缓动函数)
  3. *-to:定义过渡结束时的状态

这种分离让你可以为进入和离开设置不同的持续时间、延迟和缓动函数:

<script setup>
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button @click="show = !show">切换</button>

<Transition name="slide-fade">
<p v-if="show">滑动淡入淡出效果</p>
</Transition>
</template>

<style>
/* 进入过渡:快速滑入 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}

/* 离开过渡:慢速滑出 */
.slide-fade-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}

.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>

命名过渡

通过 name 属性给过渡命名,CSS 类名会以名称为前缀。这对于在一个组件中使用多个不同的过渡效果很有用:

<Transition name="fade">
<p v-if="show">淡入淡出</p>
</Transition>

<style>
/* 类名变为 fade- 前缀,而不是默认的 v- */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

命名规范建议:使用描述性的名称,如 fadeslidezoombounce 等,这样从 CSS 类名就能直观地知道过渡效果是什么样的。

CSS 动画

CSS 动画的使用方式与 CSS 过渡相同,但有一个关键区别:*-enter-from 类不会在元素插入后立即移除,而是在 animationend 事件触发后才移除。

这种差异是因为 CSS 动画通常需要从头播放到尾,而过渡只需要一个起始状态和一个结束状态。

<script setup>
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button @click="show = !show">切换</button>

<Transition name="bounce">
<p v-if="show" class="bounce-text">弹跳动画效果!</p>
</Transition>
</template>

<style>
.bounce-enter-active {
animation: bounce-in 0.5s;
}

.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
0% {
transform: scale(0);
}
50% {
transform: scale(1.25);
}
100% {
transform: scale(1);
}
}

.bounce-text {
text-align: center;
font-size: 24px;
}
</style>

过渡与动画的选择

特性CSS 过渡CSS 动画
复杂度简单,适合单一状态变化复杂,适合多帧动画
控制粒度只能控制开始和结束可以定义多个关键帧
循环需要额外设置原生支持循环
使用场景简单的显隐、位移动画复杂的弹跳、脉冲效果

自定义过渡类名

可以通过以下属性自定义过渡类名,这在与第三方动画库(如 Animate.css)集成时特别有用:

<!-- 与 Animate.css 集成 -->
<Transition
name="custom"
enter-active-class="animate__animated animate__tada"
leave-active-class="animate__animated animate__bounceOutRight"
>
<p v-if="show">使用 Animate.css 动画</p>
</Transition>

可用的自定义类名属性

属性对应的默认类
enter-from-classv-enter-from
enter-active-classv-enter-active
enter-to-classv-enter-to
leave-from-classv-leave-from
leave-active-classv-leave-active
leave-to-classv-leave-to

同时使用过渡和动画

当同一个元素同时使用了 CSS 过渡和动画时,Vue 需要知道应该监听哪种事件来判断过渡何时结束。默认情况下,Vue 会等待 transitionendanimationend 中先触发的事件,但这可能不是你想要的行为。

通过 type 属性可以明确指定 Vue 应该监听的事件类型:

<!-- 明确指定监听动画结束事件 -->
<Transition type="animation">
<p v-if="show">动画优先</p>
</Transition>

<!-- 明确指定监听过渡结束事件 -->
<Transition type="transition">
<p v-if="show">过渡优先</p>
</Transition>

典型场景:当元素有 Vue 触发的动画,同时又有 hover 状态的 CSS 过渡效果时,需要明确指定类型。

嵌套过渡与显式时长

虽然过渡类只应用于 <Transition> 的直接子元素,但可以使用嵌套 CSS 选择器来过渡嵌套元素:

<script setup>
import { ref } from 'vue'

const show = ref(true)
</script>

<template>
<button @click="show = !show">切换</button>

<Transition name="nested" :duration="550">
<div v-if="show" class="outer">
<div class="inner">嵌套过渡</div>
</div>
</Transition>
</template>

<style>
.outer,
.inner {
background: #eee;
padding: 30px;
min-height: 100px;
}

.inner {
background: #ccc;
}

/* 外层元素过渡 */
.nested-enter-active,
.nested-leave-active {
transition: all 0.3s ease-in-out;
}

.nested-enter-from,
.nested-leave-to {
transform: translateY(30px);
opacity: 0;
}

/* 内层元素过渡 */
.nested-enter-active .inner,
.nested-leave-active .inner {
transition: all 0.3s ease-in-out;
}

.nested-enter-from .inner,
.nested-leave-to .inner {
transform: translateX(30px);
opacity: 0;
}

/* 内层元素延迟进入,形成交错效果 */
.nested-enter-active .inner {
transition-delay: 0.25s;
}
</style>

duration 属性

默认情况下,<Transition> 会监听根元素上的 transitionendanimationend 事件来判断过渡何时结束。但在嵌套过渡场景中,你希望等待所有内部元素的过渡都完成后才认为过渡结束。

duration 属性用于显式指定过渡时长(毫秒):

<!-- 统一时长 -->
<Transition :duration="1000">...</Transition>

<!-- 分别指定进入和离开时长 -->
<Transition :duration="{ enter: 500, leave: 800 }">...</Transition>

计算 duration 的技巧:如果内层元素有 transition-delay: 0.25stransition-duration: 0.3s,那么总时长应该是 0.25 + 0.3 = 0.55s = 550ms

JavaScript 钩子

可以通过监听 <Transition> 组件上的事件来使用 JavaScript 控制过渡。这种方式提供了比纯 CSS 更精细的控制能力。

可用的钩子事件

事件说明参数
before-enter元素插入 DOM 前调用el
enter元素插入 DOM 一帧后调用el, done
after-enter进入过渡完成时调用el
enter-cancelled进入过渡被取消时调用el
before-leave离开过渡开始前调用el
leave离开过渡开始时调用el, done
after-leave离开过渡完成且元素已移除时调用el
leave-cancelled离开过渡被取消时调用(仅 v-show)el

钩子函数的执行流程

进入流程:
before-enter → [元素插入DOM] → enter → [过渡进行] → after-enter

enter-cancelled(如果被取消)

离开流程:
before-leave → leave → [过渡进行] → after-leave → [元素移除DOM]

leave-cancelled(如果被取消,仅 v-show)

基本用法示例

<script setup>
import { ref } from 'vue'

const show = ref(true)

// 进入过渡钩子
function onBeforeEnter(el) {
// 在元素插入 DOM 前设置初始状态
// 相当于设置 v-enter-from 的样式
el.style.opacity = '0'
el.style.transform = 'translateX(-100px)'
}

function onEnter(el, done) {
// 开始进入动画
// done 是回调函数,动画完成后必须调用
requestAnimationFrame(() => {
el.style.transition = 'all 0.5s ease'
el.style.opacity = '1'
el.style.transform = 'translateX(0)'
})

// 动画结束后调用 done
el.addEventListener('transitionend', done)
}

function onAfterEnter(el) {
console.log('进入过渡完成')
// 可以在这里做一些清理工作
}

// 离开过渡钩子
function onBeforeLeave(el) {
// 离开过渡开始前的准备工作
}

function onLeave(el, done) {
// 开始离开动画
requestAnimationFrame(() => {
el.style.transition = 'all 0.5s ease'
el.style.opacity = '0'
el.style.transform = 'translateX(100px)'
})

el.addEventListener('transitionend', done)
}

function onAfterLeave(el) {
console.log('离开过渡完成')
}
</script>

<template>
<button @click="show = !show">切换</button>

<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<p v-if="show">JavaScript 控制的过渡</p>
</Transition>
</template>

使用 GSAP 等动画库

JavaScript 钩子非常适合与专业的动画库配合使用:

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)

function onEnter(el, done) {
gsap.fromTo(el,
{ opacity: 0, y: -50 },
{
opacity: 1,
y: 0,
duration: 0.5,
onComplete: done
}
)
}

function onLeave(el, done) {
gsap.fromTo(el,
{ opacity: 1, y: 0 },
{
opacity: 0,
y: 50,
duration: 0.5,
onComplete: done
}
)
}
</script>

<template>
<button @click="show = !show">切换</button>

<Transition
:css="false"
@enter="onEnter"
@leave="onLeave"
>
<p v-if="show">GSAP 动画过渡</p>
</Transition>
</template>

:css="false" 的作用

当只使用 JavaScript 过渡时,建议添加 :css="false" 属性:

  1. 跳过 CSS 过渡检测:Vue 不会尝试检测 CSS 过渡,性能更好
  2. 防止 CSS 干扰:避免现有的 CSS 规则意外影响过渡效果
  3. 明确意图:让代码读者清楚这是一个纯 JavaScript 过渡
注意

使用 :css="false" 时,@enter@leave 钩子中的 done 回调必须被调用,否则过渡永远不会结束,元素会卡在中间状态。

JavaScript 钩子与 CSS 结合

JavaScript 钩子可以与 CSS 过渡同时使用。例如,你可以用 CSS 定义过渡效果,用 JavaScript 钩子在特定时机执行额外逻辑:

<script setup>
import { ref } from 'vue'

const show = ref(true)

function onAfterEnter(el) {
// 过渡完成后执行一些操作
el.classList.add('highlight')
setTimeout(() => el.classList.remove('highlight'), 1000)
}
</script>

<template>
<Transition
name="fade"
@after-enter="onAfterEnter"
>
<p v-if="show">CSS过渡 + JS钩子</p>
</Transition>
</template>

过渡模式

默认行为的问题

当在 v-ifv-else 之间切换时,默认情况下进入和离开会同时发生:

<Transition>
<button v-if="isEditing" key="save">保存</button>
<button v-else key="edit">编辑</button>
</Transition>

这可能导致布局问题:两个按钮同时存在时,可能会挤在一起或位置跳动。常见的解决方案是使用 position: absolute,但这并不总是可行或理想的。

mode 属性

mode 属性可以控制进入和离开的顺序:

模式行为
out-in当前元素先离开,新元素在离开完成后进入
in-out新元素先进入,当前元素在进入完成后离开

out-in 是最常用的模式:

<script setup>
import { ref, computed } from 'vue'

const docState = ref('saved')

const nextState = computed(() => {
const states = ['saved', 'edited', 'editing']
const currentIndex = states.indexOf(docState.value)
return states[(currentIndex + 1) % states.length]
})
</script>

<template>
<button @click="docState = nextState">切换状态</button>

<!-- out-in:离开完成后才进入 -->
<Transition name="fade" mode="out-in">
<button v-if="docState === 'saved'" key="saved">编辑</button>
<button v-else-if="docState === 'edited'" key="edited">保存</button>
<button v-else key="editing">取消</button>
</Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

in-out 模式的使用场景

in-out 模式较少使用,但在某些场景下很有用:

<!-- 新元素先进入,然后旧元素离开 -->
<!-- 适合:新元素覆盖旧元素的场景 -->
<Transition mode="in-out">
<div :key="currentSlide" class="slide">
{{ currentSlide }}
</div>
</Transition>

初始渲染过渡

默认情况下,元素的初始渲染不会触发过渡。如果想给初始渲染也添加过渡,可以使用 appear 属性:

<Transition appear>
<p>初始渲染时也会触发过渡</p>
</Transition>

appear 属性可以和自定义类名配合使用:

<Transition
appear
appear-from-class="custom-appear-from"
appear-to-class="custom-appear-to"
appear-active-class="custom-appear-active"
>
<p>自定义初始渲染过渡</p>
</Transition>

使用场景:页面首次加载时,让元素以动画方式出现,而不是突然显示。

动态过渡

过渡的属性可以是动态的,根据状态改变应用不同的过渡效果。

动态 name 属性

<script setup>
import { ref } from 'vue'

const show = ref(true)
const transitionType = ref('fade')

const transitionNames = {
fade: 'fade',
slide: 'slide-fade',
zoom: 'zoom'
}
</script>

<template>
<select v-model="transitionType">
<option value="fade">淡入淡出</option>
<option value="slide">滑动</option>
<option value="zoom">缩放</option>
</select>

<button @click="show = !show">切换</button>

<Transition :name="transitionNames[transitionType]">
<p v-if="show">动态过渡效果</p>
</Transition>
</template>

<style>
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

/* 滑动 */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}

/* 缩放 */
.zoom-enter-active,
.zoom-leave-active {
transition: all 0.3s ease;
}
.zoom-enter-from,
.zoom-leave-to {
transform: scale(0.9);
opacity: 0;
}
</style>

在 JavaScript 钩子中根据状态调整

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)
const direction = ref('right')

function onEnter(el, done) {
const translateX = direction.value === 'right' ? 100 : -100

gsap.fromTo(el,
{ opacity: 0, x: translateX },
{ opacity: 1, x: 0, duration: 0.3, onComplete: done }
)
}

function onLeave(el, done) {
const translateX = direction.value === 'right' ? -100 : 100

gsap.to(el,
{ opacity: 0, x: translateX, duration: 0.3, onComplete: done }
)
}
</script>

<template>
<button @click="direction = direction === 'right' ? 'left' : 'right'">
方向: {{ direction }}
</button>
<button @click="show = !show">切换</button>

<Transition :css="false" @enter="onEnter" @leave="onLeave">
<p v-if="show">根据方向变化的过渡</p>
</Transition>
</template>

使用 key 强制过渡

有时候需要强制元素重新渲染来触发过渡。通过改变 key 属性,Vue 会创建新元素,从而触发过渡:

<script setup>
import { ref } from 'vue'

const count = ref(0)

// 每秒递增
setInterval(() => count.value++, 1000)
</script>

<template>
<Transition name="slide-up" mode="out-in">
<span :key="count" class="counter">{{ count }}</span>
</Transition>
</template>

<style>
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.25s ease-out;
}

.slide-up-enter-from {
opacity: 0;
transform: translateY(30px);
}

.slide-up-leave-to {
opacity: 0;
transform: translateY(-30px);
}

.counter {
display: inline-block;
font-size: 48px;
font-weight: bold;
}
</style>

原理:当 key 变化时,Vue 会认为这是一个全新的元素,旧的元素会被移除(触发离开过渡),新元素会被插入(触发进入过渡)。如果不使用 key,Vue 只会更新文本节点,不会触发过渡。

可复用过渡

可以创建一个封装过渡效果的组件来复用:

<!-- components/FadeTransition.vue -->
<script setup>
defineProps({
duration: {
type: Number,
default: 300
}
})
</script>

<template>
<Transition
name="fade"
:style="{ '--duration': `${duration}ms` }"
>
<slot />
</Transition>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--duration) ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<!-- 使用可复用过渡 -->
<script setup>
import { ref } from 'vue'
import FadeTransition from './components/FadeTransition.vue'

const show = ref(true)
</script>

<template>
<button @click="show = !show">切换</button>

<FadeTransition :duration="500">
<p v-if="show">使用可复用过渡组件</p>
</FadeTransition>
</template>

带JavaScript钩子的可复用过渡

<!-- components/SlideTransition.vue -->
<script setup>
import gsap from 'gsap'

const props = defineProps({
direction: {
type: String,
default: 'left',
validator: (v) => ['left', 'right', 'up', 'down'].includes(v)
},
duration: {
type: Number,
default: 0.3
}
})

const directions = {
left: { enter: -100, leave: 100, axis: 'x' },
right: { enter: 100, leave: -100, axis: 'x' },
up: { enter: -100, leave: 100, axis: 'y' },
down: { enter: 100, leave: -100, axis: 'y' }
}

function onEnter(el, done) {
const { enter, axis } = directions[props.direction]
const from = { opacity: 0, [axis]: enter }
const to = { opacity: 1, [axis]: 0 }

gsap.fromTo(el, from, { ...to, duration: props.duration, onComplete: done })
}

function onLeave(el, done) {
const { leave, axis } = directions[props.direction]
const to = { opacity: 0, [axis]: leave }

gsap.to(el, { ...to, duration: props.duration, onComplete: done })
}
</script>

<template>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<slot />
</Transition>
</template>

TransitionGroup 组件

<TransitionGroup> 用于为列表中的元素添加插入、移除和重排序的过渡效果。

与 Transition 的区别

特性TransitionTransitionGroup
内容单个元素/组件列表元素
包装元素可选(通过 tag 指定)
过渡模式支持 mode不支持
key 属性可选必须
过渡类应用目标直接子元素列表中的每个元素
FLIP 动画不支持支持(移动过渡)

基本用法

<script setup>
import { ref } from 'vue'

const items = ref([1, 2, 3, 4, 5])

function addItem() {
const newId = Math.max(...items.value) + 1
items.value.push(newId)
}

function removeItem(index) {
items.value.splice(index, 1)
}
</script>

<template>
<div class="list-demo">
<button @click="addItem">添加</button>

<TransitionGroup name="list" tag="ul" class="list">
<li
v-for="(item, index) in items"
:key="item"
class="list-item"
>
{{ item }}
<button @click="removeItem(index)">删除</button>
</li>
</TransitionGroup>
</div>
</template>

<style>
.list {
list-style: none;
padding: 0;
}

.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
margin: 5px 0;
background: #f0f0f0;
border-radius: 4px;
}

/* 进入和离开过渡 */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

列表移动过渡(FLIP 动画)

上面的例子有个问题:当元素插入或移除时,周围的元素会立即"跳"到新位置,而不是平滑移动。添加移动过渡可以解决:

<script setup>
import { ref } from 'vue'

const items = ref([1, 2, 3, 4, 5, 6, 7, 8, 9])

function shuffle() {
items.value = items.value.sort(() => Math.random() - 0.5)
}

function removeItem(index) {
items.value.splice(index, 1)
}
</script>

<template>
<button @click="shuffle">随机排序</button>

<TransitionGroup name="list" tag="div" class="container">
<div
v-for="(item, index) in items"
:key="item"
class="item"
@click="removeItem(index)"
>
{{ item }}
</div>
</TransitionGroup>
</template>

<style>
.container {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.item {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
background: #42b983;
color: white;
border-radius: 8px;
cursor: pointer;
}

/* 进入、离开和移动过渡 */
.list-move, /* 移动过渡 */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.5);
}

/* 离开的元素使用绝对定位,让其他元素可以平滑移动 */
.list-leave-active {
position: absolute;
}
</style>

FLIP 动画原理:Vue 使用 FLIP(First, Last, Invert, Play)技术来实现平滑的位置移动动画:

  1. First:记录元素在移动前的位置
  2. Last:记录元素在移动后的位置
  3. Invert:计算位置差,使用 transform 将元素"反向"移动到原位置
  4. Play:移除 transform,让元素"播放"到新位置
关键点

要让移动过渡生效,离开的元素必须使用 position: absolute 脱离文档流,这样 Vue 才能正确计算其他元素的移动轨迹。

交错列表过渡

通过 JavaScript 钩子和数据属性,可以实现列表元素的交错动画效果:

<script setup>
import { ref, computed } from 'vue'
import gsap from 'gsap'

const query = ref('')

const computedList = computed(() => {
const allItems = [
{ msg: 'Bruce Lee' },
{ msg: 'Jackie Chan' },
{ msg: 'Chuck Norris' },
{ msg: 'Jet Li' },
{ msg: 'Kung Fury' }
]

return allItems.filter(item =>
item.msg.toLowerCase().includes(query.value.toLowerCase())
)
})

function onBeforeEnter(el) {
el.style.opacity = '0'
el.style.height = '0'
}

function onEnter(el, done) {
// 根据索引延迟动画,创建交错效果
const delay = el.dataset.index * 150

gsap.to(el, {
opacity: 1,
height: '2em',
delay: delay / 1000, // gsap 使用秒
duration: 0.5,
onComplete: done
})
}

function onLeave(el, done) {
const delay = el.dataset.index * 150

gsap.to(el, {
opacity: 0,
height: 0,
delay: delay / 1000,
duration: 0.5,
onComplete: done
})
}
</script>

<template>
<input v-model="query" placeholder="搜索..." />

<TransitionGroup
tag="ul"
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
class="list"
>
<li
v-for="(item, index) in computedList"
:key="item.msg"
:data-index="index"
class="item"
>
{{ item.msg }}
</li>
</TransitionGroup>
</template>

<style>
.list {
list-style: none;
padding: 0;
}

.item {
padding: 10px;
margin: 5px 0;
background: #f0f0f0;
border-radius: 4px;
}

input {
width: 100%;
padding: 8px;
margin-bottom: 10px;
}
</style>

性能优化

选择合适的动画属性

动画属性的选择直接影响性能:

推荐(高性能)不推荐(低性能)
transformwidth / height
opacitymargin / padding
filter(部分)top / left

为什么 transform 和 opacity 性能更好?

  1. 不影响布局transformopacity 不会触发浏览器的重新布局(reflow),只触发重绘(repaint)或合成(composite)
  2. GPU 加速:大多数现代浏览器可以利用 GPU 硬件加速来处理 transformopacity 动画
  3. 独立图层:这些属性可以创建独立的合成图层,避免影响其他元素

使用 will-change 提示浏览器

对于复杂的动画,可以提前告知浏览器:

.animated-element {
will-change: transform, opacity;
}
注意

不要过度使用 will-change,它本身会消耗内存。只在确实需要复杂动画的元素上使用。

避免动画引起的布局抖动

/* 避免在动画中使用会引起重排的属性 */
.bad {
animation: bad 0.3s;
}

@keyframes bad {
from { width: 100px; } /* 触发重排 */
to { width: 200px; }
}

/* 使用 transform 替代 */
.good {
animation: good 0.3s;
}

@keyframes good {
from { transform: scaleX(1); }
to { transform: scaleX(2); }
}

尊重用户的动画偏好

越来越多的用户在系统中设置了"减少动画"的偏好。通过 CSS 媒体查询可以尊重这一设置:

@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

在 JavaScript 中检测:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')

if (prefersReducedMotion.matches) {
// 用户偏好减少动画,使用更简单的过渡
}

实战示例:模态框

一个完整的模态框组件,结合 <Transition><Teleport>

<script setup>
import { ref } from 'vue'

const showModal = ref(false)

function openModal() {
showModal.value = true
}

function closeModal() {
showModal.value = false
}

// 点击遮罩关闭
function onOverlayClick() {
closeModal()
}

// 阻止内容区点击冒泡
function onContentClick(e) {
e.stopPropagation()
}
</script>

<template>
<button @click="openModal">打开模态框</button>

<Teleport to="body">
<Transition name="modal">
<div v-if="showModal" class="modal-overlay" @click="onOverlayClick">
<Transition name="modal-content">
<div v-if="showModal" class="modal-content" @click="onContentClick">
<h2>模态框标题</h2>
<p>这是模态框的内容</p>
<button @click="closeModal">关闭</button>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>

<style>
/* 遮罩层过渡 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}

.modal-enter-from,
.modal-leave-to {
opacity: 0;
}

.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}

/* 内容过渡 */
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.3s ease;
}

.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}

.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}

.modal-content h2 {
margin-top: 0;
}

.modal-content button {
margin-top: 10px;
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}

.modal-content button:hover {
background: #3aa876;
}
</style>

实战示例:标签页切换

<script setup>
import { ref, shallowRef } from 'vue'
import Home from './tabs/Home.vue'
import Posts from './tabs/Posts.vue'
import Archive from './tabs/Archive.vue'

const currentTab = shallowRef(Home)
const tabs = {
Home,
Posts,
Archive
}
const currentTabName = ref('Home')
</script>

<template>
<div class="tabs-demo">
<!-- 标签按钮 -->
<div class="tab-buttons">
<button
v-for="(component, name) in tabs"
:key="name"
:class="['tab-button', { active: currentTabName === name }]"
@click="currentTab = component; currentTabName = name"
>
{{ name }}
</button>
</div>

<!-- 标签内容 -->
<div class="tab-content">
<Transition name="fade-slide" mode="out-in">
<component :is="currentTab" :key="currentTabName" />
</Transition>
</div>
</div>
</template>

<style>
.tabs-demo {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}

.tab-buttons {
display: flex;
border-bottom: 1px solid #ddd;
background: #f5f5f5;
}

.tab-button {
padding: 10px 20px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}

.tab-button:hover {
background: #e8e8e8;
}

.tab-button.active {
background: white;
border-bottom: 2px solid #42b983;
color: #42b983;
}

.tab-content {
padding: 20px;
min-height: 200px;
position: relative;
}

.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}

.fade-slide-enter-from {
opacity: 0;
transform: translateX(20px);
}

.fade-slide-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>

小结

本章我们详细学习了 Vue 过渡与动画的完整内容:

  1. Transition 组件:单个元素的进入/离开过渡
  2. 六个过渡类:理解每个类的添加和移除时机
  3. CSS 过渡和动画:两种实现方式及其差异
  4. 自定义类名:与第三方动画库集成
  5. JavaScript 钩子:精细控制过渡过程
  6. 过渡模式out-inin-out 控制顺序
  7. 动态过渡:根据状态应用不同效果
  8. TransitionGroup:列表的过渡效果
  9. 移动过渡:FLIP 动画实现平滑的位置变化
  10. 性能优化:选择合适的属性、尊重用户偏好

练习

  1. 创建一个可复用的淡入淡出过渡组件,支持自定义时长
  2. 实现一个带有交错动画的列表组件
  3. 创建一个支持多个方向的滑动过渡组件
  4. 实现一个购物车列表,添加和删除商品时有平滑的动画效果

参考资源