Skip to content

插槽

TIP

本小节对应的项目源代码,可以去 刘龙彬/vue3-component-study-3 仓库中下载。

插槽的基本概念

43eb46eb-5513-4898-a0f5-5174e89fae1c

先举一个贴近生活的例子,好让大家更容易理解什么是插槽以及插槽的作用。

小霸王游戏机由“机身”和可插拔的游戏“卡带”组成,玩家在机身卡槽中插入什么样的卡带,游戏机就在屏幕上渲染出什么样的游戏画面。在这个例子中,有 3 个非常重要的名词:机身、卡槽、卡带。其中:

  • 卡槽机身预留给用户的一个接口,至于游戏机运行时要渲染何种游戏,机身并不关心;
  • 卡带是玩家提供的具体的游戏内容,把不同的卡带放入机身预留的插槽内,即可渲染出不同的游戏内容;

清楚了以上的例子之后,让我们再回到 Vue 的组件插槽(插槽的英文名叫做 Slot)。

我们在封装 SFC 组件的过程中,可以在 <template> 模板中预留一个内容的插槽,这个插槽中具体要渲染什么元素,将来由组件的使用者来动态提供。因此:

  • 我们封装的这个组件,就相当于游戏机的“机身”;
  • 我们在封装组件时预留的内容插槽,就相当于游戏机的“卡槽”;
  • 组件的使用者提供的内容,就相当于游戏机的“卡带”;

总结一下:组件中的插槽,就是一个模板占位符。这个占位符具体要渲染什么内容,由将来组件的使用者来提供。

插槽图示

插槽的基本用法

dark

封装 CustomButton 组件,通过 <slot> 标签来预留插槽占位符:

vue
<template>
  <button>
    <!-- 这里的 slot 标签,是封装组件时预留的“插槽” -->
    <slot></slot>
  </button>
</template>

在使用 <CustomButton> 组件时,可通过“内容区域”向预留的插槽中注入要渲染的“字符串内容/标签/其它组件”:

vue
<script setup>
import CustomButton from './components2/CustomButton.vue'
import TextContent from './components2/TextContent.vue'
</script>

<template>
  <!-- 1. 向插槽提供“字符串内容” -->
  <CustomButton>添加</CustomButton>

  <!-- 2. 向插槽提供“标签内容” -->
  <CustomButton>
    <span>修改</span>
  </CustomButton>

  <!-- 3. 向插槽提供“组件内容” -->
  <CustomButton>
    <TextContent />
  </CustomButton>
</template>

🚨 注意 🚨

如果子组件中没有声明 <slot> 插槽,则父组件传入的“插槽内容”会被丢弃!

默认内容

在组件中预留插槽时,还可以提供默认内容,仅当外部没有传入插槽内容的情况下,默认内容才会被渲染出来。语法格式如下:

html
<slot>默认内容</slot>

例如,在封装 CustomButton 组件时,向 <slot> 插槽提供默认内容:

vue
<template>
  <button>
    <!-- slot 开闭标签中间的内容,就是插槽的“默认内容” -->
    <slot>按钮</slot>
  </button>
</template>

🚨 注意 🚨

插槽的默认内容,也可以是“字符串/标签/组件”。

插槽命名

在一个组件中可以预留多个 slot 插槽,多个插槽之间可以使用 name 名称进行区分。

默认插槽

未提供名字的插槽叫做“默认插槽”:

html
<slot></slot>

默认插槽不是没有名字,而是名字被省略了,默认插槽的名字是 default。完整的写法如下:

html
<slot name="default"></slot>

具名插槽

提供了 name 名称的插槽叫做“具名插槽”:

html
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>

在上面的例子中,共有两个具名插槽(header 和 footer),同时还有一个默认插槽。

v-slot 指令

v-slot 指令允许我们把不同的模板片段,根据插槽的名称放到对应的 <slot> 插槽中。其基本语法如下:

