Code前端首页关于Code前端联系我们

Vue3插槽是什么?实际开发中怎么用好它?

terry 1小时前 阅读数 26 #Vue

在Vue项目开发群里,最近总有人发“这个弹窗能不能把标题改成动态的表单提交提示?”“商品卡片的标签部分怎么支持不同产品插图标?”这类问题,往往聊两句就会有人接“用Vue3插槽啊”,但具体怎么用、为什么要用、有哪些进阶玩法,很多刚开始学Vue3的朋友还是一知半解,甚至把它当成了一个“可有可无的高级功能”,今天咱们就用大白话,把这些疑问全聊透。

先搞懂基础:Vue3插槽到底解决了啥问题?

很多刚入门的前端同学可能会觉得,既然组件是用来复用UI和逻辑的,那直接传props不就行了?比如商品卡片组件,传个title、price、imgUrl,再传个tag字符串,不就能展示标签了?但如果有的产品经理要求,这个标签要显示成「红色促销角标+文字」,有的要显示成「带图标标签」,有的还要显示成「倒计时模块」,这时候props就不够用了——因为props只能传数据,不能传“一段自定义的DOM/组件”。

这时候插槽就派上用场了。Vue3插槽就是组件内部预留的“占位符”,允许父组件往里面塞任意内容:可以是纯文本、HTML标签、其他Vue组件,甚至可以是带有交互逻辑的小片段,咱们可以把组件想象成一个“空的快递盒”,组件本身的固定结构(比如快递盒的盒子、盖子、提手)就是组件的模板代码,而“空的储物空间”就是插槽,父组件负责往里面装“货物”(也就是自定义内容)。

举个最基础的例子,咱们做一个通用的按钮组件:

// 通用按钮组件 BaseButton.vue
<template>
  <button class="base-btn">
    <!-- 这就是默认插槽的占位符 -->
    <slot></slot>
  </button>
</template>
<style scoped>
.base-btn {
  padding: 8px 16px;
  border-radius: 4px;
  background: #409eff;
  color: #fff;
  border: none;
  cursor: pointer;
}
</style>

然后在父组件里用这个按钮:

<template>
  <div>
    <BaseButton>点击提交</BaseButton>
    <BaseButton>
      <span style="margin-right: 4px;">✅</span>
      保存成功
    </BaseButton>
    <BaseButton>
      <icon-component name="download"></icon-component>
      下载文件
    </BaseButton>
  </div>
</template>

你看,同样的按钮组件,父组件可以往里面塞纯文本、带HTML的内容、甚至其他自定义组件,完全满足不同场景的需求,这就是插槽最核心的价值:在保持组件可复用性的同时,给组件的UI留下最大的自定义空间。

进阶玩法一:具名插槽,精准控制多个占位符

刚才的快递盒只有一个储物空间,但如果咱们的组件有多个需要自定义的地方呢?比如一个通用的弹窗组件,可能需要自定义「弹窗标题」「弹窗内容」「弹窗底部按钮组」三个部分,这时候就需要用具名插槽了——也就是给每个占位符起个名字,父组件根据名字往对应的位置塞内容。

具名插槽在Vue3里有两种写法,推荐用简化后的<template v-slot:插槽名>语法,或者更短的<template #插槽名>语法(注意,简化语法只能在<template>标签上用哦),咱们再做一个通用的弹窗组件来演示:

// 通用弹窗组件 BaseModal.vue
<template>
  <div class="modal-mask" v-if="visible">
    <div class="modal-content">
      <div class="modal-header">
        <!-- 具名插槽:header -->
        <slot name="header"></slot>
      </div>
      <div class="modal-body">
        <!-- 默认插槽,也可以写成 <slot name="default"></slot> -->
        <slot></slot>
      </div>
      <div class="modal-footer">
        <!-- 具名插槽:footer -->
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>
<style scoped>
/* 这里省略弹窗的基本样式,重点看插槽的结构 */
.modal-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}
.modal-content {
  width: 400px;
  background: #fff;
  border-radius: 8px;
  overflow: hidden;
}
.modal-header {
  padding: 16px;
  border-bottom: 1px solid #eee;
  font-weight: bold;
}
.modal-body {
  padding: 20px 16px;
}
.modal-footer {
  padding: 12px 16px;
  border-top: 1px solid #eee;
  text-align: right;
}
</style>

然后父组件里用这个弹窗:

<template>
  <div>
    <button @click="showDeleteModal = true">删除文章</button>
    <BaseModal :visible="showDeleteModal">
      <!-- 往header插槽塞自定义标题 -->
      <template #header>
        <span style="color: #f56c6c;">⚠️ 确认删除</span>
        <icon-component name="close" style="float: right; cursor: pointer;" @click="showDeleteModal = false"></icon-component>
      </template>
      <!-- 往默认插槽塞内容,不用写template标签包裹吗?其实也可以写,但纯内容的话直接塞更方便 -->
      您确定要删除这篇文章吗?删除后无法恢复,请谨慎操作!
      <!-- 往footer插槽塞自定义按钮组 -->
      <template #footer>
        <button @click="showDeleteModal = false">取消</button>
        <button style="margin-left: 8px; background: #f56c6c;" @click="deleteArticle">确认删除</button>
      </template>
    </BaseModal>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import BaseModal from './BaseModal.vue'
