09|插槽初识:组件提供房间,父组件决定摆什么家具
第一章概览 / 09。目标:把插槽理解为内容分发协议,而不是“子组件偷看父组件 DOM”的超能力。
本页以 Vue 3.5.x + Vite + TypeScript 的多文件 SFC 为环境。文件名写在每个代码块首行;把组件放进同一个 src/components/ 目录并按示例导入,避免把三个文件粘成一个组件。
先把渲染作用域记牢
插槽内容写在父组件模板里,因此它访问的是父组件作用域;子组件若想把内部信息交给插槽,必须显式以 slot props 传出。这和 JavaScript 的词法作用域一样朴素,只是第一次看到时很容易被组件树迷惑。
<!-- Parent.vue:slot 内容中的 user 来自父组件。 -->
<script setup lang="ts">
import ProfileFrame from './ProfileFrame.vue'
const user = { name: '柚子' }
</script>
<template>
<ProfileFrame>
<p>{{ user.name }} 在父组件里写下这段内容。</p>
</ProfileFrame>
</template><!-- ProfileFrame.vue:这里的 user 不会自动对上父组件的 user。 -->
<script setup lang="ts">
const user = { name: '组件内部的团子' }
</script>
<template>
<article>
<slot />
<footer>内部用户:{{ user.name }}</footer>
</article>
</template>四种基础插槽
| 类型 | 子组件声明 | 父组件提供 | 适合 |
|---|---|---|---|
| 默认插槽 | <slot /> | 普通子内容 | 主内容区 |
| 具名插槽 | <slot name="header" /> | <template #header> | 页头、工具栏、页脚 |
| 作用域插槽 | <slot :item="item" /> | <template #default="{ item }"> | 子组件提供数据,父组件决定怎么画 |
| 后备内容 | <slot>默认内容</slot> | 不提供时显示 | 合理默认文案/结构 |
一个可组合的卡片:结构归子组件,内容归父组件
<!-- AppCard.vue -->
<script setup lang="ts">
const props = defineProps<{ title: string }>()
</script>
<template>
<article class="card">
<header class="card__header">
<h2>{{ props.title }}</h2>
<slot name="actions">
<button type="button">默认操作</button>
</slot>
</header>
<div class="card__body"><slot /></div>
<footer v-if="$slots.footer" class="card__footer">
<slot name="footer" />
</footer>
</article>
</template><!-- Parent.vue -->
<script setup lang="ts">
import AppCard from './AppCard.vue'
</script>
<template>
<AppCard title="学习进度">
<template #actions>
<button type="button">继续学习</button>
</template>
<p>默认插槽放主内容;这句话由父组件决定。</p>
<template #footer>
<small>上次学习:今天</small>
</template>
</AppCard>
</template>#header 是 v-slot:header 的缩写。v-slot 只能写在组件或 <template> 上;不要把它贴到普通 <div> 上,希望浏览器突然理解组件协议。
深水区:slot props 是公开 API,要像 props 一样设计
作用域插槽使子组件把数据交给父级渲染,因此它的字段名、类型和生命周期都是契约。只把父级实际需要的值暴露出去;不要为了方便把整个内部 store、索引结构或一堆回调全塞进 slot props。对 TypeScript 组件,defineSlots 能让这份合同在编辑器里可见。
<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{ items: T[] }>()
defineSlots<{
default(props: { item: T; index: number }): unknown
empty?(): unknown
}>()
</script>
<template>
<p v-if="props.items.length === 0"><slot name="empty">暂无数据。</slot></p>
<ul v-else>
<li v-for="(item, index) in props.items" :key="item.id">
<slot :item="item" :index="index" />
</li>
</ul>
</template>这份 API 的好处不在于“类型炫”,而在于父级写错字段、忘记提供必需内容时能更早发现。若 slot props 已经包含十几个状态和动作,先不要继续加类型;先怀疑这个组件是否同时承担了数据请求、业务决策和展示三份工作。
作用域插槽:子组件给数据,父组件给展示
以可复用列表为例:列表组件知道分页、空状态和每一项数据,但不应该替所有业务决定“这项长什么样”。slot props 就是两者之间的公开数据合同。
<!-- ResourceList.vue -->
<script setup lang="ts">
type Resource = { id: string; name: string; done: boolean }
const props = defineProps<{ items: Resource[]; loading?: boolean }>()
</script>
<template>
<p v-if="props.loading">正在整理资源柜…</p>
<p v-else-if="props.items.length === 0"><slot name="empty">这里还没有东西。</slot></p>
<ul v-else>
<li v-for="(item, index) in props.items" :key="item.id">
<slot :item="item" :index="index" />
</li>
</ul>
</template><!-- Parent.vue -->
<script setup lang="ts">
import ResourceList from './ResourceList.vue'
const lessons = [{ id: 'vue-template', name: '模板语法', done: false }]
</script>
<template>
<ResourceList :items="lessons">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.name }} {{ item.done ? '✅' : '🧭' }}
</template>
<template #empty>暂时没有课程,书架在等你。</template>
</ResourceList>
</template>动态插槽名与条件包装:少见,但别陌生
动态插槽名写作 #[slotName],适用于布局配置、表格列等确实需要运行时选择槽位的场景。它增加了 API 的动态性,也降低了可搜索性;普通页面宁可用明确的 #header / #footer。
子组件可用 $slots.name 判断父组件是否提供某槽位,从而避免渲染空包装。它是“是否有内容”的结构判断,不要把复杂业务条件藏进 $slots 分支里。
<script setup lang="ts">
import { ref } from 'vue'
const activeArea = ref<'header' | 'footer'>('header')
</script>
<template>
<DashboardLayout>
<template #[activeArea]>来自动态槽位的内容</template>
</DashboardLayout>
</template>插槽不是所有复用问题的答案
当复用目标只是共享状态与行为、而 UI 骨架并不稳定时,组合式函数往往比“无渲染组件 + 大量作用域插槽”更直接;当骨架稳定但内容需要由父级组合时,插槽才格外自然。可以用一个问题判断:子组件是否真的拥有一块稳定布局? 有,就开放槽位;没有,先考虑抽出可组合逻辑。
排错路线:插槽内容为什么读不到、显示不出或留下空白
- 先找作用域。 slot 内容写在父级,所以默认读取父级变量;子级内部数据必须以 slot props 显式传出。
- 再找槽位名。
#toolbar与子组件<slot name="toolbar" />必须完全对应;动态槽位尤其要确认运行时名称。 - 检查后备内容与包装。 后备内容属于
<slot>本身;可选区域的外层容器则应按$slots.name条件创建,避免空白。 - 审查 API 的粒度。 简单输入用 prop、动作通知用 emit、内容组合用 slot。若三者混成一团,先收缩组件职责。
插槽、props、events 怎么选
| 你要表达的东西 | 用什么 | 理由 |
|---|---|---|
| 子组件需要一个简单数据/配置 | props | 输入明确,易类型化和测试 |
| 子组件通知“发生了什么” | emits | 输出明确,父组件决定后果 |
| 父组件决定某一块 UI 长相 | slots | 保留布局骨架,同时开放内容组合 |
| 子组件把内部条目交给父组件自定义渲染 | scoped slots | 数据由子组件管理,视图由父组件控制 |
插槽不是“props 太多时的垃圾抽屉”。一旦 slot props 多到像接口文档,先审视组件是否承担了两个不相关的职责。
翻车博物馆
| 现象 | 根因 | 修复 |
|---|---|---|
| 插槽里读不到子组件变量 | slot 内容遵循父作用域 | 子组件用 slot props 显式传出 |
| 多余的 footer 空白 | 永远渲染了包装元素 | 用 $slots.footer 决定是否创建包装 |
| slot API 不敢改 | 父组件依赖了大量隐式字段 | 收缩 slot props,给公开字段命名/类型 |
| 用 slots 传一个布尔开关 | 把简单输入复杂化 | 用 prop;slot 留给内容组合 |
练习:做一个带空状态的通用面板
创建 src/components/AppPanel.vue、src/components/DataList.vue 与 src/App.vue。面板有默认内容区和可选 toolbar、footer;DataList 输入是 Array<{ id: string | number }>,通过默认作用域插槽交付 item、index,通过 empty 槽提供空状态。
先传空数组且不提供任何槽,再传两项数据并只提供默认槽,最后补上三个可选槽。父级另有一个 suffix = '!',默认槽可读取它;尝试在父级直接读取 DataList 内部变量并记录类型检查失败的原因。
参考答案:查看第一章练习答案中的第 09 题。
验收清单:如何判断练习完成
- 面板默认槽和 DataList 空槽在未提供内容时各显示自己的后备文本;
toolbar、footer未提供时不留下空包装。 - 两项数据分别得到索引 0、1;父级能读
suffix和显式传出的item、index,不能读取子组件未公开的局部变量。 - 每个设计决定都能归到 props、emits 或 slots 之一,避免把简单配置塞进插槽或把 UI 组合写成一串 props。
通关自检:你能清楚说出“内容写在哪个组件,就能访问哪个组件的状态”,插槽就不再神秘。