Vue3 过渡与动画完全指南
Vue3 过渡与动画完全指南
概述
Vue3 提供了 <Transition> 和 <TransitionGroup> 两个内置组件,用于在元素或组件显隐、状态变化时添加动画效果。配合 CSS transition 和 CSS animation,几乎所有场景的过渡动画都能以声明式方式实现,无需引入额外的动画库。
过渡的核心原理
Vue 的过渡系统会在元素进入或离开 DOM 时自动添加/移除 CSS 类名,开发者只需编写对应的 CSS 过渡样式。<Transition> 用于单个元素/组件的显隐切换;<TransitionGroup> 用于列表中多个元素的添加/移除/移动动画。
基础语法
// Transition:单个元素/组件的显隐过渡
<Transition name="fade">
<div v-if="show">内容</div>
</Transition>
// TransitionGroup:列表元素的移动/添加/删除动画
<TransitionGroup name="list">
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
</TransitionGroup>
Transition 触发的 CSS 类名(name="fade"):
- 进入:<code>fade-enter-from</code> → <code>fade-enter-active</code> → <code>fade-enter-to</code>
- 离开:<code>fade-leave-from</code> → <code>fade-leave-active</code> → <code>fade-leave-to</code>
完整代码示例
// ========== AnimationsDemo.vue ==========
<script setup>
import { ref, reactive } from 'vue'
// —— 基础显隐动画 ——
const showBasic = ref(true)
// —— 多元素切换动画 ——
const isLogin = ref(true)
// —— 列表动画 ——
const todos = reactive([
{ id: 1, text: '学习 Vue3 Transition', done: false },
{ id: 2, text: '掌握 CSS Animation', done: false },
{ id: 3, text: '完成项目实战', done: false },
])
const newTodo = ref('')
function addTodo() {
if (!newTodo.value.trim()) return
todos.push({
id: Date.now(),
text: newTodo.value.trim(),
done: false
})
newTodo.value = ''
}
function removeTodo(id) {
const index = todos.findIndex(t => t.id === id)
if (index !== -1) todos.splice(index, 1)
}
function toggleDone(id) {
const todo = todos.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
// —— 数字滚动动画 ——
const displayNumber = ref(0)
const targetNumber = ref(256)
const animating = ref(false)
function animateNumber() {
if (animating.value) return
animating.value = true
displayNumber.value = 0
const duration = 1500
const startTime = Date.now()
function step() {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// 缓动函数:easeOutCubic
const ease = 1 - Math.pow(1 - progress, 3)
displayNumber.value = Math.round(ease * targetNumber.value)
if (progress < 1) {
requestAnimationFrame(step)
} else {
animating.value = false
}
}
requestAnimationFrame(step)
}
// —— 模式切换动画(淡入+滑动)——
const currentMode = ref('view')
</script>
<template>
<div class="animation-demo">
<!-- ==================== 1. 基础显隐 ==================== -->
<section>
<h3>1. 基础显隐动画(Fade)</h3>
<button @click="showBasic = !showBasic">切换</button>
<Transition name="fade">
<div v-if="showBasic" class="box box-primary">淡入淡出</div>
</Transition>
</section>
<!-- ==================== 2. 滑动 + 缩放 ==================== -->
<section>
<h3>2. 滑动 + 缩放组合动画</h3>
<button @click="isLogin = !isLogin">
{{ isLogin ? '切换到注册' : '切换到登录' }}
</button>
<div class="form-container">
<Transition name="slide-up" mode="out-in">
<div v-if="isLogin" key="login" class="form-box">
<h4>登录</h4>
<input placeholder="用户名" />
<input placeholder="密码" type="password" />
</div>
<div v-else key="register" class="form-box">
<h4>注册</h4>
<input placeholder="邮箱" />
<input placeholder="密码" type="password" />
<input placeholder="确认密码" type="password" />
</div>
</Transition>
</div>
</section>
<!-- ==================== 3. 列表过渡 ==================== -->
<section>
<h3>3. 待办事项列表(列表过渡动画)</h3>
<div class="todo-input">
<input v-model="newTodo" @keydown.enter="addTodo" placeholder="添加新待办..." />
<button @click="addTodo">添加</button>
</div>
<TransitionGroup name="todo-list" tag="ul" class="todo-list">
<li
v-for="todo in todos"
:key="todo.id"
:class="{ done: todo.done }"
@click="toggleDone(todo.id)"
>
<span>{{ todo.text }}</span>
<button @click.stop="removeTodo(todo.id)">×</button>
</li>
</TransitionGroup>
<p style="font-size: 0.8rem; color: #888;">
tip: 点击条目切换完成状态,点击 × 删除
</p>
</section>
<!-- ==================== 4. 数字动画 ==================== -->
<section>
<h3>4. 数字滚动动画</h3>
<div class="number-display">{{ displayNumber }}</div>
<button @click="animateNumber" :disabled="animating">
{{ animating ? '动画中...' : '触发动画' }}
</button>
</section>
</div>
</template>
<style scoped>
/* ==================== 1. Fade 动画 ==================== */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ==================== 2. 滑动 + 缩放组合 ==================== */
.slide-up-enter-active {
transition: all 0.4s ease-out;
}
.slide-up-leave-active {
transition: all 0.3s ease-in;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-30px) scale(0.95);
}
/* ==================== 3. 列表过渡动画 ==================== */
.todo-list {
list-style: none;
padding: 0;
position: relative;
}
.todo-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
margin: 0.25rem 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.todo-list li.done span {
text-decoration: line-through;
color: #999;
}
.todo-list li button {
background: #e74c3c;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
/* 入场:从下方滑入 + 淡入 */
.todo-list-enter-active {
transition: all 0.4s ease-out;
}
.todo-list-leave-active {
transition: all 0.3s ease-in;
position: absolute; /* 离开时脱离文档流,为 move 动画让位 */
width: 100%;
}
/* 入场起点 */
.todo-list-enter-from {
opacity: 0;
transform: translateX(-30px);
}
/* 离场终点 */
.todo-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 列表移动动画(添加/删除时其余元素平滑移动)*/
.todo-list-move {
transition: transform 0.4s ease;
}
/* ==================== 通用 ==================== */
section {
margin: 1.5rem 0;
padding: 1rem;
border: 1px solid #eee;
border-radius: 8px;
}
.box {
width: 150px;
height: 80px;
margin-top: 0.5rem;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.box-primary { background: #42b983; }
.form-container { margin-top: 0.5rem; }
.form-box {
padding: 1rem;
border: 1px solid #42b983;
border-radius: 8px;
background: #f8fff8;
}
.form-box h4 { margin: 0 0 0.5rem; }
.form-box input {
display: block;
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.todo-input {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.todo-input input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.todo-input button {
padding: 0.5rem 1rem;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.number-display {
font-size: 3rem;
font-weight: bold;
color: #42b983;
font-variant-numeric: tabular-nums;
}
</style>
运行效果说明
基础显隐点击切换时内容平滑淡入淡出。登录/注册表单切换时使用 mode="out-in" 确保一个离开完成后再入场新的,配合 translateY 实现上下滑动。列表添加/删除时,<TransitionGroup> 自动计算位置变化并添加 move 动画,其余条目平滑过渡。数字滚动点击后数字从 0 平滑增长到 256,采用 easeOutCubic 缓动曲线使动画自然减速。
常见问题
Q1:<code><Transition></code> 的 <code>mode</code> 属性有什么作用?
mode 可选值:"in-out"(新元素先完成入场,再移除旧元素)和 "out-in"(旧元素先完成离场,再渲染新元素)。不设置 mode 时,新旧元素同时执行过渡,适合非互斥的显隐场景。登录/注册切换适合 "out-in",避免两个表单同时存在于 DOM 中造成布局混乱。
Q2:<code><TransitionGroup></code> 中删除元素时其余元素抖动/不流畅怎么办?
常见原因:删除元素的容器没有设置 position: relative。由于 move 动画依赖 Transform 实现平滑移动,容器必须有明确的高度约束。另一个常见问题:列表的 :key 使用了数组索引 index,导致 Vue 无法正确追踪元素身份,应始终使用唯一 ID 作为 :key。
Q3:Vue 过渡动画和第三方动画库(如 GSAP)如何结合?
将 GSAP 的 gsap.from() / gsap.to() 与 Vue 的 JS 过渡钩子结合使用。在 <Transition> 的 @enter / @leave 钩子中调用 GSAP 方法,完全控制动画细节,实现 Vue 声明式模板 + GSAP 高性能动画的组合。GSAP 还支持 FLIP(First-Last-Invert-Play)技术处理复杂列表重排动画。
延伸阅读
- <a href="#">Vue3 组合式 API 完全指南</a> —— 组合式 API 与动画组件的结合
- <a href="#">Vue3 生命周期钩子详解</a> —— 在钩子中集成第三方动画初始化逻辑
- <a href="#">Vue3 自定义指令完全指南</a> —— 用自定义指令实现复杂 DOM 动画场景