插槽(Slots)

插槽让父组件能够注入内容,子组件负责布局和展示结构。

一、插槽解决什么问题

组件可以通过 props 接收数据,但如果希望父组件自定义一部分模板结构,使用插槽更合适。

<!-- Card.vue:子组件控制布局 -->
<template>
  <article class="card">
    <slot />
  </article>
</template>
<!-- Parent.vue:父组件决定内容 -->
<Card>
  <h2>用户信息</h2>
  <p>这是卡片内容</p>
</Card>

插槽内容由父组件编译和管理,因此插槽模板中可以直接访问父组件的数据,不能直接访问子组件的局部变量。需要使用子组件数据时,应使用作用域插槽。

二、三种常见插槽

默认插槽

没有指定 name<slot> 是默认插槽:

<!-- Child.vue -->
<button class="button">
  <slot>默认按钮文字</slot>
</button>
<!-- Parent.vue -->
<Child>保存</Child>

父组件没有传入内容时,子组件会渲染 <slot> 标签中的默认内容。

具名插槽

组件有多个内容区域时,使用 name 区分不同插槽:

<!-- Layout.vue -->
<template>
  <header v-if="$slots.header">
    <slot name="header" />
  </header>
  <main>
    <slot />
  </main>
  <footer v-if="$slots.footer">
    <slot name="footer" />
  </footer>
</template>
<!-- Parent.vue -->
<Layout>
  <template #header>页面标题</template>
  <template #default>页面内容</template>
  <template #footer>页面底部</template>
</Layout>

#headerv-slot:header 的缩写,#default 表示默认插槽。子组件可以用 $slotsuseSlots() 判断插槽是否存在,避免输出空的布局容器。

作用域插槽

作用域插槽由子组件提供数据,父组件决定如何渲染这些数据:

<!-- UserList.vue -->
<script setup>
const list = ['Ada', 'Linus', 'Grace']
</script>

<template>
  <div v-for="(item, index) in list" :key="item">
    <slot :item="item" :index="index" />
  </div>
</template>
<!-- Parent.vue -->
<UserList v-slot="{ item, index }">
  <strong>{{ index + 1 }}.</strong> {{ item }}
</UserList>

这里的 itemindex 是子组件通过插槽参数暴露出来的,父组件使用解构接收。作用域插槽常用于表格列、列表项、下拉选项和可复用布局。

具名作用域插槽可以同时指定插槽名和参数:

<!-- Table.vue -->
<slot name="cell" :value="row.name" :row="row" />
<!-- Parent.vue -->
<Table>
  <template #cell="{ value, row }">
    <a :href="`/users/${row.id}`">{{ value }}</a>
  </template>
</Table>

三、动态插槽名

插槽名可以来自响应式数据:

<template #[slotName]>动态内容</template>
<script setup>
import { ref } from 'vue'

const slotName = ref('header')
</script>

动态插槽名适合少量、明确的布局分支。名称应保持可预测,避免把大量动态数据直接映射成插槽名,增加组件的使用和调试成本。

四、插槽的实现原理

核心模型

插槽不是把一段 HTML 字符串传给子组件,而是把父组件声明的模板以插槽内容传入:

父组件模板
  ↓ 编译
插槽内容 / 插槽函数
  ↓ 传给子组件
子组件在 <slot> 位置调用并渲染

插槽内容中的表达式仍然在父组件作用域中求值;子组件只能通过作用域插槽参数向父组件提供数据。

Vue 2

普通插槽通常会先由父组件生成 VNode,再传给子组件:

// 简化示意
render() {
  return h(Child, null, [
    h('p', '这是父组件的插槽内容'),
  ])
}

作用域插槽则以函数形式传递,子组件调用函数时提供数据:

// 简化示意
const slot = this.$scopedSlots.default
const nodes = slot({ item: 'Ada' })

Vue 3

Vue 3 中插槽统一以函数形式暴露给子组件:

import { h, useSlots } from 'vue'

export default {
  setup() {
    const slots = useSlots()

    return () => h('div', slots.default?.())
  },
}

编译器还会根据插槽是否稳定生成不同的更新信息,运行时据此减少不必要的检查。但这不等于父子组件完全隔离:如果插槽内容依赖父组件状态,父组件更新时仍可能需要更新插槽内容;子组件自身的 props、响应式状态变化也仍会触发子组件更新。

可以这样理解:

插槽函数
  ├── 父组件负责创建和维护插槽内容
  └── 子组件负责决定何时、在哪里、用什么参数调用

五、插槽类型声明

<script setup lang="ts"> 中,可以使用 defineSlots 描述作用域插槽参数:

<script setup lang="ts">
type User = {
  id: number
  name: string
}

const slots = defineSlots<{
  default(props: { user: User }): any
  empty?(): any
}>()
</script>

这样可以让组件使用者获得插槽参数的类型提示。defineSlots 只用于类型声明,不会在运行时生成实际代码。

六、Props、事件和插槽怎么选

需求推荐方式
子组件接收数据Props
子组件通知父组件发生了动作Emits / 事件
父组件自定义子组件的一段模板插槽
子组件向父组件模板提供渲染数据作用域插槽
祖先向后代共享跨层级状态Provide / Inject

例如,按钮的文字适合使用默认插槽;按钮是否禁用适合使用 prop;点击后通知父组件适合使用事件:

<Button :disabled="loading" @click="submit">
  {{ loading ? '提交中' : '提交' }}
</Button>

总结

插槽类型作用
默认插槽提供单一内容区域,并支持默认内容
具名插槽为多个布局区域提供内容
作用域插槽子组件提供数据,父组件决定渲染方式
动态插槽根据运行时名称选择内容区域

一句话总结:Props 传数据,事件传行为,插槽传结构;作用域插槽让子组件把渲染所需的数据提供给父组件。