Vue 生态(三):组件化工程 · 铸造类型安全、高内聚的 UI 单元

第三章:组件化工程 · 铸造类型安全、高内聚的 UI 单元

摘要: 如果说响应式系统是 Vue 的“灵魂”,那么组件化就是 Vue 的“躯体”。本章,我们将从“工匠”晋升为“建筑师”,学习如何设计和构建出 高复用、低耦合、API 友好 的 UI 单元。我们将深入 <script setup> 这一现代 Vue 组件的最终形态,并以 TypeScript 作为我们的“结构图纸”,为 PropsEmitsSlots 等所有内外接口建立严格的类型契约。学完本章,你将有能力构建出在大型项目中依然能保持清晰、可维护的复杂组件系统。


3.1. <script setup>:组件语法的最终形态

在深入组件设计的细节之前,我们必须先掌握最先进的“建造工具”——<script setup>。它并非简单的语法糖,而是一场关乎开发效率、代码可读性和性能的革命。

3.1.1. 核心痛点:Options API 的“逻辑孤岛”

想象一下,我们在用 Vue 2 的 Options API 构建一个带有搜索功能的列表组件。

  • data 中需要定义 searchQueryitems
  • 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}" 为关键词搜索...`);
// 模拟 API 调用
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> 的架构优势:

  1. 逻辑内聚:所有与“搜索”相关的状态、方法和副作用都集中在一起,代码的可读性和可维护性呈指数级提升。
  2. 更少的样板代码:告别 export default { setup() { ... } } 的冗长结构。
  3. 编译时优化:Vue 编译器可以更智能地分析 <script setup> 中的代码,生成更高效的渲染函数,运行时性能更佳。
  4. 告别 this:在 <script setup> 的世界里,没有 this 的心智负担,所有逻辑都更接近纯粹的 JavaScript 函数式编程。

3.2. 组件的“公共 API”:PropsEmits

一个设计良好的组件就像一个黑盒,它通过 Props(输入)和 Emits(输出)与外界清晰地交互,而将其内部实现细节完全封装。

3.2.1. defineProps:父给子传数据

Props 是父组件向子组件传递数据的通道。在架构层面,我们必须遵循 单向数据流 原则:子组件永远不应直接修改父组件传递的 Prop。这保证了数据流的可追溯性,让应用状态变得可预测。

defineProps<T>withDefaults

我们将创建一个更复杂的 CustomInput 组件,它需要接收 modelValuelabel 和一个可选的 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">
// 1. 定义 Props 的类型接口,这是组件的“输入契约”
interface Props {
modelValue: string; // 用于 v-model
label: string;
placeholder?: string;
// 可以添加更多验证相关的 props
}
// 2. 使用 withDefaults 和 defineProps 声明 Props 并提供默认值
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入内容...',
});
// 内部尝试修改 prop 会导致警告,并且在开发模式下会报错
// props.label = 'New Label'; // 错误!
</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,但这次不叫 modelValue,以突出通用性
value: string;
label: string;
placeholder?: string;
}

const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入内容...',
});

// 1. 定义事件签名:组件的“输出契约”
// 声明本组件可以触发一个名为 'input-change' 的事件,
// 并且这个事件会携带一个 string 类型的载荷(payload)。
const emit = defineEmits<{
(event: 'input-change', value: string): void;
}>();

// 2. 在事件处理函数中,触发类型安全的事件
function onInput(event: Event) {
const target = event.target as HTMLInputElement;
// 通过 emit 触发 'input-change' 事件,并将输入框的当前值作为数据传递出去
emit('input-change', target.value);
}
</script>

<template>
<div class="custom-input-wrapper">
<label>{{ props.label }}</label>
<!--
注意这里的变化:
1. Prop 名改为了 :value
2. 事件监听改为了 @input
-->
<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('');

// 3. 定义一个处理函数,它将接收子组件传递过来的值
function handleUsernameChange(newValue: string) {
console.log('子组件传来了新值:', newValue);
username.value = newValue;
}
</script>

<template>
<p>父组件中的值: {{ username }}</p>

<!--
父组件通过 v-on (简写为 @) 来监听子组件的自定义事件。
@input-change="handleUsernameChange" 的意思是:
“当 CustomInput 组件触发 'input-change' 事件时,调用我的 handleUsernameChange 方法,
并把事件附带的数据作为第一个参数传给它。”
-->
<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,我们只需要遵循一个简单的约定:

  1. Prop:接收的 Prop 名称必须是 modelValue
  2. 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 {
// 1. Prop 名约定为 modelValue
modelValue: string;
label: string;
placeholder?: string;
}
defineProps<Props>();

// 2. 事件名约定为 update:modelValue
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>

<!--
现在,这行代码 v-model="inputValue"
被 Vue 自动展开为:
:modelValue="inputValue"
@update:modelValue="newValue => inputValue = newValue"

