WinddSnow

Vue-3-Component-Communication-Part2-Slots-Scoped-Slots-Dynamic-Components-Pro...

字数统计: 5.5k阅读时长: 24 min
2026/08/01

第107课:组件通信(下)——插槽、作用域插槽、动态组件、provide/inject

上一课我们学习了父子组件通信的核心机制:Props 和 Emits 以及 v-model。这些机制适用于直接父子关系的组件。然而,实际应用中组件树往往更深、更复杂:你需要将内容动态注入到子组件的特定位置(插槽),或者让兄弟组件和跨层级组件共享数据而无需逐层传递 Props(provide/inject),或者根据运行状态动态切换渲染的组件(动态组件)。本节课将系统讲解这四种进阶通信模式:插槽(将模板内容从父组件传递到子组件)、作用域插槽(将子组件数据反向传递给父组件的模板)、动态组件(通过 <component :is> 动态切换组件)以及 **provide/inject**(跨层级依赖注入)。掌握这些工具后,你将能够构建灵活、可复用且松耦合的 Vue 组件体系。


1. 插槽(Slots):将模板内容传入子组件

插槽是 Vue 实现内容分发的机制。父组件可以向子组件的模板中插入 HTML 内容或组件,子组件通过 <slot> 标签定义插入点。这类似于 React 的 children prop,但 Vue 提供了更丰富的多插槽和作用域能力。

1.1 默认插槽

最简单的用法:子组件预留一个 <slot> 占位符,父组件在子组件标签之间写入的内容会渲染到该位置。

1
2
3
4
5
6
<!-- AlertBox.vue -->
<template>
<div class="alert">
<slot>默认内容:如果没有传入内容,显示这段文字。</slot>
</div>
</template>
1
2
3
4
<!-- 父组件使用 -->
<AlertBox>
<strong>操作成功!</strong> 数据已保存。
</AlertBox>

渲染结果:

1
2
3
<div class="alert">
<strong>操作成功!</strong> 数据已保存。
</div>

如果父组件不传任何内容(<AlertBox />),则渲染默认内容:“默认内容:如果没有传入内容,显示这段文字。”

1.2 具名插槽

当子组件需要多个插槽时,通过 name 属性为 <slot> 命名。父组件使用 v-slot:name#name 指令将内容分发到对应的插槽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header">默认头部</slot>
</header>
<main>
<slot>默认主体(默认插槽)</slot>
</main>
<footer>
<slot name="footer">默认底部</slot>
</footer>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 父组件使用 -->
<Layout>
<template #header>
<h1>页面标题</h1>
</template>

<!-- 默认插槽的内容可以直接放在组件标签内部 -->
<p>这是主体内容。</p>

<template #footer>
<small>&copy; 2026</small>
</template>
</Layout>

语法简写v-slot:header 可以写作 #header。默认插槽的 v-slot 名称为 default,即 #default,但通常直接写内容即可。

1.3 条件插槽:检测插槽是否被填充

子组件可以通过 $slots 对象检测某个插槽是否有内容,从而条件渲染包裹元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Card.vue -->
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>

$slots.header 是一个函数,当该插槽有内容时返回一个 VNode 渲染函数,否则返回 undefined。在 <script setup> 中,可以使用 useSlots() 获取相同功能。


2. 作用域插槽:子组件将数据暴露给父组件

普通插槽的内容在父组件作用域中编译,无法访问子组件的数据。作用域插槽通过 <slot> 标签的属性将子组件的数据反向传递给父组件的模板,实现“子传父”的模板级数据流动。

2.1 基本语法

子组件在 <slot> 上绑定属性(如 :item="item":index="index"),父组件通过 v-slot="slotProps"(或解构语法)接收这些数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- FancyList.vue -->
<script setup>
import { ref } from 'vue';
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
]);
</script>

<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item" :index="items.indexOf(item)">
<!-- 默认渲染:如果父组件不提供模板,使用此内容 -->
{{ item.name }}
</slot>
</li>
</ul>
</template>
1
2
3
4
5
6
7
<!-- 父组件使用 -->
<FancyList>
<template #item="{ item, index }">
<span class="index">{{ index + 1 }}.</span>
<strong>{{ item.name }}</strong>
</template>
</FancyList>

