# 留白的优雅：插槽内容分发


## 概述

前一篇文章讲了 props。props 传递的是**数据**。

但如果父组件想传的不是数据，而是一段 HTML 模板呢？

比如你有一个"卡片"组件，它的边框、标题栏都是固定的，但中间的内容每次不同。用 props 传 HTML 字符串会很难处理。

Vue 3 提供了**插槽（Slot）** 来解决这个问题。它让父组件可以向子组件内部的指定位置"塞入"模板内容。

## 默认插槽

### 基本用法

子组件中用 `<slot>` 占位，父组件在标签之间填写内容：

```vue
<!-- FancyButton.vue -->
<template>
  <button class="fancy-btn">
    <slot />
  </button>
</template>
```

```vue
<!-- Parent.vue -->
<template>
  <FancyButton>点击我</FancyButton>
  <FancyButton>
    <span style="color: red">删除</span>
  </FancyButton>
</template>
```

渲染结果：

```html
<button class="fancy-btn">点击我</button>
<button class="fancy-btn"><span style="color: red">删除</span></button>
```

`<slot>` 就是子组件中开的"口子"，父组件的内容会填到那个位置。

### 后备内容

如果父组件没传内容，可以给 slot 设置默认显示：

```vue
<!-- SubmitButton.vue -->
<template>
  <button type="submit">
    <slot>提交</slot>
  </button>
</template>
```

```vue
<template>
  <!-- 不传内容时显示 "提交" -->
  <SubmitButton />

  <!-- 传内容时覆盖 -->
  <SubmitButton>保存</SubmitButton>
</template>
```

### 渲染作用域

插槽的内容是在父组件中定义的，所以它只能访问父组件的数据，不能访问子组件的数据。

```vue
<template>
  <FancyButton>
    {{ parentMessage }}  <!-- ✅ 父组件的数据 -->
    {{ childMessage }}   <!-- ❌ 子组件的数据，拿不到 -->
  </FancyButton>
</template>
```

## 具名插槽

一个组件可能需要在多个位置插入内容。比如布局组件，需要 header、main、footer 三个插槽。

这时可以用 `name` 属性区分：

```vue
<!-- BaseLayout.vue -->
<template>
  <header>
    <slot name="header" />
  </header>
  <main>
    <slot />  <!-- 不写 name 就是默认插槽，name 为 "default" -->
  </main>
  <footer>
    <slot name="footer" />
  </footer>
</template>
```

父组件用 `v-slot` 指令指定内容对应哪个插槽：

```vue
<template>
  <BaseLayout>
    <template #header>
      <h1>网站标题</h1>
    </template>

    <template #default>
      <p>主要内容</p>
    </template>

    <template #footer>
      <p>版权信息</p>
    </template>
  </BaseLayout>
</template>
```

`#` 是 `v-slot:` 的简写。`#header` 等价于 `v-slot:header`。

> [!tip] 技巧
> 非 `template` 的内容会自动归为默认插槽。上面的 `#default` 可以省略，直接把内容写在组件标签之间即可。

## 作用域插槽

默认情况下，插槽内容只能访问父组件的数据。

但有时候，子组件需要把它的数据"吐"给插槽内容使用。比如一个列表组件，它负责循环和加载状态，但每一项怎么渲染由父组件决定。

子组件向 slot 传递数据：

```vue
<!-- FancyList.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :data="item" />
    </li>
  </ul>
</template>

<script setup lang="ts">
const items = ref([
  { id: 1, title: '文章一', author: '张三' },
  { id: 2, title: '文章二', author: '李四' },
])
</script>
```

父组件用 `#name="props"` 接收数据：

```vue
<template>
  <FancyList>
    <template #item="{ data }">
      <div>
        <strong>{{ data.title }}</strong>
        <span>—— {{ data.author }}</span>
      </div>
    </template>
  </FancyList>
</template>
```

这样，数据的控制和展示就分开了。子组件管数据（从哪里加载、怎么分页），父组件管展示（每项长什么样）。

### 默认插槽的作用域

默认插槽也可以用作用域：

```vue
<!-- Child.vue -->
<template>
  <slot text="hello" :count="1" />
</template>
```

```vue
<template>
  <Child v-slot="{ text, count }">
    <p>{{ text }} {{ count }}</p>
  </Child>
</template>
```

> [!warning] 注意
> 当有具名插槽时，默认插槽需要用 `template` 包裹才能接收作用域数据。

## 条件插槽

有时候，子组件的外层结构依赖于是否传入了插槽内容。

比如一个卡片组件，如果没传 header，就不渲染 header 区域。

用 `$slots` 可以判断：

```vue
<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    <div class="card-content">
      <slot />
    </div>
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>
```

这样，没传入 header 时，header 的 DOM 元素根本不会出现，页面结构更干净。

## 插槽 vs props

| 对比 | props | 插槽 |
|------|-------|------|
| 传递什么 | 数据（字符串、数字、对象等） | 模板片段 |
| 谁渲染 | 子组件渲染 | 父组件渲染后嵌入子组件 |
| 灵活性 | 子组件决定如何展示 | 父组件决定展示方式 |
| 适用场景 | 配置项、数据传递 | 布局、自定义内容 |

---

插槽让组件的组合方式更灵活。父组件可以在子组件内部自由布置内容。

但到目前为止，我们只讲了相邻组件（父子）之间的通信。

如果组件层级很深呢？爷爷传给孙子，要经过几层 props 传递。

这个问题的解决方案是 **provide / inject**，也是下一篇要讲的内容之一。


---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/%E7%95%99%E7%99%BD%E7%9A%84%E4%BC%98%E9%9B%85%E6%8F%92%E6%A7%BD%E5%86%85%E5%AE%B9%E5%88%86%E5%8F%91/  

