06|插槽协议
第二章:组件模型与通信纵览 / 06。插槽把“内容怎么画”交给父级,把“内容画在哪里、何时画”留给子组件。
前置模型:模板属于谁
父组件写下的插槽内容在父组件作用域中编译,所以能读取父级状态,不能直接读取子组件局部变量。子组件若希望父级参与渲染内部数据,必须通过 slot props 明确交出去。
最小可运行列表
<script setup lang="ts" generic="T extends { id: string }">
defineProps<{
items: T[]
}>()
defineSlots<{
default?(props: { item: T; index: number }): unknown
empty?(): unknown
footer?(props: { count: number }): unknown
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index">
{{ item.id }}
</slot>
</li>
</ul>
<div v-else>
<slot name="empty">暂无数据</slot>
</div>
<footer v-if="$slots.footer">
<slot name="footer" :count="items.length" />
</footer>
</template><script setup lang="ts">
import { ref } from 'vue'
import DataList from './DataList.vue'
interface User { id: string; name: string }
const users = ref<User[]>([
{ id: 'u1', name: 'Ada' },
{ id: 'u2', name: 'Lin' }
])
</script>
<template>
<DataList :items="users">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.name }}
</template>
<template #empty>还没有用户</template>
<template #footer="{ count }">
共 {{ count }} 人
</template>
</DataList>
</template>一项数据如何穿过插槽
以第一个用户为例:
DataList的v-for得到内部变量item={ id:'u1', name:'Ada' }与index=0。<slot :item="item" :index="index">调用父级提供的默认插槽函数,并传入两个 slot props。- 父级的
#default="{ item, index }"解构参数,使用父级编译的模板生成内容。 - 生成的内容回到子组件
<li>内部的插槽位置。
因此“内容由父级编写”与“内容出现在子级 DOM 中”可以同时成立。slot props应当按一次渲染调用的只读输入使用,但它们不是自动深冻结:item仍可能是同一个响应式对象。不要通过 item.name = ... 偷改子级拥有的数据;需要修改时,显式设计事件或action。
把插槽暂时还原成普通回调
下面只模拟内容计算,不生成DOM:
const suffix = '!'
const slots = {
default: ({ item, index }) => `${index + 1}. ${item.name}${suffix}`
}
function displayRow(item, index) {
return slots.default({ item, index })
}
console.log(displayRow({ id: 'u1', name: 'Ada' }, 0)) // 1. Ada!suffix 由函数定义处的闭包提供,item/index 由调用处参数提供。Vue中父模板编译出插槽函数,子组件的slot出口在渲染时调用它;返回的是渲染内容而不是本例字符串。这解释了“写在父级、显示在子级”为什么不矛盾,也解释了不能直接读取子级局部loading。函数应在渲染期间由Vue调用,不能提前执行后缓存一份结果,否则后续响应式变化与插槽调用时机容易脱节。
默认、具名、作用域、后备分别解决什么
| 形式 | 解决的问题 | 本例位置 |
|---|---|---|
| 默认插槽 | 主要内容可替换 | 每个列表项 |
| 具名插槽 | 多个内容区域需要稳定名字 | empty、footer |
| 作用域插槽 | 子级把渲染所需数据交给父级 | item、index、count |
| 后备内容 | 父级未提供插槽时仍可用 | 显示 item.id 或“暂无数据” |
defineSlots 只接收类型参数,不传运行时参数;它提供插槽名和 slot props 的类型提示,不负责运行时校验返回内容。
失败路径:作用域方向写反
假设 DataList.vue 内有局部变量 loading,父级这样写不会自动成功:
<DataList :items="users">
<p v-if="loading">加载中</p>
</DataList>父级只会在自己的作用域查找 loading。若它确实属于列表协议,应由子组件 <slot :loading="loading"> 暴露,再由父级 #default="{ loading }" 接收。
另一个错误是在原生元素上写 v-slot:v-slot 只能放在组件标签或代表具名插槽的 <template> 上。需要同时使用默认作用域插槽与具名插槽时,显式写 #default 最清楚。
条件插槽与空包装
$slots.footer 是一个插槽函数是否存在的判断。正文只在调用方提供footer函数时渲染 <footer>。但“函数存在”不保证其内容可见:若父级始终声明 #footer,只在内部写 <p v-if="false">,包装仍会存在。要控制区域是否出现,将条件放在父级的 <template v-if="showFooter" #footer> 上,或设计明确的显示prop。不要通过执行插槽函数并检查 VNode 内容来猜“是不是空”;这会让渲染时机和副作用更难预测。
动态插槽名是协议,不是字符串游戏
确有按配置选择区域的场景,可以写 #[slotName] 或 <slot :name="slotName" />。但可选名字仍应来自明确联合类型或配置表。任意字符串会让调用方无法知道哪些区域真的存在。
练习:通知列表的三块渲染权
实现泛型 NotificationList.vue:
- prop:
items,每项至少有id: string、message: string、read: boolean。 - 默认作用域插槽得到
{ item, index };未提供时显示message。 empty具名插槽可替换“暂无通知”后备内容。actions具名作用域插槽得到{ unreadCount };只有调用方提供该插槽时才渲染<footer>。- 分别用两条通知与空数组运行;父级自定义内容只能使用父级状态和已声明 slot props。
参考答案:查看第 2 章第 06 题参考答案。
通关自检:你能指出一段插槽内容在哪里编译、谁提供数据、最终 DOM 放在哪里,以及未提供插槽时显示什么。