关键点

  • 子组件通过 <slot :propName="value"> 向外暴露数据(可以有多个属性)。
  • 父组件通过 v-slot:slotName="slotProps"(或 #slotName="slotProps")接收所有暴露的属性为一个对象。
  • 使用 ES6 解构语法 { item, index } 直接提取需要的属性,代码更简洁。
  • 作用域插槽默认插槽同样适用:<slot :data="data"><template #default="{ data }">

2.2 综合示例:可定制的表格组件

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
<!-- DataTable.vue -->
<script setup>
defineProps({
columns: { type: Array, required: true },
data: { type: Array, required: true },
});
</script>

<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="rowIndex">
<td v-for="col in columns" :key="col.key">
<!-- 作用域插槽:将当前行数据和列定义暴露给父组件 -->
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :index="rowIndex">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 父组件使用 -->
<DataTable :columns="columns" :data="users">
<!-- 自定义状态列的渲染 -->
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ value === 'active' ? '启用' : '禁用' }}
</span>
</template>

<!-- 自定义操作列 -->
<template #cell-actions="{ row }">
<button @click="editUser(row)">编辑</button>
<button @click="deleteUser(row.id)">删除</button>
</template>
</DataTable>

每个单元格都通过 cell-${col.key} 具名作用域插槽暴露了行数据。父组件可以选择性地覆盖任意列的渲染方式,未覆盖的列使用默认的 {{ row[col.key] }} 纯文本显示。


3. 动态组件:<component :is>

当需要根据运行状态在多个组件之间切换时(如标签页、向导步骤),使用 <component :is="componentName"> 动态组件。is 属性可以是已注册的组件名字符串,也可以是组件对象本身。

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>
import { ref } from 'vue';
import HomeTab from './HomeTab.vue';
import UsersTab from './UsersTab.vue';
import SettingsTab from './SettingsTab.vue';

const tabs = [
{ name: 'Home', component: HomeTab },
{ name: 'Users', component: UsersTab },
{ name: 'Settings', component: SettingsTab },
];
const activeTab = ref(0);
</script>

<template>
<div>
<nav>
<button
v-for="(tab, index) in tabs"
:key="tab.name"
@click="activeTab = index"
:class="{ active: activeTab === index }"
>
{{ tab.name }}
</button>
</nav>

<!-- 动态组件 -->
<component :is="tabs[activeTab].component" />
</div>
</template>

3.1 配合 <KeepAlive> 缓存组件状态

每次切换动态组件时,Vue 默认会销毁旧组件并创建新组件实例,状态丢失。使用 <KeepAlive> 包裹 <component> 可以缓存非活跃组件实例,保留其状态。

1
2
3
<KeepAlive>
<component :is="tabs[activeTab].component" />
</KeepAlive>

<KeepAlive> 支持 includeexclude 属性,按组件名控制哪些组件需要缓存;max 属性限制最大缓存数量。

3.2 is 也可以传递 HTML 标签名

<component :is="'div'"> 等价于 <div>。这在需要动态决定渲染哪种 HTML 元素时很有用,例如根据 Props 决定渲染 <button> 还是 <a>


4. provide/inject:跨层级依赖注入

当祖先组件需要向深层嵌套的后代组件传递数据时,逐层通过 Props 传递(“Prop 逐层透传”)极其繁琐且脆弱——中间组件被迫声明它们根本不关心的 Props。Vue 的 provide/inject 机制允许祖先组件提供数据,任何层级深度的后代组件直接注入使用,无需中间层传递。

4.1 基础用法

在祖先组件中调用 provide(key, value),在后代组件中调用 inject(key) 获取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 祖先组件:App.vue -->
<script setup>
import { provide, ref } from 'vue';

const theme = ref('light');
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
};

// 提供响应式数据和方法
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>

<template>
<div :class="`app-${theme}`">
<button @click="toggleTheme">切换主题</button>
<DeepChild />
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 后代组件:DeepChild.vue(嵌套在任意层级) -->
<script setup>
import { inject } from 'vue';

// 注入祖先提供的数据
const theme = inject('theme', 'light'); // 第二个参数是默认值
const toggleTheme = inject('toggleTheme');
</script>

<template>
<p>当前主题:{{ theme }}</p>
<button @click="toggleTheme">切换</button>
</template>

