第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 | <!-- AlertBox.vue --> |
1 | <!-- 父组件使用 --> |
渲染结果:
1 | <div class="alert"> |
如果父组件不传任何内容(<AlertBox />),则渲染默认内容:“默认内容:如果没有传入内容,显示这段文字。”
1.2 具名插槽
当子组件需要多个插槽时,通过 name 属性为 <slot> 命名。父组件使用 v-slot:name 或 #name 指令将内容分发到对应的插槽。
1 | <!-- Layout.vue --> |
1 | <!-- 父组件使用 --> |
语法简写:v-slot:header 可以写作 #header。默认插槽的 v-slot 名称为 default,即 #default,但通常直接写内容即可。
1.3 条件插槽:检测插槽是否被填充
子组件可以通过 $slots 对象检测某个插槽是否有内容,从而条件渲染包裹元素。
1 | <!-- Card.vue --> |
$slots.header 是一个函数,当该插槽有内容时返回一个 VNode 渲染函数,否则返回 undefined。在 <script setup> 中,可以使用 useSlots() 获取相同功能。
2. 作用域插槽:子组件将数据暴露给父组件
普通插槽的内容在父组件作用域中编译,无法访问子组件的数据。作用域插槽通过 <slot> 标签的属性将子组件的数据反向传递给父组件的模板,实现“子传父”的模板级数据流动。
2.1 基本语法
子组件在 <slot> 上绑定属性(如 :item="item"、:index="index"),父组件通过 v-slot="slotProps"(或解构语法)接收这些数据。
1 | <!-- FancyList.vue --> |
1 | <!-- 父组件使用 --> |
关键点:
- 子组件通过
<slot :propName="value">向外暴露数据(可以有多个属性)。 - 父组件通过
v-slot:slotName="slotProps"(或#slotName="slotProps")接收所有暴露的属性为一个对象。 - 使用 ES6 解构语法
{ item, index }直接提取需要的属性,代码更简洁。 - 作用域插槽默认插槽同样适用:
<slot :data="data">和<template #default="{ data }">。
2.2 综合示例:可定制的表格组件
1 | <!-- DataTable.vue --> |
1 | <!-- 父组件使用 --> |
每个单元格都通过 cell-${col.key} 具名作用域插槽暴露了行数据。父组件可以选择性地覆盖任意列的渲染方式,未覆盖的列使用默认的 {{ row[col.key] }} 纯文本显示。
3. 动态组件:<component :is>
当需要根据运行状态在多个组件之间切换时(如标签页、向导步骤),使用 <component :is="componentName"> 动态组件。is 属性可以是已注册的组件名字符串,也可以是组件对象本身。
1 | <script setup> |
3.1 配合 <KeepAlive> 缓存组件状态
每次切换动态组件时,Vue 默认会销毁旧组件并创建新组件实例,状态丢失。使用 <KeepAlive> 包裹 <component> 可以缓存非活跃组件实例,保留其状态。
1 | <KeepAlive> |
<KeepAlive> 支持 include 和 exclude 属性,按组件名控制哪些组件需要缓存;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 | <!-- 祖先组件:App.vue --> |
1 | <!-- 后代组件:DeepChild.vue(嵌套在任意层级) --> |
关键特性:
provide(key, value)提供数据,inject(key, defaultValue)注入数据。- 如果
provide的是一个ref,后代组件注入的就是该ref本身(而非自动解包的值),因此可以直接读取和修改。 - 如果
provide的是一个非响应式的普通值,后代组件注入的是一个静态快照,不会随祖先变化而更新。必须提供响应式数据(ref、reactive)才能保持响应式连接。 inject的第二个参数是默认值,当祖先链中未找到对应的provide时使用。如果不提供默认值且未找到,inject返回undefined。
4.2 最佳实践:用 Symbol 作为 key 避免命名冲突
当多个祖先组件可能使用相同的字符串 key 时,会产生意外覆盖。大型应用中推荐使用 Symbol 作为 provide 的 key,确保唯一性。
1 | // keys.js |
1 | <script setup> |
1 | <script setup> |
4.3 在 provide 中使用 readonly 保护数据
如果仅希望后代组件读取数据而不能修改,可以在 provide 时使用 readonly() 包裹 ref:
1 | import { provide, ref, readonly } from 'vue'; |
这样后代组件中 inject 获得的 ref 是只读的,尝试修改会触发 Vue 警告。
4.4 provide/inject 与 Pinia 的选择
| 场景 | 推荐工具 |
|---|---|
| 组件树中特定子树内的共享数据 | provide/inject |
| 全局应用级状态(跨多个不相关组件) | Pinia(或 Vuex) |
| 服务端数据缓存与请求去重 | Pinia + 自定义 composable |
| 小型应用或原型,无需额外依赖 | provide/inject + reactive |
5. 综合实战:构建一个可扩展的标签页系统
以下示例结合了插槽、作用域插槽、动态组件和 **provide/inject**,构建一个完整的标签页组件。
1 | <!-- Tabs.vue --> |
1 | <!-- TabPane.vue --> |
父组件使用:
1 | <script setup> |
设计要点:
Tabs组件通过provide暴露activeIndex和registerTab给所有后代TabPane。TabPane在挂载时调用registerTab将自己注册到Tabs中,获得唯一索引。v-show根据当前激活索引切换显示/隐藏。- 父组件通过插槽组合
TabPane,无需关心内部注册逻辑,只需声明每个标签页的标题。 - 这种模式使得标签页系统高度可扩展——你可以在任何层级嵌套
TabPane,甚至未来扩展为懒加载、异步组件。
课后练习
一、概念自测(选择题 / 填空题)
(单选) 在 Vue 3 中,哪个指令用于在父组件中向子组件的具名插槽分发内容?
A.v-slot:name
B.v-insert:name
C.:name
D.#name(或v-slot:name)(单选) 关于
provide/inject的描述,正确的是?
A.provide的数据只能传递给直接子组件。
B. 如果provide的是一个 ref,后代组件注入的是 ref 的当前值(自动解包)。
C. 使用 Symbol 作为 key 可以避免命名冲突。
D.inject的数据总是只读的,无法修改。(填空) 作用域插槽允许子组件通过
<slot :propName="value">将数据暴露给父组件,父组件使用______接收这些数据。(多选) 关于动态组件
<component :is>的说法,哪些是正确的?
A.is可以是注册的组件名字符串。
B. 使用<KeepAlive>包裹可以缓存切换前的组件状态。
C.is不能传递 HTML 标签名。
D.is可以是组件对象本身。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要实现一个树形组件(TreeView),具备以下功能:
- 组件接收一个嵌套的
data数组(每个节点有id、label、children可选)。 - 使用递归组件(组件内部引用自身)渲染无限层级的树结构。
- 使用具名插槽允许父组件自定义每个节点的渲染内容,默认显示
label。 - 使用作用域插槽将当前节点的
node数据传递给父组件,使得父组件可以访问节点完整信息(如添加“编辑/删除”按钮)。 - 使用
provide/inject提供toggleExpand(id)方法,使得后代节点能展开/折叠自身。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Vue 3 单文件组件。提示词中需明确指定递归组件的写法、具名插槽和作用域插槽的配合方式、以及 provide/inject 的使用场景。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个 Vue 3 树形组件 TreeView。需要创建两个文件:
src/components/TreeView.vue(根组件):
- 使用
<script setup lang="ts">。- 定义
TreeNode接口:{ id: number | string, label: string, children?: TreeNode[] }。- Props:
data: TreeNode[]。provide一个toggleExpand函数(接收 id),使用expandedIdsref(Set<number | string>)管理展开状态。toggleExpand若 id 在 Set 中则删除,否则添加。- 模板:根层
<ul>,内部使用<TreeNodeItem>(递归子组件)。src/components/TreeNodeItem.vue(递归节点组件):
- Props:
node: TreeNode。inject接收toggleExpand。- 计算属性
isExpanded:检查expandedIds(也需要通过 provide 暴露,或单独提供一个isExpanded(id)方法——Agent 需设计为 provideexpandedIdsref 本身,或另 provideisExpanded函数。推荐提供{ 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>。- 确保 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。
课后练习答案
一、概念自测答案
D
- 解析:具名插槽使用
v-slot:name或简写#name。A 语法不完整(缺少参数),B 是杜撰的指令,C 是动态属性绑定。
- 解析:具名插槽使用
C
- 解析:使用 Symbol 作为 key 可确保唯一性,避免字符串命名冲突。A 错误,
provide的数据可被任意深度后代注入;B 错误,注入的是 ref 对象本身,不是解包值;D 错误,注入的 ref 可修改,除非祖先使用readonly包裹。
- 解析:使用 Symbol 作为 key 可确保唯一性,避免字符串命名冲突。A 错误,
**
v-slot="slotProps"**(或#default="slotProps")- 解析:父组件通过
v-slot(或#)接收作用域插槽暴露的数据对象。
- 解析:父组件通过
A、B、D
- 解析:A 正确,
is可以是字符串;B 正确,<KeepAlive>可缓存组件;C 错误,is可以是 HTML 标签名字符串(如'div');D 正确,is可以是导入的组件对象。
- 解析:A 正确,
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请用 Vue 3<script setup lang="ts">实现一个递归树形组件 TreeView。要求:
- Props 接收
data数组(TreeNode 接口)。- 使用递归组件(TreeNodeItem)渲染节点。
- 具名插槽
#label自定义节点标签,作用域插槽暴露node对象。provide/inject提供toggleExpand和isExpanded,管理展开/折叠状态。- 支持无限层级嵌套。
- 输出两个组件文件的完整代码,附带示例数据。”