html
<template v-slot:插槽的名字>
  <p>这个 p 元素要被渲染到指定名称的 slot 插槽中</p>
</template>

🚨 注意 🚨

v-slot 指令必须应用在 <template> 这个虚拟元素上,不能直接添加给实际的 DOM 节点。

例如,在下面的代码中,我们使用 v-slot:headerv-slot:footer 指令,分别向 headerfooter 两个具名插槽中渲染了指定的模板片段:

html
<CustomLayout>
  <template v-slot:header>
    <h1>这是 Header 中的标题</h1>
  </template>

  <p>这是正文中的段落</p>
  <p>这是正文中的段落</p>
  <p>这是正文中的段落</p>

  <template v-slot:footer>
    <span>2024 © 刘龙宾</span>
    <span>&nbsp;&nbsp;京 ICP 备案</span>
  </template>
</CustomLayout>

而正文的 3 个 p 段落会被渲染到默认插槽中,其完整写法如下:

html
<template v-slot:default>
  <p>这是正文中的段落</p>
  <p>这是正文中的段落</p>
  <p>这是正文中的段落</p>
</template>

v-slot 指令的简写

v-slot 指令可以被简写为 #,因此 <template v-slot:header> 可以简写为 <template #header>

下面是完整的、向 CustomLayout 传入插槽内容的代码,v-slot 指令均使用了 # 简写的形式:

html
<CustomLayout>
  <template #header>
    <h1>这是 Header 中的标题</h1>
  </template>

  <template #default>
    <p>这是正文中的段落</p>
    <p>这是正文中的段落</p>
    <p>这是正文中的段落</p>
  </template>

  <template #footer>
    <span>2024 © 刘龙宾</span>
    <span>&nbsp;&nbsp;京 ICP 备案</span>
  </template>
</CustomLayout>

最终,组件渲染出来的 DOM 结构如下:

html
<div class="container">
  <header>
    <h1>这是 Header 中的标题</h1>
  </header>

  <main>
    <p>这是正文中的段落</p>
    <p>这是正文中的段落</p>
    <p>这是正文中的段落</p>
  </main>

  <footer>
    <span>2024 © 刘龙宾</span>
    <span>&nbsp;&nbsp;京 ICP 备案</span>
  </footer>
</div>

条件插槽

在封装组件期间,如果我们不想写死固定个数的插槽占位符,而是想根据用户传入的插槽片段,来动态渲染插槽占位符。此时就需要用到模板内置的 $slots 变量和 v-if 指令。

例如,在之前的例子中,CustomLayout 组件的模板中定义了 headerdefaultfooter 这三个插槽。我们希望实现的效果是:如果用户没有传入 footer 对应的插槽内容,那么就不渲染 footer 对应的插槽占位符。修改后的代码如下:

vue
<template>
  <div class="container">
    <header>
      <slot name="header"></slot>
    </header>

    <main>
      <slot></slot>
    </main>

    <!-- 如果 $slots 对象中包含了用户传入的 footer 插槽的“渲染函数”,
         则渲染 footer 标签以及嵌套的 slot 插槽;否则不渲染它们。 -->
    <footer v-if="$slots.footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

请注意:$slots.footerfooter 插槽的渲染函数,它本质是一个函数:

html
<!-- 浏览器渲染的结果是 function -->
<p>{{ typeof $slots.footer }}</p>

动态插槽名

v-slot:插槽名 指令中的插槽名部分支持动态变量,其语法格式如下:

html
<template v-slot:[插槽名的变量]> ... </template>

<!-- 上述代码简写如下: -->
<template #[插槽名的变量]> ... </template>

示例代码如下:

html
<script setup>
  import CustomLayout from './components2/CustomLayout2.vue'
  import { ref } from 'vue'

  // 1. 响应式数据:动态插槽名
  const slotName = ref('header')
</script>