关键特性

  • provide(key, value) 提供数据,inject(key, defaultValue) 注入数据。
  • 如果 provide 的是一个 ref,后代组件注入的就是该 ref 本身(而非自动解包的值),因此可以直接读取和修改。
  • 如果 provide 的是一个非响应式的普通值,后代组件注入的是一个静态快照,不会随祖先变化而更新。必须提供响应式数据refreactive)才能保持响应式连接。
  • inject 的第二个参数是默认值,当祖先链中未找到对应的 provide 时使用。如果不提供默认值且未找到,inject 返回 undefined

4.2 最佳实践:用 Symbol 作为 key 避免命名冲突

当多个祖先组件可能使用相同的字符串 key 时,会产生意外覆盖。大型应用中推荐使用 Symbol 作为 provide 的 key,确保唯一性。

1
2
3
// keys.js
export const ThemeKey = Symbol('theme');
export const ToggleThemeKey = Symbol('toggleTheme');
1
2
3
4
5
6
7
8
9
10
<script setup>
import { provide, ref } from 'vue';
import { ThemeKey, ToggleThemeKey } from './keys.js';

const theme = ref('light');
provide(ThemeKey, theme);
provide(ToggleThemeKey, () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
});
</script>
1
2
3
4
5
6
7
<script setup>
import { inject } from 'vue';
import { ThemeKey, ToggleThemeKey } from './keys.js';

const theme = inject(ThemeKey, 'light');
const toggleTheme = inject(ToggleThemeKey);
</script>

4.3 在 provide 中使用 readonly 保护数据

如果仅希望后代组件读取数据而不能修改,可以在 provide 时使用 readonly() 包裹 ref:

1
2
3
4
import { provide, ref, readonly } from 'vue';

const count = ref(0);
provide('count', readonly(count)); // 后代只能读取,无法修改

这样后代组件中 inject 获得的 ref 是只读的,尝试修改会触发 Vue 警告。

4.4 provide/inject 与 Pinia 的选择

场景 推荐工具
组件树中特定子树内的共享数据 provide/inject
全局应用级状态(跨多个不相关组件) Pinia(或 Vuex)
服务端数据缓存与请求去重 Pinia + 自定义 composable
小型应用或原型,无需额外依赖 provide/inject + reactive

5. 综合实战:构建一个可扩展的标签页系统

以下示例结合了插槽作用域插槽动态组件和 **provide/inject**,构建一个完整的标签页组件。

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
<!-- Tabs.vue -->
<script setup>
import { ref, provide, onMounted } from 'vue';

const tabs = ref([]);
const activeIndex = ref(0);

// 注册标签页
function registerTab(title) {
const index = tabs.value.length;
tabs.value.push({ title, index });
return index;
}

// 提供给子组件
provide('tabs', {
activeIndex,
registerTab,
});

// 切换标签页
function selectTab(index) {
activeIndex.value = index;
}
</script>

<template>
<div class="tabs-container">
<!-- 标签头部:由父组件通过插槽渲染,或遍历 tabs -->
<div class="tabs-header">
<button
v-for="tab in tabs"
:key="tab.index"
:class="['tab-btn', { active: activeIndex === tab.index }]"
@click="selectTab(tab.index)"
>
{{ tab.title }}
</button>
</div>

<!-- 标签内容:由子组件提供 -->
<div class="tabs-content">
<slot />
</div>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- TabPane.vue -->
<script setup>
import { inject, onMounted, computed } from 'vue';

const props = defineProps({
title: { type: String, required: true },
});

const { activeIndex, registerTab } = inject('tabs');

// 注册当前标签页并获取索引
const myIndex = registerTab(props.title);

// 是否为激活状态
const isActive = computed(() => activeIndex.value === myIndex);
</script>

<template>
<div v-show="isActive" class="tab-pane">
<slot />
</div>
</template>

父组件使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script setup>
import Tabs from './Tabs.vue';
import TabPane from './TabPane.vue';
</script>

<template>
<Tabs>
<TabPane title="个人信息">
<p>姓名:张三</p>
<p>邮箱:zhangsan@example.com</p>
</TabPane>
<TabPane title="订单记录">
<ul>
<li>订单 #1001</li>
<li>订单 #1002</li>
</ul>
</TabPane>
<TabPane title="系统设置">
<p>通知:开启</p>
<p>隐私:公开</p>
</TabPane>
</Tabs>
</template>

