第三章:组件化工程 · 铸造类型安全、高内聚的 UI 单元
摘要: 如果说响应式系统是 Vue 的“灵魂”,那么组件化就是 Vue 的“躯体”。本章,我们将从“工匠”晋升为“建筑师”,学习如何设计和构建出 高复用、低耦合、API 友好 的 UI 单元。我们将深入 <script setup>
这一现代 Vue 组件的最终形态,并以 TypeScript 作为我们的“结构图纸”,为 Props
、Emits
、Slots
等所有内外接口建立严格的类型契约。学完本章,你将有能力构建出在大型项目中依然能保持清晰、可维护的复杂组件系统。
3.1. <script setup>
:组件语法的最终形态
在深入组件设计的细节之前,我们必须先掌握最先进的“建造工具”——<script setup>
。它并非简单的语法糖,而是一场关乎开发效率、代码可读性和性能的革命。
3.1.1. 核心痛点:Options API 的“逻辑孤岛”
想象一下,我们在用 Vue 2 的 Options API 构建一个带有搜索功能的列表组件。
data
中需要定义 searchQuery
和 items
。methods
中需要一个 fetchItems
方法。computed
中可能有一个 filteredItems
。watch
中需要监听 searchQuery
的变化来重新获取数据。mounted
中需要进行初次数据加载。
一个“搜索功能”,它的逻辑被迫分散在五个不同的“孤岛”上。当组件变得复杂时,维护和理解这段代码将成为一场噩梦。这就是 逻辑分散 的问题。
3.1.2. 解决方案:<script setup>
的“逻辑内聚”
<script setup>
允许我们将同一个功能的代码组织在一起,就像在写一个普通的 JavaScript/TypeScript 模块。
文件路径: src/components/FeatureList.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <script setup lang="ts"> import { ref, watch, onMounted } from 'vue';
const searchQuery = ref(''); const items = ref<string[]>([]);
async function fetchItems() { console.log(`正在以 "${searchQuery.value}" 为关键词搜索...`); const response = ['Apple', 'Banana', 'Cherry'].filter(item => item.toLowerCase().includes(searchQuery.value.toLowerCase()) ); items.value = response; }
watch(searchQuery, fetchItems); onMounted(fetchItems);
</script>
<template> <div> <input type="text" v-model="searchQuery" placeholder="搜索..." /> <ul> <li v-for="item in items" :key="item">{{ item }}</li> </ul> </div> </template>
|
<script setup>
的架构优势:
- 逻辑内聚:所有与“搜索”相关的状态、方法和副作用都集中在一起,代码的可读性和可维护性呈指数级提升。
- 更少的样板代码:告别
export default { setup() { ... } }
的冗长结构。 - 编译时优化:Vue 编译器可以更智能地分析
<script setup>
中的代码,生成更高效的渲染函数,运行时性能更佳。 - 告别
this
:在 <script setup>
的世界里,没有 this
的心智负担,所有逻辑都更接近纯粹的 JavaScript 函数式编程。
3.2. 组件的“公共 API”:Props
与 Emits
一个设计良好的组件就像一个黑盒,它通过 Props
(输入)和 Emits
(输出)与外界清晰地交互,而将其内部实现细节完全封装。
3.2.1. defineProps
:父给子传数据
Props
是父组件向子组件传递数据的通道。在架构层面,我们必须遵循 单向数据流 原则:子组件永远不应直接修改父组件传递的 Prop。这保证了数据流的可追溯性,让应用状态变得可预测。
defineProps<T>
与 withDefaults
我们将创建一个更复杂的 CustomInput
组件,它需要接收 modelValue
、label
和一个可选的 placeholder
。
文件路径: src/components/CustomInput.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <script setup lang="ts">
interface Props { modelValue: string; label: string; placeholder?: string; }
const props = withDefaults(defineProps<Props>(), { placeholder: '请输入内容...', });
</script>
<template> <div class="custom-input-wrapper"> <label>{{ props.label }}</label> <input type="text" :value="props.modelValue" :placeholder="props.placeholder" /> </div> </template>
|
3.2.2. 组件通信:使用 defineEmits
从子组件向父组件传递信息
当子组件内部发生变化(例如,用户输入),而父组件需要知道这个变化时,子组件不应直接修改 Prop
(因为这是单向数据流所禁止的)。正确的做法是:子组件通过 emit
(发出)一个自定义事件,并附带上需要传递的数据,父组件则通过 @
符号来监听这个事件并接收数据。
这就像子组件在对父组件“喊话”:“嘿,我的值变了,新值是这个!”
第一步:在子组件中定义和触发事件
我们继续使用 CustomInput
组件。这次,我们先定义一个通用的 input-change
事件来传递最新的输入值。
文件路径: src/components/CustomInput.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <script setup lang="ts"> interface Props { value: string; label: string; placeholder?: string; }
const props = withDefaults(defineProps<Props>(), { placeholder: '请输入内容...', });
const emit = defineEmits<{ (event: 'input-change', value: string): void; }>();
function onInput(event: Event) { const target = event.target as HTMLInputElement; emit('input-change', target.value); } </script>
<template> <div class="custom-input-wrapper"> <label>{{ props.label }}</label>
<input type="text" :value="props.value" :placeholder="props.placeholder" @input="onInput" /> </div> </template>
|
第二步:在父组件中监听事件并更新数据
现在,父组件需要“收听”子组件发出的 input-change
事件,并在事件发生时执行一个方法来更新自己的数据。
文件路径: src/App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <script setup lang="ts"> import { ref } from 'vue'; import CustomInput from '@/components/CustomInput.vue';
const username = ref('');
function handleUsernameChange(newValue: string) { console.log('子组件传来了新值:', newValue); username.value = newValue; } </script>
<template> <p>父组件中的值: {{ username }}</p>
<CustomInput :value="username" @input-change="handleUsernameChange" label="用户名" placeholder="请输入用户名" /> </template>
|
现在,你已经掌握了父子组件通信最核心、最通用的模式:父组件通过 Props 向下传递数据,子组件通过 Emits 向上发送通知。 这种模式适用于任何场景。
3.2.3. 双向绑定:v-model
的优雅语法糖
你可能已经注意到了,上面“传递 value
Prop”和“监听 input-change
事件来更新 value
”的组合非常常见。为了简化这种模式,Vue 提供了一个强大的语法糖:v-model
。
v-model
本质上就是下面这行代码的简写:
:modelValue="someRef" @update:modelValue="newValue => someRef = newValue"
要让我们的组件支持 v-model
,我们只需要遵循一个简单的约定:
- Prop:接收的 Prop 名称必须是
modelValue
。 - Emit:触发的事件名称必须是
update:modelValue
。
让我们把 CustomInput
组件改造成支持 v-model
的标准形式。
文件路径: src/components/CustomInput.vue
(改回 v-model
版本)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <script setup lang="ts"> interface Props { modelValue: string; label: string; placeholder?: string; } defineProps<Props>();
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void; }>();
function onInput(event: Event) { const target = event.target as HTMLInputElement; emit('update:modelValue', target.value); } </script>
<template> <div class="custom-input-wrapper"> <label>{{ label }}</label> <input type="text" :value="modelValue" :placeholder="placeholder" @input="onInput" /> </div> </template>
|
文件路径: src/App.vue
(使用 v-model
的版本)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <script setup lang="ts"> import { ref } from 'vue'; import CustomInput from '@/components/CustomInput.vue';
const inputValue = ref(''); </script>
<template> <p>当前输入值: {{ inputValue }}</p>
<CustomInput v-model="inputValue" label="用户名" placeholder="请输入用户名" /> </template>
|
总结:
v-model
不是魔法,它是 :prop
+ @event
模式的一个约定和语法糖。- 理解底层的事件监听机制 (
@input-change
) 对于创建更复杂、更灵活的组件至关重要,也是调试问题的基础。 - 当你的组件功能符合“双向绑定”的语义时,优先使用
v-model
,因为它更符合 Vue 开发者的习惯,也让代码更简洁。
3.3. Slots
:实现 UI 的“控制反转”
Props
擅长传递数据,但当我们需要传递复杂的 UI 结构时,Props
就显得力不从心了。这时,Slots
(插槽)就登场了。
架构思想: Slots
是一种 控制反转 (Inversion of Control, IoC) 的体现。组件本身不决定其所有内容的具体实现,而是定义一个“坑”(插槽),把填充这个“坑”的“控制权”反转 给了父组件。
3.3.1. 默认、具名与作用域插槽
我们将创建一个通用的 BaseLayout
组件来演示所有类型的插槽。
文件路径: src/components/BaseLayout.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <script setup lang="ts"> const items = ['Vue', 'React', 'Angular']; </script>
<template> <div class="layout"> <header> <slot name="header"> <h1>默认标题</h1> </slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer" :items="items" :currentYear="new Date().getFullYear()"> </slot> </footer> </div> </template>
|
在 App.vue
中使用它:
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <script setup lang="ts"> import BaseLayout from '@/components/BaseLayout.vue'; </script>
<template> <BaseLayout> <template #header> <h2>我的应用</h2> </template>
<p>这是应用的主要内容。</p>
<template #footer="{ items, currentYear }"> <p> © {{ currentYear }}. 技术栈: {{ items.join(', ') }} </p> </template> </BaseLayout> </template>
|
3.3.2. (TS 实战) defineSlots
:为插槽提供类型
Vue 3.3+ 引入了 defineSlots
宏,我们可以像定义 Props
和 Emits
一样,为插槽提供类型。
文件路径: src/components/BaseLayout.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <script setup lang="ts"> const items = ['Vue', 'React', 'Angular'];
const slots = defineSlots<{ header?: () => any; default?: () => any; footer?: (scope: { items: string[]; currentYear: number }) => any; }>(); </script>
<template> <div class="layout"> <header> <template v-if="slots.header"> <slot name="header" /> </template> <template v-else> <h1>默认标题</h1> </template> </header> <main> <slot /> </main> <footer> <template v-if="slots.footer"> <slot name="footer" :items="items" :currentYear="new Date().getFullYear()" /> </template> </footer> </div> </template>
|
这为模板作者提供了极大的便利,当使用作用域插槽时,能获得完整的类型提示和自动补全。
3.4. 组件实例的交互:defineExpose
与模板 ref
架构思想: 默认情况下,组件的内部状态和方法是私有的,这是 封装 的体现。但是,在某些特定场景下(如表单校验、媒体播放),父组件需要调用子组件的公共方法。defineExpose
就是打开一个受控的、明确的“后门”。
文件路径: src/components/ValidationForm.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <script setup lang="ts"> import { ref } from 'vue';
const internalValue = ref('');
async function validate() { console.log('开始校验...'); return internalValue.value.length > 0; }
defineExpose({ validate, }); </script>
<template> <input type="text" v-model="internalValue" /> </template>
|
在父组件中,通过模板 ref
来获取组件实例并调用其暴露的方法。
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <script setup lang="ts"> import { ref } from 'vue'; import ValidationForm from '@/components/ValidationForm.vue';
const formRef = ref<InstanceType<typeof ValidationForm> | null>(null);
async function submitForm() { if (formRef.value) { const isValid = await formRef.value.validate(); alert(isValid ? '表单提交成功' : '表单校验失败'); } } </script>
<template> <ValidationForm ref="formRef" /> <button @click="submitForm">提交</button> </template>
|
3.5. 高级组件模式:应对复杂场景的架构利器
掌握了 Props
, Emits
, Slots
和 Expose
之后,你就拥有了构建绝大多数组件的能力。然而,在大型应用中,我们还会遇到更复杂的场景,例如根据状态动态渲染不同 UI、或者为了极致的性能而按需加载组件。Vue 提供了两种强大的高级模式来应对这些挑战。
3.5.1. 动态组件:<component :is="...">
is
后面可以是组件的名称或组件的引用,通过改变 is
绑定的值,就能动态切换要渲染的组件 。
**架构思想:状态驱动的 UI 渲染 **。在复杂的界面中,我们常常需要根据某个状态值来决定渲染哪个组件。与其使用繁琐的 v-if/v-else-if/...
链条,不如将“要渲染哪个组件”这个决定权本身也交给一个响应式状态来管理。这大大地 解耦了渲染逻辑和视图结构。
典型场景:设置页面中的标签页(个人资料、账户安全、通知设置)、多步骤表单向导等。
构建一个动态标签页系统
让我们构建一个简单的设置页面,可以通过点击按钮切换不同的设置面板。
第一步:创建几个“面板”子组件
文件路径: src/components/tabs/ProfileSettings.vue
(新增)
1 2 3 4 5 6
| <template> <div class="tab-panel"> <h3>个人资料</h3> <label>用户名: <input type="text" value="TechLead" /></label> </div> </template>
|
文件路径: src/components/tabs/SecuritySettings.vue
(新增)
1 2 3 4 5 6 7
| <template> <div class="tab-panel"> <h3>账户安全</h3> <label>旧密码: <input type="password" /></label> <label>新密码: <input type="password" /></label> </div> </template>
|
第二步:在父组件中使用动态组件
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| <script setup lang="ts"> import { ref, shallowRef, type Component } from 'vue'; import ProfileSettings from '@/components/tabs/ProfileSettings.vue'; import SecuritySettings from '@/components/tabs/SecuritySettings.vue';
interface Tab { name: string; component: Component; }
const tabs: Tab[] = [ { name: '个人资料', component: ProfileSettings }, { name: '账户安全', component: SecuritySettings }, ];
const currentTabComponent = shallowRef<Component>(ProfileSettings); </script>
<template> <div class="tabs-container"> <div class="tab-buttons"> <button v-for="tab in tabs" :key="tab.name" @click="currentTabComponent = tab.component"> {{ tab.name }} </button> </div>
<main class="tab-content">
<KeepAlive> <component :is="currentTabComponent" /> </KeepAlive> </main> </div> </template>
|
这个重构后的例子不仅完整可运行,还引入了 shallowRef
和 <KeepAlive>
这两个在真实项目中与动态组件形影不离的最佳实践,并展示了如何用 TypeScript 来为组件集合提供类型。
3.5.2. 异步组件:defineAsyncComponent
架构思想:按需加载与代码分割。在一个大型单页应用 (SPA) 中,如果将所有页面的所有组件代码都打包进一个巨大的 JavaScript 文件,会导致首次加载速度极慢,严重影响用户体验。异步组件允许我们将代码分割成多个小块(chunks),只有当某个组件 实际需要被渲染时,对应的代码块才会被从服务器下载和执行。
典型场景:
- 路由懒加载:这是最常见的用法,每个页面组件都是异步的。
- 重量级组件:如图表、富文本编辑器、复杂的弹窗等,它们体积较大且不是每个用户都会立即用到。
(TS 实战) 优雅地加载一个重量级组件
假设我们有一个 UserProfileCard
组件,它依赖一个庞大的图表库,我们希望只在用户点击按钮后才加载它。
第一步:创建目标组件和加载/错误状态组件
文件路径: src/components/UserProfileCard.vue
(新增)
1 2 3 4 5 6 7 8 9
| <script setup lang="ts"> console.log('UserProfileCard 组件被加载并执行了!'); </script> <template> <div class="user-profile-card"> <h2>用户资料卡</h2> <p>这里包含很多数据和图表...</p> </div> </template>
|
文件路径: src/components/common/LoadingSpinner.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <template> <div class="spinner"></div> </template>
<style scoped lang="scss"> .spinner { width: 32px; height: 32px; border: 4px solid #eee; border-top: 4px solid #409eff; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 16px auto; }
@keyframes spin { to { transform: rotate(360deg); } } </style>
|
文件路径: src/components/common/LoadingError.vue
(新增)
1
| <template><p>组件加载失败!</p></template>
|
第二步:在父组件中使用 defineAsyncComponent
和 <Suspense>
Vue 3 的 <Suspense>
组件为处理异步依赖(包括异步组件)提供了一流的内置支持,是现代 Vue 应用处理加载状态的首选方案。
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <script setup lang="ts"> import { ref, defineAsyncComponent } from 'vue'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import LoadingError from '@/components/common/LoadingError.vue';
const AsyncComp = defineAsyncComponent({ loader: () => new Promise((resolve) => { setTimeout(() => { resolve(import('@/components/UserProfileCard.vue')); }, 2000); }), loadingComponent: LoadingSpinner, errorComponent: LoadingError, delay: 200, timeout: 3000, suspensible: false, onError(error, retry, fail, attempts) { if (error.message.match(/fetch/) && attempts <= 3) { retry(); } else { fail(); } }, });
const showProfile = ref(false); </script>
<template> <div> <button @click="showProfile = !showProfile"> {{ showProfile ? '隐藏' : '显示' }}用户资料 </button> <hr>
<div v-if="showProfile" class="profile-container"> <AsyncComp /> </div> </div> </template>
<style></style>
|
注意:defineAsyncComponent
自身的 loadingComponent
选项和 <Suspense>
的 #fallback
插槽功能上有些重叠。现代实践中,更推荐使用 <Suspense>
,因为它能统一处理组件树中 任何层级 的多个异步依赖,而 loadingComponent
只对自己负责,组合性较差。这里为了教学完整性两者都展示了。
3.6. 内置组件深度解析:驾驭布局、异步与性能核心组件
引言: 我们已经掌握了构建和组合组件的十八般武艺,但 Vue 的工具箱中还藏着几位“特种兵”。它们不是用来构建 UI 元素的,而是用来解决那些常规组件组合难以处理的、棘手的布局、异步和性能问题。精通它们,是区分资深开发者与普通开发者的重要标志。
3.6.1. <Teleport>
- “空间传送门”
核心痛点: 在复杂的 DOM 结构中,一个组件的渲染位置会受到其父元素 CSS 属性的严格限制。最典型的噩梦场景就是:一个深层嵌套的组件想要弹出一个全局模态框(Modal),但其父容器设置了 overflow: hidden
或 z-index
限制,导致模态框被裁剪或被其他元素错误地遮挡。
<Teleport>
提供了一个干净利落的解决方案:它能将一个组件的 DOM 结构,“传送”到当前 Vue 应用挂载的 DOM 树中的任何位置,同时保持其逻辑上的父子关系和状态连接不变。
用法: 它的核心是一个 to
属性,它接受一个 CSS 选择器字符串或一个真实的 DOM 节点作为目标。最常见的用法是 to="body"
,将元素直接挂载到 <body>
标签下,从而跳出所有父元素的样式限制。
(TS 实战) 构建一个不受 DOM 结构限制的全局模态框
我们将构建一个可复用的 BaseModal
组件,无论它在何处被调用,其内容都将被可靠地渲染在页面的最顶层。
第一步:创建 BaseModal
组件
文件路径: src/components/common/BaseModal.vue
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| <script setup lang="ts">
defineProps<{ show: boolean; }>();
const emit = defineEmits<{ (e: "close"): void; }>(); </script>
<template> <teleport to="body"> <div class="modal-mask" v-if="show"> <div class="modal-container"> <header> <slot name="header">默认标题</slot> </header> <main> <slot /> </main> <footer> <button @click="emit('close')">关闭</button> </footer> </div> </div> </teleport> </template>
<style lang="scss" scoped> .modal-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 999; } .modal-container { background-color: white; padding: 20px; border-radius: 8px; min-width: 300px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } </style>
|
第二步:在父组件中使用 BaseModal
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <script setup lang="ts"> import { ref } from 'vue'; import BaseModal from '@/components/common/BaseModal.vue';
const showModal = ref(false); </script>
<template> <div class="some-deeply-nested-container"> <button @click="showModal = true">打开模态框</button> </div>
<BaseModal :show="showModal" @close="showModal = false"> <template #header> <h3>来自 App.vue 的标题</h3> </template> <p>这是一段模态框的内容。</p> </BaseModal> </template>
<style>
.some-deeply-nested-container { overflow: hidden; position: relative; border: 1px solid red; padding: 20px; } </style>
|
打开浏览器,点击按钮,你会发现模态框完美地居中显示在整个页面之上,完全不受红色边框父容器的 overflow: hidden
影响。这就是 <Teleport>
的威力。
3.6.2. <Suspense>
- “异步万能口袋”
核心痛点: 在组件中处理异步操作(如 API 请求)时,我们通常需要手动管理至少三种状态:loading
, error
, 和 data
。当一个页面由多个需要异步加载数据的子组件构成时,每个子组件都重复着相似的状态管理逻辑,代码变得冗长,并且难以协调统一的加载体验(比如,整个页面显示一个加载动画,而不是各自闪烁),正如我们在 3.5.2
学习的异步组件一样,而 Suspense
就是 Vue3 为我们做的一个简便封装
<Suspense>
组件就是为了 以声明式的方式优雅地处理组件树中的异步依赖 而生的。它就像一个“口袋”,你把可能需要等待的异步组件放进去,它会负责展示加载状态,直到所有异步操作完成后,再无缝切换到最终内容。
用法: 它提供了两个插槽:
#default
: 用于放置你的异步组件。#fallback
: 用于放置在异步组件准备就绪前,需要显示的“加载中”内容。
(TS 实战) 统一管理仪表盘的异步组件加载
在父组件中使用 <Suspense>
编排异步流程
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| <script setup lang="ts"> import { ref, defineAsyncComponent } from 'vue'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import LoadingError from '@/components/common/LoadingError.vue';
const showProfile = ref(false);
const UserProfileCard = defineAsyncComponent(() => new Promise((resolve) => { setTimeout(() => { resolve(import('@/components/UserProfileCard.vue')); }, 2000); }) ); </script>
<template> <div> <button @click="showProfile = !showProfile"> {{ showProfile ? '隐藏' : '显示' }}用户资料 </button> <hr>
<div v-if="showProfile" class="profile-container"> <Suspense> <template #default> <UserProfileCard /> </template> <template #fallback> <LoadingSpinner /> </template> <template #error> <LoadingError /> </template> </Suspense> </div> </div> </template>
|
刷新页面,你会看到 “正在加载仪表盘数据…” 这个提示显示了 2 秒(以最慢的异步组件为准),然后两个小部件 同时 优雅地出现。<Suspense>
完美地编排了整个异步加载流程,让父组件的代码保持了惊人的简洁和声明性。
3.6.3. <KeepAlive>
- “性能优化利器”
核心痛点: 在使用动态组件(<component :is="...">
)或在路由之间切换时,Vue 的默认行为是 销毁 离开的组件实例,并在下次需要时 重新创建 一个全新的实例。这会导致两个问题:
- 状态丢失: 用户在组件内的输入、滚动条位置等状态都会丢失。
- 性能开销: 对于复杂的组件,频繁地创建和销毁会带来不必要的性能开销。
<KeepAlive>
的作用就是将其包裹的动态切换的组件 缓存 在内存中,而不是销毁它们。当组件被切换出去时,它只是变为“非活动”状态;当再次切回来时,它会从缓存中被重新激活,并保留之前的所有状态。
用法: 它提供了 include
和 exclude
两个 prop,可以接收字符串、正则表达式或数组,用于精确控制哪些组件需要被缓存。
专属生命周期: 被 <KeepAlive>
管理的组件会拥有两个额外的生命周期钩子:
onActivated
: 组件从缓存中被激活时调用。onDeactivated
: 组件被切换出去、进入缓存时调用。
(TS 实战) 为动态标签页保留用户输入
我们将重用 3.5.1
节的动态标签页示例,并用 <KeepAlive>
来缓存它们,以保留输入框中的内容。
第一步:创建带输入框的标签页组件
文件路径: src/components/tabs/ProfileSettings.vue
1 2 3 4 5 6 7 8 9 10 11 12
| <script setup lang="ts"> import { ref, onActivated, onDeactivated } from 'vue'; const username = ref(''); onActivated(() => console.log('ProfileSettings 已激活')); onDeactivated(() => console.log('ProfileSettings 已失活')); </script> <template> <div> <h3>个人资料</h3> <label>用户名: <input type="text" v-model="username" placeholder="请输入..." /></label> </div> </template>
|
文件路径: src/components/tabs/SecuritySettings.vue
1 2 3 4 5 6 7 8 9 10 11 12
| <script setup lang="ts"> import { ref, onActivated, onDeactivated } from 'vue'; const password = ref(''); onActivated(() => console.log('SecuritySettings 已激活')); onDeactivated(() => console.log('SecuritySettings 已失活')); </script> <template> <div> <h3>账户安全</h3> <label>新密码: <input type="password" v-model="password" placeholder="请输入..." /></label> </div> </template>
|
第二步:在父组件中使用 <KeepAlive>
文件路径: src/App.vue
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <script setup lang="ts"> import { shallowRef, type Component } from 'vue'; import ProfileSettings from '@/components/tabs/ProfileSettings.vue'; import SecuritySettings from '@/components/tabs/SecuritySettings.vue';
const tabs: Record<string, Component> = { Profile: ProfileSettings, Security: SecuritySettings, }; const currentTab = shallowRef<string>('Profile'); </script>
<template> <div class="tabs"> <button v-for="(_, tab) in tabs" :key="tab" @click="currentTab = tab"> {{ tab }} </button> </div>
<KeepAlive> <component :is="tabs[currentTab]" /> </KeepAlive> </template>
|
现在,在“个人资料”标签页的输入框中输入一些内容,然后切换到“账户安全”标签页,再切回来。你会惊喜地发现,你之前输入的内容 依然存在!同时,观察控制台,onActivated
和 onDeactivated
钩子被正确地触发。<KeepAlive>
为我们轻松地实现了状态保持和性能优化。