本篇定位 :Vue 提供了一系列内置组件,用于处理常见的 UI 模式和性能优化场景。本篇将系统讲解四个最核心的内置组件------
Transition(过渡动画)、Teleport(传送门)、Suspense(异步依赖处理)和KeepAlive(缓存组件)。读完本篇,你将能够为应用添加流畅的过渡动画、灵活地控制 DOM 渲染位置、优雅地处理异步组件加载,以及智能地缓存组件状态。
一、Transition:过渡与动画
1.1 基本概念
Transition 是 Vue 提供的过渡动画组件 ,它可以在元素或组件的进入、离开和列表变化时自动应用过渡效果。
vue
<template>
<button @click="show = !show">切换</button>
<Transition>
<p v-if="show">这是一个带有过渡动画的元素</p>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style>
/* 进入/离开的过渡状态 */
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
Transition 的核心特征:
- 当
v-if、v-show或动态组件切换时自动触发 - 通过 CSS 类名控制动画过程
- 支持 JavaScript 钩子函数
- 支持 CSS 动画库(如 Animate.css)
1.2 过渡类名详解
Vue 在元素过渡过程中会添加/移除以下 6 个 CSS 类:
进入阶段:
v-enter-from → v-enter-active → v-enter-to
(起始状态) (过渡中) (结束状态)
离开阶段:
v-leave-from → v-leave-active → v-leave-to
(起始状态) (过渡中) (结束状态)
| 类名 | 时机 | 说明 |
|---|---|---|
v-enter-from |
插入前 | 元素的起始状态(如 opacity: 0) |
v-enter-active |
插入到过渡结束 | 定义过渡的持续时间、缓动函数 |
v-enter-to |
插入后 | 元素的结束状态(如 opacity: 1) |
v-leave-from |
离开触发时 | 元素的起始状态 |
v-leave-active |
离开到过渡结束 | 定义过渡的持续时间、缓动函数 |
v-leave-to |
离开后 | 元素的结束状态 |
自定义过渡名称:
vue
<template>
<!-- 使用 name 属性自定义类名前缀 -->
<Transition name="fade">
<p v-if="show">内容</p>
</Transition>
</template>
<style>
/* 类名变为 fade-enter-from、fade-enter-active 等 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
1.3 常见过渡效果
① 淡入淡出:
css
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
② 滑动(从上方滑入):
css
.slide-enter-active,
.slide-leave-active {
transition: all 0.4s ease;
}
.slide-enter-from {
transform: translateY(-30px);
opacity: 0;
}
.slide-leave-to {
transform: translateY(30px);
opacity: 0;
}
③ 缩放:
css
.scale-enter-active,
.scale-leave-active {
transition: all 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.8);
opacity: 0;
}
④ 组合动画:
css
.combined-enter-active {
animation: bounceIn 0.6s ease;
}
.combined-leave-active {
animation: bounceOut 0.4s ease;
}
@keyframes bounceIn {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes bounceOut {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(0.3); opacity: 0; }
}
1.4 使用 CSS 动画库(Animate.css)
bash
npm install animate.css
vue
<script setup>
import { ref } from 'vue'
import 'animate.css'
const show = ref(true)
</script>
<template>
<button @click="show = !show">切换</button>
<Transition
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOut"
>
<p v-if="show">带有 Animate.css 动画的内容</p>
</Transition>
</template>
使用自定义属性:
vue
<Transition
enter-active-class="animate__animated animate__fadeInUp"
leave-active-class="animate__animated animate__fadeOutDown"
:duration="800"
>
<div v-if="show">内容</div>
</Transition>
1.5 JavaScript 钩子
使用 JavaScript 钩子可以实现更复杂的动画控制:
vue
<template>
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@enter-cancelled="onEnterCancelled"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
@leave-cancelled="onLeaveCancelled"
>
<div v-if="show">内容</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
function onBeforeEnter(el) {
el.style.opacity = '0'
el.style.transform = 'translateY(-20px)'
}
function onEnter(el, done) {
el.animate([
{ opacity: 0, transform: 'translateY(-20px)' },
{ opacity: 1, transform: 'translateY(0)' }
], {
duration: 500,
easing: 'ease'
}).finished.then(done)
}
function onAfterEnter(el) {
console.log('进入完成')
}
// ... 其他钩子
</script>
使用 GSAP 动画库:
vue
<script setup>
import { ref } from 'vue'
import gsap from 'gsap'
const show = ref(true)
function onEnter(el, done) {
gsap.from(el, {
opacity: 0,
y: 30,
duration: 0.6,
ease: 'power2.out',
onComplete: done
})
}
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
y: -30,
duration: 0.4,
ease: 'power2.in',
onComplete: done
})
}
</script>
1.6 TransitionGroup:列表过渡
TransitionGroup 用于为列表中的元素添加过渡动画:
vue
<script setup>
import { ref } from 'vue'
import { TransitionGroup } from 'vue'
const items = ref([1, 2, 3, 4, 5])
let nextId = 6
function addItem() {
items.value.push(nextId++)
}
function removeItem(index) {
items.value.splice(index, 1)
}
function shuffle() {
items.value.sort(() => Math.random() - 0.5)
}
</script>
<template>
<div>
<button @click="addItem">添加</button>
<button @click="shuffle">打乱</button>
<TransitionGroup name="list" tag="ul" class="list">
<li v-for="item in items" :key="item" class="list-item">
{{ item }}
<button @click="removeItem(items.indexOf(item))">×</button>
</li>
</TransitionGroup>
</div>
</template>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from {
opacity: 0;
transform: translateX(30px);
}
.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* 移动动画(列表重排时) */
.list-move {
transition: transform 0.5s ease;
}
</style>
二、Teleport:传送门
2.1 基本概念
Teleport 允许将组件的内容传送到 DOM 中的任意位置,而不受组件层级限制。
使用场景:
- 模态框(Modal)
- 通知提示(Toast)
- 下拉菜单
- 工具提示(Tooltip)
vue
<template>
<button @click="open = true">打开弹窗</button>
<!-- 将弹窗传送到 body 下 -->
<Teleport to="body">
<div v-if="open" class="modal-overlay" @click="open = false">
<div class="modal" @click.stop>
<h2>这是一个弹窗</h2>
<p>通过 Teleport 传送到 body 下</p>
<button @click="open = false">关闭</button>
</div>
</div>
</Teleport>
</template>
2.2 Teleport 的目标选择器
to 属性支持多种选择器:
vue
<!-- CSS 选择器 -->
<Teleport to="body">
<div>传送到 body</div>
</Teleport>
<Teleport to="#app">
<div>传送到 id="app" 的元素</div>
</Teleport>
<Teleport to=".container">
<div>传送到 class="container" 的元素</div>
</Teleport>
<!-- 动态目标 -->
<Teleport :to="targetSelector">
<div>动态传送</div>
</Teleport>
2.3 禁用 Teleport
使用 disabled 属性控制 Teleport 是否生效:
vue
<Teleport to="body" :disabled="isMobile">
<!-- 在移动端不传送,保持组件内 -->
<div class="modal">内容</div>
</Teleport>
2.4 多个 Teleport 到同一目标
多个 Teleport 传送到同一目标时,内容会按顺序追加:
vue
<Teleport to="#app">
<div>第一个内容</div>
</Teleport>
<Teleport to="#app">
<div>第二个内容</div>
</Teleport>
<!-- 渲染结果:第一个内容 + 第二个内容 -->
2.5 综合实战:全局通知系统
vue
<!-- NotificationContainer.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from './eventBus'
interface Notification {
id: number
message: string
type: 'info' | 'success' | 'warning' | 'error'
duration: number
}
const notifications = ref<Notification[]>([])
let idCounter = 0
function showNotif(message: string, type: Notification['type'] = 'info', duration = 3000) {
const id = idCounter++
notifications.value.push({ id, message, type, duration })
if (duration > 0) {
setTimeout(() => {
removeNotif(id)
}, duration)
}
}
function removeNotif(id: number) {
notifications.value = notifications.value.filter(n => n.id !== id)
}
// 监听事件总线
onMounted(() => {
eventBus.on('notify', showNotif)
})
onUnmounted(() => {
eventBus.off('notify', showNotif)
})
function getIcon(type: string) {
const icons = {
info: 'ℹ️',
success: '✅',
warning: '⚠️',
error: '❌'
}
return icons[type as keyof typeof icons] || 'ℹ️'
}
function getColor(type: string) {
const colors = {
info: { bg: '#ebf8ff', border: '#bee3f8', text: '#2b6cb0' },
success: { bg: '#f0fff4', border: '#c6f6d5', text: '#276749' },
warning: { bg: '#fffff0', border: '#f6e05e', text: '#975a16' },
error: { bg: '#fff5f5', border: '#fed7d7', text: '#9b2c2c' }
}
return colors[type as keyof typeof colors] || colors.info
}
</script>
<template>
<!-- 使用 Teleport 将通知传送到 body -->
<Teleport to="body">
<TransitionGroup name="notification" class="notification-container">
<div
v-for="notif in notifications"
:key="notif.id"
class="notification"
:style="{
background: getColor(notif.type).bg,
borderColor: getColor(notif.type).border,
color: getColor(notif.type).text
}"
>
<span class="icon">{{ getIcon(notif.type) }}</span>
<span class="message">{{ notif.message }}</span>
<button @click="removeNotif(notif.id)" class="close">×</button>
</div>
</TransitionGroup>
</Teleport>
</template>
<style>
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.notification {
padding: 12px 16px;
border: 2px solid;
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.notification .message { flex: 1; font-size: 14px; }
.notification .close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
opacity: 0.5;
}
.notification .close:hover { opacity: 1; }
.notification-enter-active,
.notification-leave-active {
transition: all 0.4s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.notification-leave-to {
opacity: 0;
transform: translateX(80%) scale(0.6);
}
</style>
三、Suspense:异步依赖处理
3.1 基本概念
Suspense 是 Vue 3 提供的异步组件加载 内置组件,用于在等待异步组件加载时显示备用内容。
vue
<template>
<Suspense>
<!-- 异步组件 -->
<AsyncComponent />
<!-- 加载中显示的内容 -->
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>
Suspense 的工作流程:
- 渲染时,尝试渲染
#default插槽中的组件 - 如果遇到异步依赖 (
async setup或使用了Suspense的内置组件),显示#fallback内容 - 所有异步依赖完成后,切换显示
#default内容
3.2 异步组件
vue
<!-- AsyncComponent.vue -->
<script setup>
// 使用顶层 await
const data = await fetch('/api/data').then(r => r.json())
// 组件会在数据加载完成后才渲染
console.log('数据已加载:', data)
</script>
<template>
<div>
<h2>异步加载的数据</h2>
<pre>{{ data }}</pre>
</div>
</template>
3.3 使用 defineAsyncComponent
vue
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent({
loader: () => import('./AsyncComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
</script>
<template>
<Suspense>
<AsyncComponent />
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>
3.4 嵌套 Suspense
Suspense 可以嵌套使用,每个层级独立处理:
vue
<template>
<Suspense>
<ParentComponent />
<template #fallback>
<div>父组件加载中...</div>
</template>
</Suspense>
</template>
<!-- ParentComponent.vue -->
<template>
<div>
<h1>父组件</h1>
<Suspense>
<ChildComponent />
<template #fallback>
<div>子组件加载中...</div>
</template>
</Suspense>
</div>
</template>
3.5 错误处理
vue
<script setup>
import { ref } from 'vue'
const error = ref<Error | null>(null)
function handleError(err: Error) {
console.error('Suspense 错误:', err)
error.value = err
}
</script>
<template>
<div v-if="error" class="error">
❌ 加载失败:{{ error.message }}
<button @click="error = null; $forceUpdate()">重试</button>
</div>
<Suspense @pending="console.log('pending')" @resolve="console.log('resolve')" @fallback="console.log('fallback')">
<AsyncComponent />
<template #fallback>
<div>⏳ 加载中...</div>
</template>
</Suspense>
</template>
四、KeepAlive:缓存组件状态
4.1 基本概念
KeepAlive 用于缓存组件实例,避免组件被销毁和重新创建,从而保留状态和提升性能。
vue
<template>
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
</template>
使用场景:
- 路由切换时保留页面状态
- Tab 切换时保留表单内容
- 列表详情页切换时保留滚动位置
4.2 配合路由使用
vue
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
</template>
仅缓存特定路由:
vue
<script setup>
const route = useRoute()
</script>
<template>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['UserList', 'Dashboard']">
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</RouterView>
</template>
4.3 include / exclude / max
| 属性 | 说明 | 示例 |
|---|---|---|
include |
需要缓存的组件名(正则、字符串、数组) | :include="['Home', 'About']" |
exclude |
不需要缓存的组件名 | :exclude="['Login']" |
max |
最大缓存实例数量 | :max="10" |
vue
<KeepAlive :include="/^User/" :exclude="['UserDetail']" :max="20">
<component :is="currentComponent" />
</KeepAlive>
4.4 生命周期钩子:onActivated / onDeactivated
被 KeepAlive 缓存的组件会触发额外的生命周期钩子:
vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
// 组件被激活时(从缓存中恢复)
onActivated(() => {
console.log('组件被激活')
// 刷新数据、恢复滚动位置等
})
// 组件被停用时(被缓存)
onDeactivated(() => {
console.log('组件被停用')
// 保存滚动位置、暂停定时器等
})
</script>
4.5 KeepAlive 与 Transition 的组合
vue
<template>
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</Transition>
</RouterView>
</template>
五、综合实战:带缓存和过渡的 Tab 切换
让我们综合运用 KeepAlive、Transition 和 Teleport,构建一个完整的 Tab 切换应用:
vue
<script setup lang="ts">
import { ref, shallowRef, onActivated, onDeactivated } from 'vue'
import { Transition, KeepAlive, Teleport } from 'vue'
// -------- 组件定义 --------
const Home = {
name: 'Home',
template: `
<div class="tab-content">
<h2>🏠 首页</h2>
<p>这是首页内容,缓存状态:{{ counter }}</p>
<button @click="counter++">点击:{{ counter }}</button>
</div>
`,
setup() {
const counter = ref(0)
onActivated(() => console.log('🏠 Home 被激活,counter:', counter.value))
onDeactivated(() => console.log('🏠 Home 被停用'))
return { counter }
}
}
const About = {
name: 'About',
template: `
<div class="tab-content">
<h2>📖 关于</h2>
<p>关于页面,缓存状态:{{ count }}</p>
<button @click="count++">点击:{{ count }}</button>
</div>
`,
setup() {
const count = ref(0)
onActivated(() => console.log('📖 About 被激活'))
onDeactivated(() => console.log('📖 About 被停用'))
return { count }
}
}
const Contact = {
name: 'Contact',
template: `
<div class="tab-content">
<h2>📧 联系方式</h2>
<form @submit.prevent="submit">
<input v-model="email" placeholder="输入邮箱" />
<button type="submit">提交</button>
<p v-if="submitted">已提交:{{ email }}</p>
</form>
</div>
`,
setup() {
const email = ref('')
const submitted = ref(false)
const submit = () => {
submitted.value = true
}
return { email, submitted, submit }
}
}
// -------- 状态 --------
const tabs = [
{ key: 'home', label: '🏠 首页', component: Home },
{ key: 'about', label: '📖 关于', component: About },
{ key: 'contact', label: '📧 联系', component: Contact }
]
const currentTab = ref(tabs[0].key)
const currentComponent = shallowRef(tabs[0].component)
// 切换 Tab
function switchTab(key: string) {
const tab = tabs.find(t => t.key === key)
if (tab) {
currentTab.value = key
currentComponent.value = tab.component
}
}
// -------- 通知系统 --------
const notifications = ref<Array<{ id: number; message: string; type: string }>>([])
let notifId = 0
function showNotification(message: string, type = 'info') {
const id = notifId++
notifications.value.push({ id, message, type })
setTimeout(() => {
notifications.value = notifications.value.filter(n => n.id !== id)
}, 3000)
}
</script>
<template>
<div class="app">
<h1>Vue 内置组件综合演示</h1>
<!-- Tab 切换 -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
@click="switchTab(tab.key)"
:class="['tab-btn', { active: currentTab === tab.key }]"
>
{{ tab.label }}
</button>
</div>
<!-- 内容区域:带过渡和缓存 -->
<div class="content">
<Transition name="fade" mode="out-in">
<KeepAlive :include="['Home', 'About', 'Contact']" :max="5">
<component :is="currentComponent" />
</KeepAlive>
</Transition>
</div>
<!-- 操作区 -->
<div class="actions">
<button @click="showNotification('这是一个通知消息', 'info')">
ℹ️ 信息通知
</button>
<button @click="showNotification('操作成功!', 'success')">
✅ 成功通知
</button>
<button @click="showNotification('发生错误!', 'error')">
❌ 错误通知
</button>
</div>
<!-- 通知系统:使用 Teleport -->
<Teleport to="body">
<TransitionGroup name="notification" class="notification-container">
<div
v-for="notif in notifications"
:key="notif.id"
:class="['notification', notif.type]"
>
{{ notif.message }}
<button @click="notifications = notifications.filter(n => n.id !== notif.id)">
×
</button>
</div>
</TransitionGroup>
</Teleport>
</div>
</template>
<style>
.app {
max-width: 800px;
margin: 20px auto;
padding: 30px;
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
}
h1 { font-size: 24px; margin-bottom: 24px; }
/* Tab 样式 */
.tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid #e2e8f0;
margin-bottom: 20px;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 15px;
color: #718096;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-btn:hover { color: #2d3748; }
.tab-btn.active {
color: #4299e1;
border-bottom-color: #4299e1;
}
/* 内容过渡 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.content {
min-height: 200px;
padding: 20px;
background: #f7fafc;
border-radius: 10px;
}
.tab-content h2 { margin-top: 0; }
.tab-content button {
padding: 6px 16px;
background: #4299e1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
/* 操作区 */
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.actions button {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
}
.actions button:hover { background: #f7fafc; }
/* 通知样式 */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 380px;
}
.notification {
padding: 12px 16px;
border-radius: 10px;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-left: 4px solid #4299e1;
}
.notification.info { border-left-color: #4299e1; }
.notification.success { border-left-color: #48bb78; }
.notification.error { border-left-color: #fc8181; }
.notification button {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
opacity: 0.5;
}
.notification button:hover { opacity: 1; }
.notification-enter-active,
.notification-leave-active {
transition: all 0.4s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.8);
}
.notification-leave-to {
opacity: 0;
transform: translateX(80%) scale(0.6);
}
</style>
六、本篇小结
| 内置组件 | 核心功能 | 关键用法 |
|---|---|---|
| Transition | 元素/组件的进入/离开动画 | CSS 过渡类、JavaScript 钩子、TransitionGroup |
| Teleport | 将内容传送到 DOM 任意位置 | to 目标选择器、disabled 禁用 |
| Suspense | 异步组件加载的等待状态 | 配合 async setup、defineAsyncComponent |
| KeepAlive | 缓存组件实例,保留状态 | include/exclude/max、onActivated/onDeactivated |
七、思考与练习
-
基础练习:实现一个带有淡入淡出过渡的模态框组件,使用 Teleport 传送到 body,并带有背景遮罩层。
-
实践任务 :构建一个多标签页应用,每个标签页的组件用
KeepAlive缓存,并在切换时使用Transition实现平滑过渡。 -
陷阱排查:以下代码有什么问题,如何修正?
vue<KeepAlive :exclude="['Home', 'About']"> <component :is="currentComponent" /> </KeepAlive> -
深度思考 :
Suspense和onMounted中获取数据有什么区别?什么情况下应该使用Suspense? -
扩展任务:为通知系统添加以下功能:
- 支持
duration: 0表示不自动关闭 - 添加"清空所有"按钮
- 通知显示进度条,倒计时指示剩余时间
- 支持
下篇预告
第 16 篇我们将学习自定义指令与过滤器,扩展 Vue 的能力边界:
- 自定义指令的生命周期钩子
- 指令的参数、修饰符和值
- 在组合式 API 中使用指令
- Vue 3 中过滤器的移除与替代方案
准备好为 Vue 添加你自己的"魔法"!✨