设计要点

  • Tabs 组件通过 provide 暴露 activeIndexregisterTab 给所有后代 TabPane
  • TabPane 在挂载时调用 registerTab 将自己注册到 Tabs 中,获得唯一索引。
  • v-show 根据当前激活索引切换显示/隐藏。
  • 父组件通过插槽组合 TabPane,无需关心内部注册逻辑,只需声明每个标签页的标题。
  • 这种模式使得标签页系统高度可扩展——你可以在任何层级嵌套 TabPane,甚至未来扩展为懒加载、异步组件。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 在 Vue 3 中,哪个指令用于在父组件中向子组件的具名插槽分发内容?
    A. v-slot:name
    B. v-insert:name
    C. :name
    D. #name(或 v-slot:name

  2. (单选) 关于 provide/inject 的描述,正确的是?
    A. provide 的数据只能传递给直接子组件。
    B. 如果 provide 的是一个 ref,后代组件注入的是 ref 的当前值(自动解包)。
    C. 使用 Symbol 作为 key 可以避免命名冲突。
    D. inject 的数据总是只读的,无法修改。

  3. (填空) 作用域插槽允许子组件通过 <slot :propName="value"> 将数据暴露给父组件,父组件使用 ______ 接收这些数据。

  4. (多选) 关于动态组件 <component :is> 的说法,哪些是正确的?
    A. is 可以是注册的组件名字符串。
    B. 使用 <KeepAlive> 包裹可以缓存切换前的组件状态。
    C. is 不能传递 HTML 标签名。
    D. is 可以是组件对象本身。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要实现一个树形组件(TreeView),具备以下功能:

  • 组件接收一个嵌套的 data 数组(每个节点有 idlabelchildren 可选)。
  • 使用递归组件(组件内部引用自身)渲染无限层级的树结构。
  • 使用具名插槽允许父组件自定义每个节点的渲染内容,默认显示 label
  • 使用作用域插槽将当前节点的 node 数据传递给父组件,使得父组件可以访问节点完整信息(如添加“编辑/删除”按钮)。
  • 使用 provide/inject 提供 toggleExpand(id) 方法,使得后代节点能展开/折叠自身。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Vue 3 单文件组件。提示词中需明确指定递归组件的写法、具名插槽和作用域插槽的配合方式、以及 provide/inject 的使用场景。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 Vue 3 树形组件 TreeView。需要创建两个文件:

  1. src/components/TreeView.vue(根组件):
    • 使用 <script setup lang="ts">
    • 定义 TreeNode 接口:{ id: number | string, label: string, children?: TreeNode[] }
    • Props:data: TreeNode[]
    • provide 一个 toggleExpand 函数(接收 id),使用 expandedIds ref(Set<number | string>)管理展开状态。toggleExpand 若 id 在 Set 中则删除,否则添加。
    • 模板:根层 <ul>,内部使用 <TreeNodeItem>(递归子组件)。
  2. src/components/TreeNodeItem.vue(递归节点组件):
    • Props:node: TreeNode
    • inject 接收 toggleExpand
    • 计算属性 isExpanded:检查 expandedIds(也需要通过 provide 暴露,或单独提供一个 isExpanded(id) 方法——Agent 需设计为 provide expandedIds ref 本身,或另 provide isExpanded 函数。推荐提供 { expandedIds, toggleExpand } 对象)。
    • 模板:<li>。包含一个展开/折叠按钮(isExpanded ? '▼' : '▶')、一个 <slot name="label" :node="node"> 默认渲染 {{ node.label }}、可选的尾部操作区域 <slot name="actions" :node="node">
    • 如果 node.children 存在且 isExpanded,递归渲染 <TreeNodeItem v-for="child in node.children" :key="child.id" :node="child">注意:在 Vue 3 <script setup> 中,组件可以通过文件名直接自引用(无需显式注册),但为了明确,可使用 defineOptions({ name: 'TreeNodeItem' }) 并在模板中使用 <TreeNodeItem>
  3. 确保 TypeScript 类型安全。添加 JSDoc 注释。在 App.vue 中提供使用示例。完成后列出所有文件内容。

四、面试真题与参考答案

题目(字节跳动前端面试题):

请解释 Vue 3 中 provide/inject 和 Props/Emits 的使用场景差异。为什么说 provide/inject 适合跨层级通信而 Props 不适合?如果 provide 的值是一个 ref,后代组件通过 inject 拿到的是什么?能否直接修改?在什么情况下应该用 provide/inject 替代全局状态管理(如 Pinia)?