import IconComponent from './IconComponent.vue'
const showDeleteModal = ref(false)
const deleteArticle = () => {
  // 这里写删除文章的逻辑
  showDeleteModal.value = false
}
</script>

这样是不是清晰多了?不管是标题加关闭按钮,还是底部按钮组的样式和顺序,父组件都可以完全控制,组件本身只负责弹窗的遮罩、居中、边框这些通用的UI和交互。

进阶玩法二:作用域插槽,父组件能用子组件的数据

刚才的两种插槽,不管是默认的还是具名的,都是父组件给子组件“喂内容”,但如果反过来呢?比如咱们做一个通用的表格组件,表格的列是固定的(比如ID、姓名、年龄),但单元格的显示方式需要自定义:比如年龄列如果超过30岁就显示成红色,姓名列要显示成“头像+姓名”的组合,这时候父组件需要用到子组件(表格组件)里的当前行数据,这时候就需要用作用域插槽了。

作用域插槽其实就是“带参数的插槽”——子组件在定义插槽的时候,可以把自己内部的数据传给插槽,父组件在使用插槽的时候,可以接收这些参数,然后根据参数来渲染自定义内容,咱们还是用表格组件来演示:

// 通用表格组件 BaseTable.vue
<template>
  <table class="base-table">
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.prop">{{ col.label }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, index) in data" :key="row.id">
        <!-- 遍历列,根据列的prop或者插槽名来渲染 -->
        <td v-for="col in columns" :key="col.prop">
          <!-- 这里是关键:如果当前列有对应的插槽,就用作用域插槽,否则显示默认的row[col.prop] -->
          <slot :name="col.prop" :row="row" :index="index">
            {{ row[col.prop] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>
<style scoped>
.base-table {
  width: 100%;
  border-collapse: collapse;
}
.base-table th, .base-table td {
  padding: 12px 16px;
  border: 1px solid #eee;
  text-align: left;
}
</style>

父组件里使用这个表格:

<template>
  <div>
    <BaseTable :columns="tableColumns" :data="tableData">
      <!-- 自定义姓名列的显示:头像+姓名 -->
      <template #name="{ row }">
        <div style="display: flex; align-items: center;">
          <img :src="row.avatar" style="width: 32px; height: 32px; border-radius: 50%; margin-right: 8px;" alt="">
          {{ row.name }}
        </div>
      </template>
      <!-- 自定义年龄列的显示:超过30岁红色 -->
      <template #age="{ row }">
        <span :style="{ color: row.age > 30 ? '#f56c6c' : '' }">{{ row.age }}</span>
      </template>
      <!-- 自定义操作列的显示:加一个编辑按钮 -->
      <template #action="{ row, index }">
        <button @click="editRow(row)">编辑</button>
      </template>
    </BaseTable>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import BaseTable from './BaseTable.vue'
const tableColumns = ref([
  { label: 'ID', prop: 'id' },
  { label: '姓名', prop: 'name' },
  { label: '年龄', prop: 'age' },
  { label: '操作', prop: 'action' } // 这里prop是action,但默认插槽没数据,所以必须用作用域插槽自定义
])
const tableData = ref([
  { id: 1, name: '张三', age: 28, avatar: 'https://example.com/avatar1.jpg' },
  { id: 2, name: '李四', age: 35, avatar: 'https://example.com/avatar2.jpg' },
  { id: 3, name: '王五', age: 22, avatar: 'https://example.com/avatar3.jpg' }
])
const editRow = (row) => {
  // 这里写编辑行的逻辑
  console.log('编辑行', row)
}
</script>

这里有几个细节要注意:

  1. 子组件在定义作用域插槽的时候,用的是<slot :name="col.prop" :row="row" :index="index">,这里的row和index就是传给父组件的参数,参数名可以随便起,但建议语义化一点;
  2. 父组件在使用作用域插槽的时候,用的是<template #name="{ row }">,这里的{ row }是ES6的解构赋值,也可以写成<template #name="scope">然后用scope.row,但解构赋值更方便;
  3. 如果父组件没有给某个列传插槽,就会显示子组件<slot>标签里的默认内容(也就是{{ row[col.prop] }}),这个默认内容的设置很重要,能让组件的使用更灵活,不用每个列都自定义。

避坑指南:Vue3插槽容易踩的几个小坑

聊完了三种插槽的基本用法,咱们再聊聊实际开发中容易踩的几个小坑,避免大家走弯路。

坑一:简化语法不能单独用在非<template>标签上

刚才咱们提到过,具名插槽和作用域插槽的简化语法#插槽名或者#插槽名="{参数}",只能用在<template>标签上,不能直接用在普通的HTML标签或者组件上,比如下面这种写法是错误的:

<!-- 错误写法 -->
<BaseButton #default>点击提交</BaseButton>

正确的写法是,如果是默认插槽的简化语法,应该写成<template #default>包裹,或者直接不用<template>标签(默认插槽直接塞内容就行);如果是具名插槽或者作用域插槽,必须用<template>标签包裹。

坑二:作用域插槽的参数是对象,要注意解构赋值的顺序

刚才咱们用了ES6的解构赋值来接收作用域插槽的参数,但如果子组件传的参数顺序变了,会不会影响父组件?其实不会,因为子组件传的是一个对象,父组件是根据对象的属性名来解构的,和顺序无关,比如子组件改成<slot :name="col.prop" :index="index" :row="row">,父组件还是用<template #name="{ row }">,完全没问题。

但要注意,如果子组件没有传某个参数,父组件解构的时候可以给它一个默认值,避免报错:

<!-- 父组件给index一个默认值0 -->
<template #name="{ row, index = 0 }">
  {{ index + 1 }}. {{ row.name }}
</template>

坑三:在v-if和v-for一起用的元素里,插槽的渲染顺序要注意

Vue3已经不再推荐在同一个元素上同时使用v-if和v-for了,但如果真的要用(比如为了兼容旧代码),要注意v-for的优先级比v-if高,所以插槽的渲染顺序也是先循环再判断,这一点和Vue2是一样的,但更推荐的做法是,把v-if移到父元素或者子元素上,或者用计算属性先过滤数据,再循环。

坑四:插槽里的内容会继承父组件的作用域,不会继承子组件的作用域

这一点其实是插槽的基本特性,但很多初学者容易搞混。插槽里的内容,数据和方法都是来自父组件的,不是来自子组件的,比如如果子组件里有一个msg变量,父组件在插槽里直接用{{ msg }}是会报错的,除非子组件通过作用域插槽把msg传过来。

咱们可以做一个小测试来验证:

// 子组件 TestSlot.vue
<template>
  <div>
    <p>子组件的msg:{{ msg }}</p>
    <slot></slot>
  </div>
</template>
<script setup>
import { ref } from 'vue'
const msg = ref('我是子组件的消息')
</script>
// 父组件
<template>
  <div>
    <p>父组件的msg:{{ msg }}</p>
    <TestSlot>
      <p>插槽里的msg:{{ msg }}</p>
    </TestSlot>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import TestSlot from './TestSlot.vue'
const msg = ref('我是父组件的消息')
</script>

运行之后你会发现,插槽里的msg显示的是“我是父组件的消息”,而不是子组件的,这就验证了刚才的说法。

最后聊一聊:Vue3插槽和Vue2插槽有啥区别?

如果你之前用过Vue2,可能会发现Vue3的插槽语法和Vue2有一些不一样,这里咱们简单总结一下:

  1. 默认插槽的写法更统一:Vue2里默认插槽可以用<slot></slot>,也可以用<slot name="default"></slot>,但有时候还会用到<template slot-scope="scope">这种写法;Vue3里默认插槽就是<slot></slot>或者<slot name="default"></slot>,作用域插槽统一用<template v-slot:插槽名="scope">或者简化的<template #插槽名="scope">;
  2. 简化语法更方便:Vue3新增了简化语法,不用写那么长的v-slot:了;
  3. slot-scope被废弃了:Vue2里的<template slot-scope="scope">在Vue3里不能用了,必须用v-slot或者;
  4. 具名插槽和作用域插槽的合并写法更清晰:Vue2里如果要同时用具有名和作用域,写法是<template slot="header" slot-scope="scope">,Vue3里直接写成<template #header="scope">就可以了。

不过虽然语法有变化,但插槽的核心思想是一样的:都是预留占位符,允许父组件自定义内容,父组件插槽的内容继承父作用域,如果你之前熟练掌握了Vue2的插槽,过渡到Vue3的插槽会非常快,只要记住新的语法就行。

今天咱们从最基础的“插槽解决啥问题”聊起,讲了默认插槽、具名插槽、作用域插槽的用法,还聊了容易踩的几个小坑,最后对比了Vue3和Vue2插槽的区别,插槽就是Vue组件复用的“最后一块拼图”——props用来传数据,emit用来传事件,slot用来传“自定义内容”,三者结合起来,就能做出非常灵活、复用性非常高的组件。

如果你现在还在犹豫要不要学插槽,别犹豫了,赶紧动手写几个通用组件试试吧,比如通用的卡片组件、通用的分页组件、通用的标签页组件,写完之后你会发现,插槽真的是太好用了!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门