第5章 5.4 动画与过渡
⏱️ 阅读本文约需 90 分钟,建议边读边敲代码
🎯 开场 3 分钟:为什么要学这个?
上一章我们学会了用 Sass/Less 让 CSS 写起来更爽,这一章我们要用它解决一个更直观的问题——怎么让界面「动」起来。
你有没有遇到过这些情况?
- 网页切换时太生硬——按钮点了没反应,或者列表突然蹦出来吓你一跳
- 想做个loading动画——但不知道从哪里下手
- 别人做的界面很流畅——而你的总是一卡一卡的,像 PPT 翻页
学完这一章,你能:
- 让 Vue 组件优雅地出现和消失(而不是突然蹦出来)
- 用
@keyframes做出自定义动画(不只是网上抄的特效) - 用
Transition和TransitionGroup管理列表的增删动画
说白了,这一章就是让你的网页从「静态图」变成「小动画」的关键技能。
🧱 基础 25 分钟:核心概念
5.4.1 先搞清楚:什么是过渡?什\n\n
\n\n
\n\n么是动画?
过渡(Transition):从一个状态慢慢变到另一个状态。比如门的「慢慢关上」就是过渡。
动画(Animation):可以自己做一套复杂的动作序列。比如门的「慢慢关上 → 停一下 → 再慢慢打开」,这是动画。
生活中的类比:
- 过渡 = 烧水:水从 20°C 慢慢升到 100°C,你可以看到每一个温度的变化
- 动画 = 皮影戏:预先设计好一套动作,然后自动播放
5.4.2 Vue 的 Transition 组件——让元素优雅地上场和谢幕
Vue 提供了一个内置组件 <Transition>,它能自动帮你处理元素的进入和离开动画。
最简代码:
<template>
<button @click="show = !show">切换</button>
<Transition name="fade">
<div v-if="show" class="box">你好,我出现了</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style>
.box {
width: 200px;
height: 200px;
background: #3498db;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
这 5 行 CSS 做了什么?
fade-enter-active:元素进入时的样式fade-leave-active:元素离开时的样式fade-enter-from:进入前的初始状态( opacity: 0 看不见)fade-leave-to:离开后的终态( opacity: 0 消失)
点击按钮,你会看到 div 0.5 秒内慢慢变淡出现/消失,而不是突然蹦出来或消失。
5.4.3 6 个常见的类名(重点!)
Vue 的 Transition 会自动给你 6 个类名:
| 类名 | 什么时候触发 | 典型用途 |
|---|---|---|
name-enter-from |
进入前 | 设置初始位置/透明度 |
name-enter-active |
进入中 | 设置过渡时长、缓动函数 |
name-enter-to |
进入后 | 设置最终状态 |
name-leave-from |
离开前 | 同 enter-from |
name-leave-active |
离开中 | 同 enter-active |
name-leave-to |
离开后 | 同 enter-to |
一个完整的例子——滑入滑出:
<template>
<button @click="show = !show">切换</button>
<Transition name="slide">
<div v-if="show" class="box">滑入滑出</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>
<style>
.box {
width: 200px;
height: 100px;
background: #e74c3c;
color: white;
}
.slide-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-enter-active {
transition: all 0.5s ease;
}
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-leave-active {
transition: all 0.5s ease;
}
</style>
5.4.4 @keyframes——自己设计动画剧本
刚才的过渡是「从 A 状态到 B 状态」,但如果我想做更复杂的动作,比如:
- 先放大
- 再缩小
- 然后消失
这就需要 @keyframes 了——你自己写「剧本」,Vue 来「播放」。
@keyframes my-animation {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.5;
}
100% {
transform: scale(0);
opacity: 0;
}
}
.box {
animation: my-animation 1s ease-in-out;
}
生活类比:@keyframes 就像剧本写「第一幕:进门;第二幕:坐下;第三幕:说话」,演员按照剧本来演。
5.4.5 TransitionGroup——给列表加动画
刚才的 <Transition> 只能给一个元素加动画。如果你要给列表加动画(比如每次新增一个 item),用 <TransitionGroup>。
<template>
<button @click="addItem">添加</button>
<TransitionGroup name="list">
<div v-for="item in items" :key="item.id" class="item">
{{ item.text }}
</div>
</TransitionGroup>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, text: '第一项' },
{ id: 2, text: '第二项' }
])
let nextId = 3
const addItem = () => {
items.value.push({ id: nextId++, text: `第${nextId - 1}项` })
}
</script>
<style>
.item {
padding: 10px;
margin: 5px;
background: #2ecc71;
color: white;
}
/* 进入动画 */
.list-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.list-enter-active {
transition: all 0.5s ease;
}
/* 离开动画 */
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
transition: all 0.5s ease;
/* 重要:离开时 absolute 定位,这样其他元素能平滑移动 */
position: absolute;
}
</style>
敲黑板:list-leave-active 里的 position: absolute 是很多人忘记的!不加的话,删除时其他元素会突然跳位,而不是平滑移动。
5.4.6 mode 属性——控制进出顺序
有时候你不想让「离开」和「进入」同时发生。比如两个页面切换,你想先让旧的走,新的再进来。
<Transition name="fade" mode="out-in">
<component :is="currentView" />
</Transition>
mode="out-in":先等旧的完全离开,新的再进来mode="in-out":新的先进来,旧的再离开
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):一个带动画的警告框
需求:点击按钮切换警告框的显示/隐藏,带淡入淡出效果。
完整代码:
<template>
<div class="container">
<button @click="visible = !visible" class="btn">
{{ visible ? '关闭警告' : '打开警告' }}
</button>
<Transition name="alert-fade">
<div v-if="visible" class="alert">
⚠️ 这是一条重要提醒!
</div>
</Transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
const visible = ref(false)
</script>
<style scoped>
.container {
padding: 20px;
}
.btn {
padding: 10px 20px;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.btn:hover {
background: #2980b9;
}
.alert {
margin-top: 15px;
padding: 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
color: #856404;
}
.alert-fade-enter-active,
.alert-fade-leave-active {
transition: all 0.5s ease;
}
.alert-fade-enter-from,
.alert-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>
预期输出:点击按钮,警告框会从上方滑入淡入,再点击会滑出淡出。
项目 2(15 分钟):一个带动画的待办清单
需求:可以添加和删除待办事项,每一项有滑入滑出动画。
完整代码:
<template>
<div class="todo-app">
<h2>我的待办清单</h2>
<div class="input-row">
<input
v-model="newTask"
@keyup.enter="addTask"
placeholder="输入新任务..."
class="input"
/>
<button @click="addTask" class="add-btn">添加</button>
</div>
<TransitionGroup name="task" tag="ul" class="task-list">
<li v-for="task in tasks" :key="task.id" class="task-item">
<span>{{ task.text }}</span>
<button @click="removeTask(task.id)" class="delete-btn">删除</button>
</li>
</TransitionGroup>
<p v-if="tasks.length === 0" class="empty">暂无任务,添加一个吧!</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const tasks = ref([
{ id: 1, text: '买牛奶' },
{ id: 2, text: '遛狗' },
{ id: 3, text: '写周报' }
])
const newTask = ref('')
let nextId = 4
const addTask = () => {
if (newTask.value.trim() === '') return
tasks.value.push({
id: nextId++,
text: newTask.value.trim()
})
newTask.value = ''
}
const removeTask = (id) => {
const index = tasks.value.findIndex(t => t.id === id)
if (index > -1) {
tasks.value.splice(index, 1)
}
}
</script>
<style scoped>
.todo-app {
max-width: 400px;
margin: 0 auto;
padding: 20px;
}
h2 {
text-align: center;
color: #2c3e50;
}
.input-row {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.add-btn {
padding: 10px 20px;
background: #27ae60;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
position: relative;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
margin-bottom: 8px;
background: #ecf0f1;
border-radius: 5px;
}
.delete-btn {
padding: 5px 10px;
background: #e74c3c;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
/* 列表过渡动画 */
.task-enter-from {
opacity: 0;
transform: translateX(-50px);
}
.task-enter-active {
transition: all 0.4s ease;
}
.task-leave-to {
opacity: 0;
transform: translateX(50px);
}
.task-leave-active {
transition: all 0.4s ease;
position: absolute;
width: 100%;
box-sizing: border-box;
}
.empty {
text-align: center;
color: #95a5a6;
margin-top: 20px;
}
</style>
预期输出:添加任务时从左边滑入,删除任务时向右边滑出消失。
项目 3(15 分钟):一个卡片轮播切换器
需求:多张卡片可以左右切换,带滑动动画效果。可以自动播放,也可以手动切换。
完整代码:
<template>
<div class="carousel">
<h2>卡片轮播</h2>
<div class="carousel-container">
<button @click="prev" class="nav-btn">◀</button>
<Transition :name="direction === 'left' ? 'slide-left' : 'slide-right'">
<div :key="currentIndex" class="card">
<h3>{{ cards[currentIndex].title }}</h3>
<p>{{ cards[currentIndex].content }}</p>
</div>
</Transition>
<button @click="next" class="nav-btn">▶</button>
</div>
<div class="indicators">
<span
v-for="(card, index) in cards"
:key="index"
:class="{ active: index === currentIndex }"
@click="goTo(index)"
class="dot"
/>
</div>
<div class="controls">
<button @click="toggleAutoPlay" class="control-btn">
{{ isAutoPlaying ? '暂停' : '自动播放' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const cards = ref([
{ title: '卡片 1', content: '这是第一张卡片的内容' },
{ title: '卡片 2', content: '这是第二张卡片的内容' },
{ title: '卡片 3', content: '这是第三张卡片的内容' }
])
const currentIndex = ref(0)
const direction = ref('left')
const isAutoPlaying = ref(false)
let autoPlayTimer = null
const next = () => {
direction.value = 'left'
currentIndex.value = (currentIndex.value + 1) % cards.value.length
}
const prev = () => {
direction.value = 'right'
currentIndex.value = (currentIndex.value - 1 + cards.value.length) % cards.value.length
}
const goTo = (index) => {
direction.value = index > currentIndex.value ? 'left' : 'right'
currentIndex.value = index
}
const toggleAutoPlay = () => {
isAutoPlaying.value = !isAutoPlaying.value
}
onMounted(() => {
autoPlayTimer = setInterval(() => {
if (isAutoPlaying.value) {
next()
}
}, 3000)
})
onUnmounted(() => {
if (autoPlayTimer) clearInterval(autoPlayTimer)
})
</script>
<style scoped>
.carousel {
max-width: 500px;
margin: 0 auto;
padding: 20px;
}
.carousel-container {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.nav-btn {
padding: 10px 15px;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 18px;
}
.card {
width: 300px;
height: 200px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
color: white;
}
.card h3 {
margin: 0 0 15px 0;
font-size: 24px;
}
.card p {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.indicators {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #bdc3c7;
cursor: pointer;
transition: all 0.3s;
}
.dot.active {
background: #3498db;
transform: scale(1.2);
}
.controls {
text-align: center;
margin-top: 20px;
}
.control-btn {
padding: 10px 20px;
background: #27ae60;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
/* 向左滑动的动画 */
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-enter-active {
transition: all 0.5s ease;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-left-leave-active {
transition: all 0.5s ease;
position: absolute;
}
/* 向右滑动的动画 */
.slide-right-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-active {
transition: all 0.5s ease;
}
.slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-right-leave-active {
transition: all 0.5s ease;
position: absolute;
}
</style>
预期输出:点击左右箭头或底部指示点切换卡片,带滑动动画。可以开启自动播放模式。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:TransitionGroup 删除时元素乱跳
❌ 错误写法:
.list-leave-active {
transition: all 0.5s ease;
}
✅ 正确写法:
.list-leave-active {
transition: all 0.5s ease;
position: absolute; /* 关键:让元素脱离文档流 */
}
原因:不加 position: absolute,删除时其他元素会直接跳过去而不是平滑移动。
坑 2:动画不生效?先检查 name 属性
❌ 错误写法:
<Transition name="fade">
<div v-show="show">...</div> <!-- v-show 不行! -->
</Transition>
✅ 正确写法:
<Transition name="fade">
<div v-if="show">...</div> <!-- v-if 可以 -->
</Transition>
原因:<Transition> 只监听 v-if 的变化,不支持 v-show。
坑 3:mode="out-in" 用错顺序
❌ 错误写法:
<Transition mode="in-out">
<component :is="currentView" />
</Transition>
✅ 正确写法:
<Transition mode="out-in">
<component :is="currentView" />
</Transition>
原因:out-in 是「先走再进」,in-out 是「先进再走」。大部分场景用 out-in。
坑 4:@keyframes 名字重复
❌ 错误写法(多个组件用同一个名字):
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
✅ 正确写法(给动画起唯一的名字):
@keyframes alert-fade {
from { opacity: 0; }
to { opacity: 1; }
}
原因:如果两个组件都用 fade,可能会互相覆盖。养成好习惯,用「组件名-动画名」的格式。
坑 5:动画时间太长导致卡顿
❌ 错误写法:
.fade-enter-active {
transition: all 5s ease; /* 太慢了! */
}
✅ 正确写法:
.fade-enter-active {
transition: all 0.3s ease; /* 0.2-0.5s 是最佳区间 */
}
原因:动画超过 1 秒会让人感觉拖沓。进出场动画控制在 0.3s 左右最好。
性能小贴士:使用 will-change 优化动画
如果你的动画是 GPU 加速的(比如 transform、opacity),可以提前告诉浏览器:
.box {
will-change: transform, opacity;
/* 浏览器会提前准备资源,动画更流畅 */
}
但不要滥用,只在确实需要动画的元素上用。
调试技巧:用 Chrome 开发者工具查看动画
- 打开开发者工具(F12)
- 切换到 Elements 面板
- 点击元素,查看右侧 Styles 面板
- 勾选 Animate 复选框,可以看到动画的实时状态
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改改过渡时间
- 输入:把项目 1 的淡入淡出时间从 0.5s 改成 1s
- 预期输出:警告框出现/消失的速度变慢
- 提示:找 alert-fade-enter-active 类
练习 2(2 分钟):加一个条件判断
- 输入:在项目 1 基础上,只有当警告内容非空时才显示
- 预期输出:内容为空时,警告框不出现
- 提示:用 v-if 结合三元表达式
练习 3(3 分钟):给列表加新样式
- 输入:在项目 2 的待办清单中,给已完成的任务加删除线
- 预期输出:添加 completed class 的任务文字有删除线
- 提示:用 v-bind:class 绑定动态 class
练习 4(5 分钟):串起两个项目
- 输入:用项目 2 的列表展示项目 1 的多个警告框
- 预期输出:点击列表中的项可以打开对应警告框
- 提示:用 v-for + 点击事件
练习 5(5 分钟):分析报错
- 输入:运行下面代码,发现动画不生效,console 有报错
- 预期输出:找出问题并修复
- 提示:检查 name 属性是否和 CSS 类名对应
<template>
<Transition name="fade">
<div v-if="show">测试</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style>
.fade-enter-active {
transition: opacity 0.5s;
}
.fade-enter-from {
opacity: 0;
}
</style>
作业题(30 分钟-2 小时)
作业:做一个「心情温度计」动画卡片
- 需求描述:做一个能展示不同心情的卡片,每种心情有对应的颜色和动画效果
- 功能点:
1. 4种心情:开心(黄色)、平静(蓝色)、焦虑(橙色)、难过(灰色)
2. 点击切换心情,带平滑过渡动画
3. 每种心情有对应的 emoji 和一句话 - 加分项:
1. 自动随机切换心情(带动画)
2. 背景颜色渐变动画 - 验收标准:能跑起来 + 切换流畅 + 代码有注释
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学到的 3 个核心点
<Transition>组件——给单个元素的显示/隐藏加动画,用v-if触发- 6 个类名——
enter-from/active/to和leave-from/active/to,控制进入和离开的过程 <TransitionGroup>——给列表加动画,记得给leave-active加position: absolute
推荐延伸资源
- Vue3 官方过渡文档 ——最权威的参考资料
- Vue School - Animation 课程 ——视频教程更直观
- CSS Tricks - Animation 详解 ——深入理解 @keyframes
你在做网页时最想加什么动画? 是页面切换?还是加载状态?还是数据更新提示?评论区聊聊你的想法,老粉优先回复!
📌 下一章我们要学「自定义 Hooks(composables)」——学会把动画逻辑封装成可复用的函数,让多个组件共享同一套动画效果。敬请期待!

评论(0)