参考答案

Props/Emits 适合直接父子组件之间的精确通信。数据沿组件树逐层传递,流向清晰可控。但当组件层级较深(超过 2-3 层),且中间组件不需要也不关心这些数据时,逐层透传 Props 会导致中间组件“污染”——它们被迫声明自己根本不用的 Props 并向下转发。provide/inject 允许祖先组件直接向任意深度的后代组件提供数据,跳过中间层级,解决了透传问题。

如果 provide 的值是一个 ref,后代组件通过 inject 拿到的就是该 ref 对象本身(不是解包后的值)。这意味着后代组件可以直接调用 injectedRef.value = newValue 修改它,修改会响应式地反馈到祖先组件和所有其他注入了该 ref 的组件。这既是灵活性也是风险——通常应在 provide 时使用 readonly() 包裹 ref,或同时提供修改函数(如 toggleTheme),以明确控制数据修改的入口。

provide/inject 与 Pinia 的选择:provide/inject 适用于组件子树范围内的共享数据(如单个表单的上下文、某个页面模块的主题)。它的数据生命周期跟随提供数据的组件——组件卸载时数据消失。Pinia 适用于全局应用级状态(如用户认证信息、跨页面共享的数据),不绑定到任何特定组件树,且提供了 DevTools 集成、模块化、插件生态等工程化能力。原则是:如果数据的消费范围是“某个组件及其所有后代”,用 provide/inject;如果消费范围是“整个应用,跨多个不相关的页面/路由”,用 Pinia。


课后练习答案

一、概念自测答案

  1. D

    • 解析:具名插槽使用 v-slot:name 或简写 #name。A 语法不完整(缺少参数),B 是杜撰的指令,C 是动态属性绑定。
  2. C

    • 解析:使用 Symbol 作为 key 可确保唯一性,避免字符串命名冲突。A 错误,provide 的数据可被任意深度后代注入;B 错误,注入的是 ref 对象本身,不是解包值;D 错误,注入的 ref 可修改,除非祖先使用 readonly 包裹。
  3. **v-slot="slotProps"**(或 #default="slotProps"

    • 解析:父组件通过 v-slot(或 #)接收作用域插槽暴露的数据对象。
  4. A、B、D

    • 解析:A 正确,is 可以是字符串;B 正确,<KeepAlive> 可缓存组件;C 错误,is 可以是 HTML 标签名字符串(如 'div');D 正确,is 可以是导入的组件对象。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 Vue 3 <script setup lang="ts"> 实现一个递归树形组件 TreeView。要求:

  • Props 接收 data 数组(TreeNode 接口)。
  • 使用递归组件(TreeNodeItem)渲染节点。
  • 具名插槽 #label 自定义节点标签,作用域插槽暴露 node 对象。
  • provide/inject 提供 toggleExpandisExpanded,管理展开/折叠状态。
  • 支持无限层级嵌套。
  • 输出两个组件文件的完整代码,附带示例数据。”
CATALOG
  1. 1. 第107课:组件通信(下)——插槽、作用域插槽、动态组件、provide/inject
    1. 1.1. 1. 插槽(Slots):将模板内容传入子组件
      1. 1.1.1. 1.1 默认插槽
      2. 1.1.2. 1.2 具名插槽
      3. 1.1.3. 1.3 条件插槽:检测插槽是否被填充
    2. 1.2. 2. 作用域插槽:子组件将数据暴露给父组件
      1. 1.2.1. 2.1 基本语法
      2. 1.2.2. 2.2 综合示例:可定制的表格组件
    3. 1.3. 3. 动态组件:<component :is>
      1. 1.3.1. 3.1 配合 <KeepAlive> 缓存组件状态
      2. 1.3.2. 3.2 is 也可以传递 HTML 标签名
    4. 1.4. 4. provide/inject:跨层级依赖注入
      1. 1.4.1. 4.1 基础用法
      2. 1.4.2. 4.2 最佳实践:用 Symbol 作为 key 避免命名冲突
      3. 1.4.3. 4.3 在 provide 中使用 readonly 保护数据
      4. 1.4.4. 4.4 provide/inject 与 Pinia 的选择
    5. 1.5. 5. 综合实战:构建一个可扩展的标签页系统
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、Agent 模式下的提示词示例
      4. 1.6.4. 四、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)