Skip to content

06|插槽协议 ​

第二章:组件模型与通信纵览 / 06。插槽把“内容怎么画”交给父级,把“内容画在哪里、何时画”留给子组件。

前置模型:模板属于谁 ​

父组件写下的插槽内容在父组件作用域中编译,所以能读取父级状态,不能直接读取子组件局部变量。子组件若希望父级参与渲染内部数据,必须通过 slot props 明确交出去。

最小可运行列表 ​

vue
<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>
vue
<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>

一项数据如何穿过插槽 ​

以第一个用户为例:

  1. DataList 的 v-for 得到内部变量 item={ id:'u1', name:'Ada' } 与 index=0。
  2. <slot :item="item" :index="index"> 调用父级提供的默认插槽函数,并传入两个 slot props。
  3. 父级的 #default="{ item, index }" 解构参数,使用父级编译的模板生成内容。
  4. 生成的内容回到子组件 <li> 内部的插槽位置。

因此“内容由父级编写”与“内容出现在子级 DOM 中”可以同时成立。slot props应当按一次渲染调用的只读输入使用,但它们不是自动深冻结:item仍可能是同一个响应式对象。不要通过 item.name = ... 偷改子级拥有的数据;需要修改时,显式设计事件或action。

把插槽暂时还原成普通回调 ​

下面只模拟内容计算,不生成DOM:

js
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,父级这样写不会自动成功:

vue
<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 放在哪里,以及未提供插槽时显示什么。

用心记录每一步成长