内置组件与高级特性
在电商后台管理系统中,你一定遇到过这些需求:订单列表切换时需要平滑动画,商品编辑页面在标签页切换时不能丢失表单状态,商品详情弹窗要渲染到 body 层级以避免被父容器裁剪,异步加载的报表组件需要统一展示加载状态,图片需要懒加载以提升首屏性能,还需要一套全局插件来注入通用服务……
这些需求看似分散,实则指向同一个核心主题——Vue 3 的内置组件与高级特性。Vue 将开发者最常遇到的交互模式提炼为内置组件(Transition、KeepAlive、Teleport、Suspense),并通过自定义指令和插件系统提供了灵活的扩展机制。
本章将以「电商后台管理系统」为贯穿场景,系统讲解这六大主题。掌握它们,你才能从”能写出来”进化到”写得专业”。
📋 开篇自测
在正式学习前,试着回答以下三个问题,检验你对内置组件的现有认知:
<Transition>组件本身会渲染出额外的 DOM 元素吗?它是如何控制子元素动画效果的?- 使用
<KeepAlive>缓存组件时,如果缓存的组件越来越多,会出现什么问题?Vue 提供了什么策略来解决? - 一个弹窗组件写在深层嵌套的子组件内部,但希望它渲染到
<body>上。在没有<Teleport>的时代,你会怎么做?这种做法有什么弊端?
带着这些问题,开始本章的学习。
一、Transition 与 TransitionGroup——让界面动起来
在电商后台中,一个精心设计的过渡动画能让用户的操作感知更加连贯:订单状态变更时的提示渐显渐隐、商品上下架时列表项的平滑进出、侧边栏面板的滑入滑出。这些动效不是锦上添花,而是用户体验的基础设施。
1.1 Transition 基础——CSS 过渡与动画
<Transition> 是 Vue 提供的内置组件,用于在元素或组件进入和离开 DOM 时添加过渡效果。一个关键认知:<Transition> 本身不会渲染任何额外 DOM 元素,它只是一个行为容器,在适当的时机为子元素添加或移除特定的 CSS 类名。
来看一个电商场景——商品编辑成功后显示的保存提示:
<template>
<button @click="saveProduct">保存商品</button>
<Transition name="fade">
<p v-if="showSavedTip" class="save-tip">保存成功!</p>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const showSavedTip = ref(false)
function saveProduct() {
// 模拟保存操作
showSavedTip.value = true
setTimeout(() => {
showSavedTip.value = false
}, 2000)
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<Transition> 的工作机制可以概括为六个类名在三个阶段的添加与移除:
进入阶段(Enter):
| 类名 | 作用 | 时机 |
|---|---|---|
v-enter-from | 进入的起始状态 | 元素插入前添加,插入后下一帧移除 |
v-enter-active | 进入的激活状态 | 元素插入前添加,过渡结束后移除 |
v-enter-to | 进入的结束状态 | 元素插入后下一帧添加,过渡结束后移除 |
离开阶段(Leave):
| 类名 | 作用 | 时机 |
|---|---|---|
v-leave-from | 离开的起始状态 | 离开触发时添加,下一帧移除 |
v-leave-active | 离开的激活状态 | 离开触发时添加,过渡结束后移除 |
v-leave-to | 离开的结束状态 | 离开触发后下一帧添加,过渡结束后移除 |
想象一扇自动门:enter-from 是门关着的状态,enter-active 描述门打开的速度和方式,enter-to 是门完全打开的状态。当你使用 name="fade" 时,所有类名中的 v 前缀会被替换为 fade。
1.2 过渡模式——解决元素切换的时序问题
当两个元素交替切换时,默认情况下进入和离开动画会同时发生,这往往导致视觉上的混乱。<Transition> 提供了 mode 属性来控制时序:
<!-- 电商后台:商品状态切换 -->
<template>
<Transition name="slide-fade" mode="out-in">
<div v-if="productStatus === 'editing'" key="editing">
<ProductEditForm :product="currentProduct" />
</div>
<div v-else-if="productStatus === 'preview'" key="preview">
<ProductPreviewCard :product="currentProduct" />
</div>
</Transition>
</template>
<style>
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from {
transform: translateX(20px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>
out-in:先执行当前元素的离开动画,完成后再执行新元素的进入动画。这是最常用的模式。in-out:先执行新元素的进入动画,完成后再执行旧元素的离开动画。
1.3 JavaScript 钩子——精细控制动画过程
对于需要精细控制的场景(如基于数据动态计算动画参数),可以使用 JavaScript 钩子:
<!-- 电商后台:订单金额变化的数字滚动效果 -->
<template>
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
:css="false"
>
<div v-if="showOrderAmount" class="order-amount">
¥{{ displayAmount }}
</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
const showOrderAmount = ref(true)
const displayAmount = ref('0.00')
function onBeforeEnter(el) {
el.style.opacity = '0'
el.style.transform = 'translateY(-20px)'
}
function onEnter(el, done) {
// 使用 requestAnimationFrame 实现平滑过渡
const animation = el.animate(
[
{ opacity: 0, transform: 'translateY(-20px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{ duration: 400, easing: 'ease-out' }
)
animation.onfinish = done
}
function onAfterEnter(el) {
el.style.opacity = ''
el.style.transform = ''
}
function onBeforeLeave(el) {
el.style.opacity = '1'
}
function onLeave(el, done) {
const animation = el.animate(
[
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' }
],
{ duration: 300, easing: 'ease-in' }
)
animation.onfinish = done
}
function onAfterLeave(el) {
el.style.opacity = ''
el.style.transform = ''
}
</script>
最佳实践: 当使用纯 JavaScript 钩子控制动画时,添加
:css="false"可以告诉 Vue 跳过 CSS 过渡检测,避免不必要的性能开销。同时必须在onEnter和onLeave中调用done回调,否则过渡会立即完成。
1.4 TransitionGroup——列表过渡
<TransitionGroup> 专门用于处理 v-for 渲染的列表中元素的进入、离开和位移动画。Vue 2 中 <transition-group> 默认渲染一个 <span> 作为包裹元素;Vue 3 中 <TransitionGroup> 默认不渲染任何包裹元素(使用 Fragment)。如果需要一个包裹容器,可以通过 tag 属性显式指定(如 tag="ul")。
<!-- 电商后台:订单列表实时更新动画 -->
<template>
<div class="order-panel">
<h3>实时订单</h3>
<TransitionGroup name="order-list" tag="ul" class="order-list">
<li
v-for="order in recentOrders"
:key="order.id"
class="order-item"
>
<span class="order-id">{{ order.id }}</span>
<span class="order-product">{{ order.productName }}</span>
<span class="order-amount">¥{{ order.amount }}</span>
<button @click="removeOrder(order.id)">完成</button>
</li>
</TransitionGroup>
</div>
</template>
<script setup>
import { ref } from 'vue'
const recentOrders = ref([
{ id: 'ORD-001', productName: '无线蓝牙耳机', amount: '299.00' },
{ id: 'ORD-002', productName: '机械键盘', amount: '459.00' },
{ id: 'ORD-003', productName: 'USB-C 扩展坞', amount: '189.00' },
])
function removeOrder(orderId) {
recentOrders.value = recentOrders.value.filter(o => o.id !== orderId)
}
function addOrder(order) {
recentOrders.value.unshift(order)
}
</script>
<style>
.order-list {
list-style: none;
padding: 0;
}
/* 进入和离开动画 */
.order-list-enter-active,
.order-list-leave-active {
transition: all 0.4s ease;
}
.order-list-enter-from {
opacity: 0;
transform: translateX(30px);
}
.order-list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* 关键:move 类实现位移动画 */
.order-list-move {
transition: transform 0.4s ease;
}
/* 让离开的元素脱离文档流,使 move 动画正常运作 */
.order-list-leave-active {
position: absolute;
}
</style>
<TransitionGroup> 独有的 *-move 类名使列表中的元素在其他元素进出时能平滑地移动到新位置,而不是突然跳变。这背后用到了 FLIP 动画技术——先记录元素位置,再计算差值,最后通过 CSS transform 实现平滑过渡。
注意事项:
<TransitionGroup>中的每个子元素必须提供唯一的key。*-move类名对应的transition仅作用于transform属性,不要在其中添加opacity等其他属性的过渡。
二、KeepAlive——组件缓存策略
在电商后台中,运营人员经常需要在「基本信息」「规格参数」「库存价格」「图片管理」等标签页之间来回切换。如果每次切换都重新加载和渲染组件,不仅造成接口的重复请求,更会导致用户已填写的表单数据丢失。<KeepAlive> 正是为解决这类问题而生。
2.1 基本用法——缓存动态组件
<KeepAlive> 会缓存包裹在其中的动态组件实例。当组件被切换走时,它不会被真正销毁,而是进入一个”非活跃”状态;当组件被切换回来时,它会从缓存中恢复,而不是重新创建。
<!-- 电商后台:商品编辑标签页 -->
<template>
<div class="product-editor">
<nav class="tab-bar">
<button
v-for="tab in tabs"
:key="tab.name"
:class="{ active: activeTab === tab.name }"
@click="activeTab = tab.name"
>
{{ tab.label }}
</button>
</nav>
<KeepAlive>
<component :is="currentTabComponent" :product-id="productId" />
</KeepAlive>
</div>
</template>
<script setup>
import { ref, computed, shallowRef } from 'vue'
import ProductBasicInfo from './tabs/ProductBasicInfo.vue'
import ProductSpecParams from './tabs/ProductSpecParams.vue'
import ProductStockPrice from './tabs/ProductStockPrice.vue'
import ProductImageManager from './tabs/ProductImageManager.vue'
const productId = ref('P-10086')
const activeTab = ref('basic')
const tabs = [
{ name: 'basic', label: '基本信息', component: ProductBasicInfo },
{ name: 'spec', label: '规格参数', component: ProductSpecParams },
{ name: 'stock', label: '库存价格', component: ProductStockPrice },
{ name: 'images', label: '图片管理', component: ProductImageManager },
]
const currentTabComponent = computed(() => {
return tabs.find(t => t.name === activeTab.value)?.component
})
</script>
此时,用户在「基本信息」页填写了商品名称,切换到「规格参数」后再切回,填写内容依然保留。
2.2 include/exclude——精确控制缓存范围
并非所有组件都适合缓存。例如「图片管理」组件涉及大量图片预览,占用内存较大,可能并不适合常驻缓存。通过 include 和 exclude 属性可以精确控制:
<!-- 只缓存基本信息和规格参数两个组件 -->
<KeepAlive :include="['ProductBasicInfo', 'ProductSpecParams']">
<component :is="currentTabComponent" :product-id="productId" />
</KeepAlive>
<!-- 排除图片管理组件 -->
<KeepAlive :exclude="['ProductImageManager']">
<component :is="currentTabComponent" :product-id="productId" />
</KeepAlive>
<!-- 使用正则 -->
<KeepAlive :include="/^Product(Basic|Spec|Stock)/">
<component :is="currentTabComponent" :product-id="productId" />
</KeepAlive>
重要:
include和exclude匹配的是组件的name选项。使用<script setup>的单文件组件会自动根据文件名推断name,但如果文件名与你期望的不一致,可以通过额外的<script>块显式声明:
<script>
export default {
name: 'ProductBasicInfo'
}
</script>
<script setup>
// 组合式 API 代码...
</script>
2.3 max——LRU 缓存策略
缓存的组件越多,占用的内存越大。通过 max 属性可以限制最大缓存数量。当超出限制时,Vue 会采用 LRU(Least Recently Used,最近最少使用) 策略,移除最久未被访问的缓存实例,为新实例腾出空间。
<!-- 最多缓存 3 个组件实例 -->
<KeepAlive :max="3">
<component :is="currentTabComponent" :product-id="productId" />
</KeepAlive>
这就像一个有固定容量的置物架:架子最多放 3 个物品,当第 4 个物品需要放入时,最久没被使用过的那个会被移走。每次你取用某个物品,它就会被放到架子最显眼的位置(标记为”最近使用”)。
2.4 activated/deactivated——缓存组件的生命周期
被 <KeepAlive> 缓存的组件不会触发 mounted 和 unmounted,取而代之的是两个专属生命周期钩子:
onActivated:组件被激活(从缓存恢复到页面)时调用。首次挂载时也会调用。onDeactivated:组件被停用(从页面移入缓存)时调用。
<!-- ProductStockPrice.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
const stockData = ref([])
const lastFetchTime = ref(0)
// 每次切换回来时,检查数据是否过期
onActivated(() => {
const now = Date.now()
// 如果距离上次获取超过 5 分钟,刷新数据
if (now - lastFetchTime.value > 5 * 60 * 1000) {
fetchStockData()
}
})
onDeactivated(() => {
// 组件被缓存时,可以清理定时器等资源
console.log('库存价格组件已缓存,暂停轮询')
})
async function fetchStockData() {
stockData.value = await api.getStockInfo()
lastFetchTime.value = Date.now()
}
</script>
最佳实践: 如果缓存组件中有定时器、WebSocket 连接或事件监听,应在
onDeactivated中暂停或清理,在onActivated中恢复,避免后台的无效资源消耗。
三、Teleport——跨 DOM 层级渲染
当你在一个嵌套了 5 层的商品列表组件中打开编辑弹窗时,弹窗的 DOM 可能被外层容器的 overflow: hidden 或 z-index 截断。这是前端开发中的经典困境——组件的逻辑层级和 DOM 的渲染层级应该解耦。
3.1 为什么需要 Teleport
在没有 <Teleport> 的时代,解决弹窗层级问题通常需要手动操作 DOM:
// 过去的做法(不推荐)
export default {
mounted() {
document.body.appendChild(this.$el)
},
beforeUnmount() {
this.$el.parentNode?.removeChild(this.$el)
}
}
这种做法有两个严重问题:首先,组件内部需要维护复杂的 DOM 转移逻辑;其次,浏览器需要进行两次渲染——先在原始位置挂载,再移动到目标位置。
<Teleport> 从根本上解决了这个问题:它在渲染阶段就直接将内容挂载到目标位置,只需一次渲染,既简洁又高效。
3.2 基本用法——模态框实践
<!-- 电商后台:商品编辑弹窗 -->
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<button @click="showEditModal = true">编辑</button>
<Teleport to="body">
<div v-if="showEditModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content product-edit-modal">
<h2>编辑商品</h2>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label>商品名称</label>
<input v-model="editForm.name" type="text" />
</div>
<div class="form-group">
<label>价格</label>
<input v-model="editForm.price" type="number" />
</div>
<div class="form-actions">
<button type="button" @click="closeModal">取消</button>
<button type="submit">保存</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const props = defineProps({
product: { type: Object, required: true }
})
const emit = defineEmits(['update'])
const showEditModal = ref(false)
const editForm = reactive({
name: '',
price: 0
})
function closeModal() {
showEditModal.value = false
}
function handleSubmit() {
emit('update', { ...editForm })
closeModal()
}
</script>
to 属性接受 CSS 选择器字符串或实际的 DOM 元素引用。弹窗的 HTML 会被渲染到 <body> 的末尾,但弹窗内部的所有逻辑(v-model、事件、响应式数据)仍然属于原组件的作用域。
3.3 disabled 属性——条件性传送
有时你希望在移动端直接展示弹窗内容而非弹层,这时可以通过 disabled 属性在两种渲染模式之间切换:
<template>
<Teleport to="body" :disabled="isMobile">
<div v-if="showNotification" class="notification-popup">
<p>{{ notificationMessage }}</p>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 768)
</script>
当 disabled 为 true 时,内容不会被传送,而是在原地渲染。
3.4 实际应用场景总结
| 场景 | to 目标 | 说明 |
|---|---|---|
| 全局模态框 | body | 避免 z-index 和 overflow 问题 |
| 通知提示 | #notification-root | 集中管理通知层 |
| 工具提示 / Tooltip | body | 防止被父容器裁剪 |
| 全屏加载遮罩 | body | 覆盖整个视口 |
最佳实践: 在
index.html中预先定义好传送目标容器(如<div id="modal-root"></div>),避免在运行时动态创建。确保Teleport的to目标在组件挂载时已存在于 DOM 中。
四、Suspense——异步组件的优雅加载
在电商后台的「数据概览」页面中,可能同时存在销售趋势图表、热销排行榜、库存预警面板等多个异步组件。每个组件独立加载数据,如果不做统一管理,页面会呈现一种”拼图式”的加载体验——各个区域像爆米花一样此起彼伏地弹出。<Suspense> 帮助我们在异步组件树中优雅地协调这一过程。
注意:
<Suspense>目前仍是 Vue 3 的实验性功能,API 可能在未来版本中调整,且官方不保证其最终达到稳定状态。虽然已有不少生产应用在使用它,但在采用前应充分评估风险。以下内容旨在帮助你理解其设计理念和使用模式。
4.1 基本用法——默认内容与回退内容
<Suspense> 通过两个插槽工作:
#default:放置需要异步加载的组件。#fallback:在异步组件加载完成前显示的占位内容。
<!-- 电商后台:数据概览页 -->
<template>
<div class="dashboard">
<h2>数据概览</h2>
<Suspense>
<template #default>
<DashboardContent />
</template>
<template #fallback>
<div class="loading-state">
<LoadingSpinner />
<p>正在加载数据面板...</p>
</div>
</template>
</Suspense>
</div>
</template>
<Suspense> 能感知其内部所有异步依赖。所谓异步依赖,主要包括两类:
- 带有异步
setup()的组件:setup函数返回 Promise(使用async setup()或顶层await)。 - 异步组件:通过
defineAsyncComponent定义的组件。
<!-- DashboardContent.vue —— 含有异步 setup 的组件 -->
<script setup>
// 顶层 await 会让组件变为"异步依赖"
const salesData = await fetchSalesOverview()
const topProducts = await fetchTopProducts()
const stockAlerts = await fetchStockAlerts()
</script>
<template>
<div class="dashboard-content">
<SalesTrendChart :data="salesData" />
<TopProductsRank :products="topProducts" />
<StockAlertPanel :alerts="stockAlerts" />
</div>
</template>
当所有异步依赖都完成时,<Suspense> 会自动从 fallback 切换到 default 内容。
4.2 错误处理
<Suspense> 本身不提供错误 UI,但可以结合 onErrorCaptured 钩子来捕获异步组件中抛出的错误:
<!-- 包含错误处理的 Suspense 包装器 -->
<template>
<div class="dashboard">
<div v-if="loadError" class="error-state">
<p>数据加载失败:{{ loadError.message }}</p>
<button @click="retryLoad">重试</button>
</div>
<Suspense v-else @resolve="onResolve" @pending="onPending">
<template #default>
<DashboardContent :key="retryKey" />
</template>
<template #fallback>
<LoadingState message="正在加载概览数据..." />
</template>
</Suspense>
</div>
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue'
const loadError = ref(null)
const retryKey = ref(0)
onErrorCaptured((error) => {
loadError.value = error
return false // 阻止错误向上传播
})
function retryLoad() {
loadError.value = null
retryKey.value++
}
function onPending() {
console.log('开始加载...')
}
function onResolve() {
console.log('加载完成')
}
</script>
4.3 嵌套 Suspense
当组件树中存在多层嵌套的异步依赖时,内层的 <Suspense> 可以独立管理自己的加载状态:
<template>
<!-- 外层 Suspense:管理页面级加载 -->
<Suspense>
<template #default>
<div class="analytics-page">
<AnalyticsHeader />
<!-- 内层 Suspense:管理图表区域加载 -->
<Suspense>
<template #default>
<SalesChartPanel />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</div>
</template>
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>
嵌套场景的规则:内层 <Suspense> 的异步依赖完成后,其 effects(如 onMounted)会被收集到外层的 <Suspense> 中。只有当外层的所有异步依赖也完成后,这些副作用才会统一执行。这意味着生命周期钩子的执行顺序由最外层的 <Suspense> 统一协调。
4.4 配合路由使用
<Suspense> 与 Vue Router 搭配使用时效果尤为出色:
<!-- App.vue -->
<template>
<nav>
<RouterLink to="/products">商品管理</RouterLink>
<RouterLink to="/orders">订单管理</RouterLink>
<RouterLink to="/analytics">数据分析</RouterLink>
</nav>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<div class="route-loading">
<LoadingBar />
</div>
</template>
</Suspense>
</template>
</RouterView>
</template>
最佳实践: 结合
<Transition>和<KeepAlive>使用时,注意嵌套顺序必须是<RouterView>><template v-if="Component">><Transition>><KeepAlive>><Suspense>><Component>(由外到内)。v-if守卫确保在Component为空时不会触发<Transition>报错。详见 Vue Router 官方文档。
五、自定义指令——扩展模板的能力
Vue 的模板系统通过内置指令(v-if、v-for、v-model)提供了声明式的 DOM 操作能力。当内置指令无法满足需求时,自定义指令允许你将底层 DOM 操作封装为可复用的声明式 API。
5.1 指令的生命周期
自定义指令的生命周期钩子与组件类似,但作用于其绑定的 DOM 元素:
const myDirective = {
// 元素的 attribute 或事件监听器被应用之前
created(el, binding, vnode, prevVnode) {},
// 元素被插入到 DOM 之前
beforeMount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件及所有子节点挂载完成后
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新之前
beforeUpdate(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件及所有子节点更新完成后
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载之前
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后
unmounted(el, binding, vnode, prevVnode) {}
}
binding 对象包含以下属性:
| 属性 | 说明 |
|---|---|
value | 指令绑定的值,如 v-my="3" 中的 3 |
oldValue | 之前的值(仅在 beforeUpdate 和 updated 中可用) |
arg | 传给指令的参数,如 v-my:foo 中的 "foo" |
modifiers | 修饰符对象,如 v-my.trim 中的 { trim: true } |
instance | 使用该指令的组件实例 |
dir | 指令的定义对象 |
5.2 实用指令一:v-focus
最简单但最实用的指令——自动获取焦点:
<script setup>
// 在 <script setup> 中,任何以 v 开头的 camelCase 变量都可以作为自定义指令
const vFocus = {
mounted(el) {
// 支持在 input 外层包一个容器的场景
const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
input?.focus()
}
}
</script>
<template>
<!-- 商品搜索框自动聚焦 -->
<input v-focus type="text" placeholder="搜索商品..." />
</template>
5.3 实用指令二:v-click-outside
电商后台中下拉菜单、筛选面板等组件需要在点击外部区域时自动关闭:
// directives/clickOutside.js
export const vClickOutside = {
mounted(el, binding) {
if (typeof binding.value !== 'function') {
console.warn('v-click-outside 需要绑定一个函数')
return
}
el._clickOutsideHandler = (event) => {
// 判断点击是否发生在元素外部
if (!el.contains(event.target) && el !== event.target) {
binding.value(event)
}
}
// 使用 setTimeout 确保不会捕获触发打开的那次点击
setTimeout(() => {
document.addEventListener('click', el._clickOutsideHandler)
}, 0)
},
unmounted(el) {
if (el._clickOutsideHandler) {
document.removeEventListener('click', el._clickOutsideHandler)
delete el._clickOutsideHandler
}
}
}
<template>
<!-- 商品分类筛选下拉面板 -->
<div class="category-filter">
<button @click="showPanel = !showPanel">选择分类</button>
<div
v-if="showPanel"
v-click-outside="() => showPanel = false"
class="filter-panel"
>
<label v-for="cat in categories" :key="cat.id">
<input type="checkbox" :value="cat.id" v-model="selectedCategories" />
{{ cat.name }}
</label>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { vClickOutside } from '@/directives/clickOutside'
const showPanel = ref(false)
const selectedCategories = ref([])
const categories = ref([
{ id: 1, name: '电子产品' },
{ id: 2, name: '家居生活' },
{ id: 3, name: '食品饮料' },
])
</script>
5.4 实用指令三:v-lazy(图片懒加载)
商品列表页通常有大量图片,全部同时加载会严重影响性能。使用 Intersection Observer API 实现图片懒加载指令:
// directives/lazyImage.js
export const vLazy = {
mounted(el, binding) {
const placeholderSrc = el.dataset.placeholder || '/images/placeholder.png'
// 先设置占位图
el.src = placeholderSrc
el.style.transition = 'opacity 0.3s ease'
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 元素进入视口,加载真实图片
const img = new Image()
img.src = binding.value
img.onload = () => {
el.src = binding.value
el.style.opacity = '1'
}
img.onerror = () => {
el.src = '/images/error-placeholder.png'
}
// 加载后取消观察
observer.unobserve(el)
}
})
},
{
rootMargin: '200px', // 提前 200px 开始加载
threshold: 0.01
}
)
el._lazyObserver = observer
observer.observe(el)
},
updated(el, binding) {
// 当绑定值变化时,更新图片
if (binding.value !== binding.oldValue) {
el.src = binding.value
}
},
unmounted(el) {
if (el._lazyObserver) {
el._lazyObserver.disconnect()
delete el._lazyObserver
}
}
}
<template>
<!-- 商品列表中使用懒加载 -->
<div class="product-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<img
v-lazy="product.coverImage"
:alt="product.name"
data-placeholder="/images/product-placeholder.png"
class="product-cover"
/>
<h4>{{ product.name }}</h4>
<span class="price">¥{{ product.price }}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { vLazy } from '@/directives/lazyImage'
const products = ref([
{ id: 1, name: '无线充电器', price: '79.00', coverImage: '/images/charger.jpg' },
{ id: 2, name: '蓝牙音箱', price: '199.00', coverImage: '/images/speaker.jpg' },
// ...更多商品
])
</script>
5.5 全局注册指令
局部定义适合单个组件使用,全局注册则让指令在整个应用中可用:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { vClickOutside } from './directives/clickOutside'
import { vLazy } from './directives/lazyImage'
const app = createApp(App)
app.directive('click-outside', vClickOutside)
app.directive('lazy', vLazy)
// 简写形式:会在 mounted 和 updated 时都执行
// 注意:每次组件更新都会重新聚焦,按需选择是否使用简写
app.directive('focus', (el) => {
const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
input?.focus()
})
app.mount('#app')
最佳实践: 自定义指令适用于需要直接操作 DOM 的场景(焦点管理、滚动监听、第三方库集成等)。对于涉及组件状态和响应式数据的复杂逻辑,应优先考虑使用组合式函数(Composable)而非指令。
六、插件系统——构建可复用的应用级功能
当你需要为整个应用添加全局功能——比如统一的 HTTP 客户端、全局消息提示、权限检查、埋点系统——这时就需要 Vue 的插件系统。
6.1 app.use() 的工作机制
app.use(plugin, options) 是注册插件的标准方式。Vue 对插件有两种接受形式:
- 对象形式:具有
install方法的对象。 - 函数形式:一个函数本身就是
install方法。
// 对象形式
const myPlugin = {
install(app, options) {
// 在此处配置应用级功能
}
}
// 函数形式
function myPlugin(app, options) {
// 在此处配置应用级功能
}
// 使用
app.use(myPlugin, { /* 选项 */ })
install 方法接收应用实例 app 和传入的选项 options,可以在其中:
- 通过
app.component()注册全局组件 - 通过
app.directive()注册全局指令 - 通过
app.provide()提供应用级的依赖注入 - 通过
app.config.globalProperties添加全局属性/方法
6.2 实战:编写全局消息提示插件
// plugins/toast.js
import { ref, createApp, h } from 'vue'
import ToastContainer from './ToastContainer.vue'
const toastState = ref([])
let toastId = 0
function showToast(message, options = {}) {
const id = toastId++
const toast = {
id,
message,
type: options.type || 'info', // 'info' | 'success' | 'warning' | 'error'
duration: options.duration || 3000
}
toastState.value.push(toast)
if (toast.duration > 0) {
setTimeout(() => {
removeToast(id)
}, toast.duration)
}
return id
}
function removeToast(id) {
const index = toastState.value.findIndex(t => t.id === id)
if (index > -1) {
toastState.value.splice(index, 1)
}
}
export const toastPlugin = {
install(app) {
// 1. 通过 provide 注入 toast 服务,供组合式 API 使用
const toastService = {
info: (msg, opts) => showToast(msg, { ...opts, type: 'info' }),
success: (msg, opts) => showToast(msg, { ...opts, type: 'success' }),
warning: (msg, opts) => showToast(msg, { ...opts, type: 'warning' }),
error: (msg, opts) => showToast(msg, { ...opts, type: 'error' }),
remove: removeToast
}
app.provide('toast', toastService)
// 2. 同时挂载到全局属性,供选项式 API 使用
app.config.globalProperties.$toast = toastService
// 3. 创建 Toast 容器并挂载到 DOM
const container = document.createElement('div')
container.id = 'toast-container'
document.body.appendChild(container)
const toastApp = createApp({
setup() {
return () => h(ToastContainer, { toasts: toastState.value })
}
})
toastApp.mount(container)
}
}
<!-- ToastContainer.vue -->
<template>
<Teleport to="body">
<div class="toast-wrapper">
<TransitionGroup name="toast" tag="div">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast-item', `toast-${toast.type}`]"
>
{{ toast.message }}
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
defineProps({
toasts: { type: Array, default: () => [] }
})
</script>
<style scoped>
.toast-wrapper {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast-enter-active {
transition: all 0.3s ease;
}
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
6.3 使用插件
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { toastPlugin } from './plugins/toast'
const app = createApp(App)
app.use(toastPlugin)
app.mount('#app')
<!-- 在组件中使用 -->
<script setup>
import { inject } from 'vue'
const toast = inject('toast')
async function handleProductSave() {
try {
await saveProduct(formData)
toast.success('商品保存成功')
} catch (error) {
toast.error(`保存失败:${error.message}`)
}
}
</script>
6.4 实战:权限检查插件
另一个典型场景——在电商后台中,不同角色看到的按钮和功能不同:
// plugins/permission.js
export const permissionPlugin = {
install(app, options = {}) {
const { getUserPermissions } = options
const permissionService = {
permissions: new Set(),
async load() {
const perms = await getUserPermissions()
this.permissions = new Set(perms)
},
has(permission) {
return this.permissions.has(permission)
},
hasAny(...permissions) {
return permissions.some(p => this.permissions.has(p))
},
hasAll(...permissions) {
return permissions.every(p => this.permissions.has(p))
}
}
// 注册全局指令 v-permission
app.directive('permission', {
mounted(el, binding) {
const requiredPermission = binding.value
if (!permissionService.has(requiredPermission)) {
// 根据修饰符决定隐藏还是禁用
if (binding.modifiers.disable) {
el.disabled = true
el.style.opacity = '0.5'
el.style.cursor = 'not-allowed'
} else {
el.style.display = 'none'
}
}
}
})
app.provide('permission', permissionService)
app.config.globalProperties.$permission = permissionService
}
}
<template>
<div class="product-actions">
<!-- 没有权限则隐藏按钮 -->
<button v-permission="'product:create'" @click="createProduct">
新增商品
</button>
<!-- 没有权限则禁用按钮 -->
<button v-permission.disable="'product:delete'" @click="deleteProduct">
删除商品
</button>
</div>
</template>
最佳实践: 插件系统的核心价值在于应用级的关注点封装。一个好的插件应该:(1)通过
provide/inject暴露服务,便于组合式 API 使用;(2)通过globalProperties兼容选项式 API;(3)提供合理的默认配置,同时允许自定义。避免在插件中滥用全局状态,尽量通过依赖注入保持可测试性。
🤔 思考题
-
Transition + KeepAlive 协作:如果在
<KeepAlive>外层包裹<Transition>,当缓存组件被切换回来时,进入动画会执行吗?为什么?请动手验证。 -
Teleport 与组件通信:一个通过
<Teleport>传送到body的子组件,它的provide/inject链路是否会被打断?它能否接收到原父组件通过provide提供的数据? -
自定义指令 vs 组合式函数:有人说”自定义指令能做的事,Composable 都能做”。你认同吗?请举出一个只适合用自定义指令的场景和一个只适合用 Composable 的场景。
-
Suspense 的边界条件:如果
<Suspense>内部只有同步组件(没有任何异步依赖),它的行为会是什么样的?fallback 插槽会显示吗?
📝 结尾自测
-
<TransitionGroup>与<Transition>有哪些关键区别?至少说出三点。 -
以下代码中,
<KeepAlive>能正常工作吗?为什么?<KeepAlive> <ComponentA v-if="show" /> <ComponentB v-else /> </KeepAlive> -
在以下场景中,
Teleport的to目标元素尚未渲染到 DOM 中会发生什么?<Teleport to="#not-yet-mounted"> <div>内容</div> </Teleport> -
自定义指令的
binding对象中,value和oldValue分别在哪些钩子中可用?如果只需要在mounted和updated中执行相同逻辑,有什么简写方式? -
使用
app.use()注册同一个插件两次会发生什么?Vue 内部是如何处理的?
本章小结
本章围绕电商后台管理系统的真实场景,系统讲解了 Vue 3 的六大内置组件与高级特性:
- Transition / TransitionGroup:通过声明式的 CSS 类名切换和 JavaScript 钩子,为元素和列表提供进入、离开、位移动画。
- KeepAlive:基于 LRU 缓存策略缓存组件实例,配合
include/exclude/max精细控制缓存范围,通过activated/deactivated管理缓存组件的生命周期。 - Teleport:将组件的 DOM 输出”传送”到任意目标位置,在渲染阶段直接完成挂载,解决了层级嵌套导致的 z-index 和 overflow 问题。
- Suspense:通过统一的 fallback 机制协调异步组件树的加载状态,支持嵌套和错误处理。
- 自定义指令:将底层 DOM 操作封装为声明式 API,适用于焦点管理、点击外部检测、图片懒加载等场景。
- 插件系统:通过
app.use()注册应用级功能,结合provide/inject、全局属性和全局指令构建可复用的基础设施。
掌握这些工具后,你已经具备了构建专业级 Vue 应用的完整武器库。下一章,我们将进入工程化实践的领域。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90