留白的优雅:插槽内容分发

第 1 节 概述

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

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

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

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

第 2 节 默认插槽

2.1 基本用法

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

1
2
3
4
5
6
<!-- FancyButton.vue -->
<template>
  <button class="fancy-btn">
    <slot />
  </button>
</template>
1
2
3
4
5
6
7
<!-- Parent.vue -->
<template>
  <FancyButton>点击我</FancyButton>
  <FancyButton>
    <span style="color: red">删除</span>
  </FancyButton>
</template>

渲染结果:

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

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

2.2 后备内容

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

1
2
3
4
5
6
<!-- SubmitButton.vue -->
<template>
  <button type="submit">
    <slot>提交</slot>
  </button>
</template>
1
2
3
4
5
6
7
<template>
  <!-- 不传内容时显示 "提交" -->
  <SubmitButton />

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

2.3 渲染作用域

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

1
2
3
4
5
6
<template>
  <FancyButton>
    {{ parentMessage }}  <!--  父组件的数据 -->
    {{ childMessage }}   <!--  子组件的数据拿不到 -->
  </FancyButton>
</template>

第 3 节 具名插槽

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

这时可以用 name 属性区分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- BaseLayout.vue -->
<template>
  <header>
    <slot name="header" />
  </header>
  <main>
    <slot />  <!-- 不写 name 就是默认插槽name  "default" -->
  </main>
  <footer>
    <slot name="footer" />
  </footer>
</template>

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<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

技巧

template 的内容会自动归为默认插槽。上面的 #default 可以省略,直接把内容写在组件标签之间即可。

第 4 节 作用域插槽

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

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

子组件向 slot 传递数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- 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" 接收数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <FancyList>
    <template #item="{ data }">
      <div>
        <strong>{{ data.title }}</strong>
        <span>—— {{ data.author }}</span>
      </div>
    </template>
  </FancyList>
</template>

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

4.1 默认插槽的作用域

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

1
2
3
4
<!-- Child.vue -->
<template>
  <slot text="hello" :count="1" />
</template>
1
2
3
4
5
<template>
  <Child v-slot="{ text, count }">
    <p>{{ text }} {{ count }}</p>
  </Child>
</template>
注意

当有具名插槽时,默认插槽需要用 template 包裹才能接收作用域数据。

第 5 节 条件插槽

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

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

$slots 可以判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<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 元素根本不会出现,页面结构更干净。

第 6 节 插槽 vs props

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

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

但到目前为止,我们只讲了相邻组件(父子)之间的通信。

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

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