它完美地将数据绑定和事件监听合二为一,代码更加简洁和直观。
-->
<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>
<!-- 具名插槽: header -->
<slot name="header">
<h1>默认标题</h1> <!-- 插槽的后备内容 -->
</slot>
</header>
<main>
<!-- 默认插槽 -->
<slot></slot>
</main>
<footer>
<!-- 作用域插槽: footer -->
<!-- 将组件内部的数据 (items) 暴露给父组件 -->
<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>
<!-- 填充具名插槽 header -->
<template #header>
<h2>我的应用</h2>
</template>

<!-- 填充默认插槽 -->
<p>这是应用的主要内容。</p>

<!-- 填充作用域插槽 footer, 并接收其暴露的数据 -->
<template #footer="{ items, currentYear }">
<p>
&copy; {{ currentYear }}. 技术栈: {{ items.join(', ') }}
</p>
</template>
</BaseLayout>
</template>

3.3.2. (TS 实战) defineSlots:为插槽提供类型

Vue 3.3+ 引入了 defineSlots 宏,我们可以像定义 PropsEmits 一样,为插槽提供类型。

文件路径: 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'];

// 使用 defineSlots 定义插槽类型
const slots = defineSlots<{
header?: () => any;
default?: () => any;
footer?: (scope: { items: string[]; currentYear: number }) => any;
}>();
</script>

<template>
<div class="layout">
<header>
<!-- 具名插槽: header -->
<template v-if="slots.header">
<slot name="header" />
</template>
<template v-else>
<h1>默认标题</h1>
</template>
</header>
<main>
<!-- 默认插槽 -->
<slot />
</main>
<footer>
<!-- 作用域插槽: 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;
}

// 只暴露 validate 方法,隐藏 internalValue
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';

// 1. 定义一个 ref 来持有组件实例
// 注意类型需要是 InstanceType<typeof ValidationForm>
const formRef = ref<InstanceType<typeof ValidationForm> | null>(null);

async function submitForm() {
if (formRef.value) {
const isValid = await formRef.value.validate();
alert(isValid ? '表单提交成功' : '表单校验失败');
}
}
</script>

<template>
<!-- 2. 将 ref 绑定到组件上 -->
<ValidationForm ref="formRef" />
<button @click="submitForm">提交</button>
</template>

3.5. 高级组件模式:应对复杂场景的架构利器

掌握了 Props, Emits, SlotsExpose 之后,你就拥有了构建绝大多数组件的能力。然而,在大型应用中,我们还会遇到更复杂的场景,例如根据状态动态渲染不同 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 },
];

// 使用 shallowRef 存储当前激活的组件。
// 对于组件这种不应被深度代理的复杂对象,shallowRef 是性能更优的选择。
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>

<!--
<component :is="..."> 是动态组件的核心。
'is' 属性绑定到一个组件对象或组件名字符串。
当 currentTabComponent 变化时,Vue 会自动卸载旧组件,挂载新组件。
-->
<main class="tab-content">
<!--
搭配 <KeepAlive> 可以缓存非活动组件的实例,
而不是销毁它们。这对于保留用户输入或滚动位置非常有用。
-->
<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,
// 显示 loading 组件前的延迟(默认 200ms)
delay: 200,
// 超时后显示错误组件(默认 Infinity)
timeout: 3000,
// 是否支持异步组件(默认 true)
suspensible: false,
// 错误处理函数
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// 遇到 fetch 错误时重试,最多 3 次
retry();
} else {
// 注意 retry/fail 类似于 promise 的 resolve/reject:
// 必须调用其中之一以继续错误处理
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: hiddenz-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">
// 该组件接收一个布尔值 prop 来控制其显示与隐藏
defineProps<{
show: boolean;
}>();
// 定义一个 emit 事件,用于通知父组件关闭模态框
const emit = defineEmits<{
(e: "close"): void;
}>();
</script>

<template>
<!-- v-if 控制模态框的挂载与卸载 -->
<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 在这里被调用,它的真实 DOM 也会出现在 <body> 中。
父组件通过 v-model:show (或 :show 和 @close) 来控制其状态,
逻辑关系依然清晰。
-->
<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);

// 用于 Suspense 的异步组件,模拟网络延迟,与defineAsyncComponent不同的是,这段代码完全可以放在组建内部,保持主组件的简洁性
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 的默认行为是 销毁 离开的组件实例,并在下次需要时 重新创建 一个全新的实例。这会导致两个问题:

  1. 状态丢失: 用户在组件内的输入、滚动条位置等状态都会丢失。
  2. 性能开销: 对于复杂的组件,频繁地创建和销毁会带来不必要的性能开销。

<KeepAlive> 的作用就是将其包裹的动态切换的组件 缓存 在内存中,而不是销毁它们。当组件被切换出去时,它只是变为“非活动”状态;当再次切回来时,它会从缓存中被重新激活,并保留之前的所有状态。

用法: 它提供了 includeexclude 两个 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 中 -->
<KeepAlive>
<component :is="tabs[currentTab]" />
</KeepAlive>
</template>

现在,在“个人资料”标签页的输入框中输入一些内容,然后切换到“账户安全”标签页,再切回来。你会惊喜地发现,你之前输入的内容 依然存在!同时,观察控制台,onActivatedonDeactivated 钩子被正确地触发。<KeepAlive> 为我们轻松地实现了状态保持和性能优化。