跳到主要内容
预计阅读 75 分钟

内置组件与高级特性

在电商后台管理系统中,你一定遇到过这些需求:订单列表切换时需要平滑动画,商品编辑页面在标签页切换时不能丢失表单状态,商品详情弹窗要渲染到 body 层级以避免被父容器裁剪,异步加载的报表组件需要统一展示加载状态,图片需要懒加载以提升首屏性能,还需要一套全局插件来注入通用服务……

这些需求看似分散,实则指向同一个核心主题——Vue 3 的内置组件与高级特性。Vue 将开发者最常遇到的交互模式提炼为内置组件(Transition、KeepAlive、Teleport、Suspense),并通过自定义指令和插件系统提供了灵活的扩展机制。

本章将以「电商后台管理系统」为贯穿场景,系统讲解这六大主题。掌握它们,你才能从”能写出来”进化到”写得专业”。

📋 开篇自测

在正式学习前,试着回答以下三个问题,检验你对内置组件的现有认知:

  1. <Transition> 组件本身会渲染出额外的 DOM 元素吗?它是如何控制子元素动画效果的?
  2. 使用 <KeepAlive> 缓存组件时,如果缓存的组件越来越多,会出现什么问题?Vue 提供了什么策略来解决?
  3. 一个弹窗组件写在深层嵌套的子组件内部,但希望它渲染到 <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 过渡检测,避免不必要的性能开销。同时必须在 onEnteronLeave 中调用 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——精确控制缓存范围

并非所有组件都适合缓存。例如「图片管理」组件涉及大量图片预览,占用内存较大,可能并不适合常驻缓存。通过 includeexclude 属性可以精确控制:

<!-- 只缓存基本信息和规格参数两个组件 -->
<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>

重要: includeexclude 匹配的是组件的 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> 缓存的组件不会触发 mountedunmounted,取而代之的是两个专属生命周期钩子:

  • 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: hiddenz-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>

disabledtrue 时,内容不会被传送,而是在原地渲染。

3.4 实际应用场景总结

场景to 目标说明
全局模态框body避免 z-index 和 overflow 问题
通知提示#notification-root集中管理通知层
工具提示 / Tooltipbody防止被父容器裁剪
全屏加载遮罩body覆盖整个视口

最佳实践:index.html 中预先定义好传送目标容器(如 <div id="modal-root"></div>),避免在运行时动态创建。确保 Teleportto 目标在组件挂载时已存在于 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> 能感知其内部所有异步依赖。所谓异步依赖,主要包括两类:

  1. 带有异步 setup() 的组件setup 函数返回 Promise(使用 async setup() 或顶层 await)。
  2. 异步组件:通过 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-ifv-forv-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之前的值(仅在 beforeUpdateupdated 中可用)
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 对插件有两种接受形式:

  1. 对象形式:具有 install 方法的对象。
  2. 函数形式:一个函数本身就是 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)提供合理的默认配置,同时允许自定义。避免在插件中滥用全局状态,尽量通过依赖注入保持可测试性。


🤔 思考题

  1. Transition + KeepAlive 协作:如果在 <KeepAlive> 外层包裹 <Transition>,当缓存组件被切换回来时,进入动画会执行吗?为什么?请动手验证。

  2. Teleport 与组件通信:一个通过 <Teleport> 传送到 body 的子组件,它的 provide/inject 链路是否会被打断?它能否接收到原父组件通过 provide 提供的数据?

  3. 自定义指令 vs 组合式函数:有人说”自定义指令能做的事,Composable 都能做”。你认同吗?请举出一个只适合用自定义指令的场景和一个只适合用 Composable 的场景。

  4. Suspense 的边界条件:如果 <Suspense> 内部只有同步组件(没有任何异步依赖),它的行为会是什么样的?fallback 插槽会显示吗?


📝 结尾自测

  1. <TransitionGroup><Transition> 有哪些关键区别?至少说出三点。

  2. 以下代码中,<KeepAlive> 能正常工作吗?为什么?

    <KeepAlive>
      <ComponentA v-if="show" />
      <ComponentB v-else />
    </KeepAlive>
  3. 在以下场景中,Teleportto 目标元素尚未渲染到 DOM 中会发生什么?

    <Teleport to="#not-yet-mounted">
      <div>内容</div>
    </Teleport>
  4. 自定义指令的 binding 对象中,valueoldValue 分别在哪些钩子中可用?如果只需要在 mountedupdated 中执行相同逻辑,有什么简写方式?

  5. 使用 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