第5章 5.4 动画与过渡

⏱️ 阅读本文约需 90 分钟,建议边读边敲代码


🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了用 Sass/Less 让 CSS 写起来更爽,这一章我们要用它解决一个更直观的问题——怎么让界面「动」起来

你有没有遇到过这些情况?

  • 网页切换时太生硬——按钮点了没反应,或者列表突然蹦出来吓你一跳
  • 想做个loading动画——但不知道从哪里下手
  • 别人做的界面很流畅——而你的总是一卡一卡的,像 PPT 翻页

学完这一章,你能:

  1. 让 Vue 组件优雅地出现和消失(而不是突然蹦出来)
  2. @keyframes 做出自定义动画(不只是网上抄的特效)
  3. TransitionTransitionGroup 管理列表的增删动画

说白了,这一章就是让你的网页从「静态图」变成「小动画」的关键技能。


🧱 基础 25 分钟:核心概念

5.4.1 先搞清楚:什么是过渡?什\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 状态」,但如果我想做更复杂的动作,比如:

  1. 先放大
  2. 再缩小
  3. 然后消失

这就需要 @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 开发者工具查看动画

  1. 打开开发者工具(F12)
  2. 切换到 Elements 面板
  3. 点击元素,查看右侧 Styles 面板
  4. 勾选 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 个核心点

  1. <Transition> 组件——给单个元素的显示/隐藏加动画,用 v-if 触发
  2. 6 个类名——enter-from/active/toleave-from/active/to,控制进入和离开的过程
  3. <TransitionGroup>——给列表加动画,记得给 leave-activeposition: absolute

推荐延伸资源

  1. Vue3 官方过渡文档 ——最权威的参考资料
  2. Vue School - Animation 课程 ——视频教程更直观
  3. CSS Tricks - Animation 详解 ——深入理解 @keyframes

你在做网页时最想加什么动画? 是页面切换?还是加载状态?还是数据更新提示?评论区聊聊你的想法,老粉优先回复!

📌 下一章我们要学「自定义 Hooks(composables)」——学会把动画逻辑封装成可复用的函数,让多个组件共享同一套动画效果。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。