Skip to content

09|插槽初识:组件提供房间,父组件决定摆什么家具 ​

第一章概览 / 09。目标:把插槽理解为内容分发协议,而不是“子组件偷看父组件 DOM”的超能力。

本页以 Vue 3.5.x + Vite + TypeScript 的多文件 SFC 为环境。文件名写在每个代码块首行;把组件放进同一个 src/components/ 目录并按示例导入,避免把三个文件粘成一个组件。

先把渲染作用域记牢 ​

插槽内容写在父组件模板里,因此它访问的是父组件作用域;子组件若想把内部信息交给插槽,必须显式以 slot props 传出。这和 JavaScript 的词法作用域一样朴素,只是第一次看到时很容易被组件树迷惑。

vue
<!-- 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>
vue
<!-- 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>不提供时显示合理默认文案/结构

一个可组合的卡片:结构归子组件,内容归父组件 ​

vue
<!-- 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>
vue
<!-- 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 能让这份合同在编辑器里可见。

vue
<!-- 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 就是两者之间的公开数据合同。

vue
<!-- 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>
vue
<!-- 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 分支里。

vue
<script setup lang="ts">
import { ref } from 'vue'
const activeArea = ref<'header' | 'footer'>('header')
</script>

<template>
  <DashboardLayout>
    <template #[activeArea]>来自动态槽位的内容</template>
  </DashboardLayout>
</template>

插槽不是所有复用问题的答案 ​

当复用目标只是共享状态与行为、而 UI 骨架并不稳定时,组合式函数往往比“无渲染组件 + 大量作用域插槽”更直接;当骨架稳定但内容需要由父级组合时,插槽才格外自然。可以用一个问题判断:子组件是否真的拥有一块稳定布局? 有,就开放槽位;没有,先考虑抽出可组合逻辑。

排错路线:插槽内容为什么读不到、显示不出或留下空白 ​

  1. 先找作用域。 slot 内容写在父级,所以默认读取父级变量;子级内部数据必须以 slot props 显式传出。
  2. 再找槽位名。 #toolbar 与子组件 <slot name="toolbar" /> 必须完全对应;动态槽位尤其要确认运行时名称。
  3. 检查后备内容与包装。 后备内容属于 <slot> 本身;可选区域的外层容器则应按 $slots.name 条件创建,避免空白。
  4. 审查 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。

通关自检:你能清楚说出“内容写在哪个组件,就能访问哪个组件的状态”,插槽就不再神秘。

用心记录每一步成长