<template>
  <!-- 2. 点击按钮,切换插槽名变量的值 -->
  <button class="btn-toggle" @click="slotName = 'footer'">切换 slotName 为 footer</button>
  <CustomLayout>
    <!-- 3. 根据“动态插槽名”,动态把模板片段渲染到指定的插槽中 -->
    <template v-slot:[slotName]>
      <h1>这是 Header 中的标题</h1>
    </template>

    <template v-slot:default>
      <p>这是正文中的段落</p>
      <p>这是正文中的段落</p>
      <p>这是正文中的段落</p>
    </template>
  </CustomLayout>
</template>

<style scoped>
  /* 按钮绝对定位到页面右上角 */
  .btn-toggle {
    position: absolute;
    right: 0;
    top: 0;
  }
</style>

作用域插槽

作用域插槽指的是,在子组件中声明 <slot> 插槽的时候,通过 Props 向上传出数据,传出的数据可在父组件的“插槽内容”中接收并使用。

作用域插槽的语法格式如下:

html
<!-- 子组件 -->
<slot :text="content" :count="1"></slot>

在父组件中,可以通过 v-slot="变量名" 来接收子组件的作用域插槽传出的数据:

html
<template v-slot="slotProps">
  <p>{{ slotProps.text }}</p>
  <p>{{ slotProps.count }}</p>
</template>

组件的 Props vs. 插槽上的 Props

组件上的 Props 负责向下传入数据,插槽上的 Props 负责向上传出数据。两者的图示如下:

dark

具名作用域插槽

每个具名插槽也可以通过 props 向上传出数据,父组件可以分别通过 v-slot:插槽名称="slotProps" 来接收传出的数据

简写形式为 #插槽名称="slotProps"

我们在子组件中声明具名的作用域插槽,示例代码如下:

vue
<!-- 子组件 -->
<script setup>
import { ref } from 'vue'
// 响应式数据
const article = ref({
  title: '滕王阁序',
  content: ['豫章故郡,洪都新府。', '星分翼轸,地接衡庐。', '襟三江而带五湖,控蛮荆而引瓯越。'],
  author: '王勃'
})
</script>

<template>
  <div class="container">
    <header>
      <!-- 具名作用域插槽 -->
      <slot name="header" :title="article.title"></slot>
    </header>

    <main>
      <!-- 默认作用域插槽 -->
      <slot :content="article.content"></slot>
    </main>

    <footer v-if="$slots.footer">
      <!-- 具名作用域插槽 -->
      <slot name="footer" :author="article.author"></slot>
    </footer>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

header {
  height: 60px;
  background-color: #2b4b6b;
  color: #fff;
}

main {
  flex: 1;
  background-color: #fff;
}

footer {
  height: 50px;
  background-color: lightgray;
  font-size: 12px;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

在父组件中接收作用域插槽传出的数据:

vue
<script setup>
import CustomLayout from './components2/CustomLayout2.vue'
import { ref } from 'vue'

// 1. 响应式数据:动态插槽名
const slotName = ref('header')
</script>

<template>
  <!-- 2. 点击按钮,切换插槽名变量的值 -->
  <button class="btn-toggle" @click="slotName = 'footer'">切换 slotName 为 footer</button>
  <CustomLayout>
    <!-- 3. 根据“动态插槽名”,动态把模板片段渲染到指定的插槽中 -->
    <template #[slotName]="slotProps">
      <!-- 按需渲染对应的标签 -->
      <h1 v-if="slotName === 'header'">{{ slotProps.title }}</h1>
      <span v-else>作者:{{ slotProps.author }}</span>
    </template>

    <template v-slot:default="slotProps">
      <p v-for="row in slotProps.content" :key="row">{{ row }}</p>
    </template>
  </CustomLayout>
</template>

<style scoped>
/* 按钮绝对定位到页面右上角 */
.btn-toggle {
  position: absolute;
  right: 0;
  top: 0;
}
</style>

天不生夫子,万古长如夜