用 Vue slot 插槽实现一个简单的 list 组件

1,761 阅读8分钟

slot 插槽是 Vue 非常实用的一个功能,它通常用于为同一个组件填充不同的内容。然而,官方文档里面提到的编译作用域、具名插槽、作用域插槽等概念有点抽象,让人有点难以理解。我想通过一个简单的例子,即使用插槽的特性实现一个简单的 list 组件,这会有助于我们彻底搞懂 Vue 插槽。

本文代码使用最新的 v-slot 指令(Vue 2.6.0)实现,v-slot 指令与原来的 slot attribute API 差异请参考官方文档的说明:cn.vuejs.org

插槽内容

image.png
我们想实现一个如图所示的 list 组件。这很简单,参考 Vue 官方文档关于 slot 内容分发的 API,我们可以很快做出来:

list.vue 子组件

<div class="card">
  <h2 class="card-header">实时热搜榜</h2>
  <div class="card-section">
    <ul class="list">
      <slot />
    </ul> 
  </div>
</div>

parent.vue 父组件

<base-list>
  <li>教育部要求严格国际学生申请资格</li>
  <li>央视对话郭杰瑞</li>
  <li>情人节挑战</li>
  <li>钟南山李兰娟张文宏禁毒宣传片</li>
</base-list>

当组件渲染的时候,list 组件的 <slot></slot> 将会被替换为使用该组件时插槽的内容。

等等,你可能会有所疑惑,为什么我们一定需要使用插槽,上面这个例子使用 Props 将列表数据以数组的形式传递给子组件也完全可以呀!是的,使用 Props 看起来还更加简单且符合逻辑。

<template>
  <div class="card">
    <h2 class="card-header">实时热搜榜</h2>
    <div class="card-section">
      <ul class="list">
        <li v-for="item in items">{{ item }}</li>
      </ul> 
    </div>
  </div>
</template>

<script>
export default {
  name: 'baseList',
  props: {
    items: () => {
      return []
    }
  }
</script>

如果仅仅是数值的变动我们完全可以用 Props 解决,但是真实的项目之中,组件往往是复杂的,组件可能还需要嵌套其他组件。假如我们的项目可能需要用到多种类型的 list,list 的 title 样式以及其他部分是保持一致的,但是内部的列表布局和样式可能会有所不同,如下图所示:

image.png
也就是说,我们需要让 list 组件变得更加通用,可以容纳各种样式的列表内容。此时 Props 显然无能为力,插槽灵活性的优势便得以体现。

具名插槽

在上面的例子中,我们的 <slot /> 插槽没有 name attribute,实际上,Vue 默认的会给 <slot /> 加上 name="default",这便是默认插槽。我们也可以自定义插槽的 name,这在组件有多个插槽时是特别有用的。假设我们的 list 组件现在需要将 header 头部也调整为插槽,这样我们就有两个插槽了,我们可以这样写:

list.vue 组件

<div class="card">
  <div class="card-header">
    <slot name="header" />
  </div>
  <div class="card-section">
    <ul class="list">
      /* 这里的 slot 我们可以不设置 name 属性,默认为 default */
      <slot name="content" />
    </ul> 
  </div>
</div>

parent.vue 组件

<base-list>
  <template v-slot:header>
    <h2 class="card-title">猜你喜欢</h2>
  </template>
  <template v-slot:content>
    <li>教育部要求严格国际学生申请资格</li>
    <li>央视对话郭杰瑞</li>
    <li>情人节挑战</li>
    <li>钟南山李兰娟张文宏禁毒宣传片</li>
  </template>
</base-list>

这样一来,我们 list 组件的 header 部分和 content 部分都变为更加灵活的插槽了,我们也可以为 header 设置独特的样式。

image.png

v-slotv-on 一样,有缩写的形式,例如 v-slot:header 可以被重写为 #header,关于这部分内容请参考官方文档,就不再班门弄斧了。

作用域插槽

在了解了具名插槽后,我们再看看是否还有可以优化的地方,让我们暂时撤回添加 header 插槽的部分,回到第一个例子的状态:

parent.vue 父组件

<base-list>
    <li><span>1</span>教育部要求严格国际学生申请资格<small>4百万次</small></li>
    <li><span>2</span>央视对话郭杰瑞<small>3百万次</small></li>
    <li><span>3</span>情人节挑战<small>2百万次</small></li>
    <li><span>4</span>钟南山李兰娟张文宏禁毒宣传片<small>2百万次</small></li>
</base-list>

显然,这里的列表数据都是填充的假数据,我们可以用 v-for 渲染列表数据,以便以后通过后端接口获取真实的数据。

<template>
  <base-list>
    <li v-for="item in messages">
      <span>{{ item.index }}</span>
      {{ item.name }}
      <small>{{ item.count }}百万次</small>
  	</li>
  </base-list>
</template>

<script>
  export default {
    name: 'baseList',
    data() {
      return {
        messages: [
          {name: '教育部要求严格国际学生申请资格', count: '4'},
          {name: '央视对话郭杰瑞', count: '3'},
          {name: '情人节挑战', count: '2'},
          {name: '钟南山李兰娟张文宏禁毒宣传片', count: '2'}
        ]
      }
    }
  }
</script>

这仍然不是最优的,试想一下,我们的 v-for 渲染部分是在父组件完成的,这意味着当我们有多个 list 组件时,我们会存在重复的 v-for 部分代码: parent.vue 父组件

<div id="app">
  <base-list title="实时热搜榜">
    <!-- v-for 代码部分放在父组件,因此当有两个 list 组件时,vue-for 显得有点重复 -->
    <li v-for="item in messages" :key="item.id">
      <span>{{ item.index }}</span>
      {{ item.name }}
      <small>{{ item.count }}百万次</small>
  	</li>
  </base-list>
  <base-list title="热门话题">
    <!-- 这是第二个 v-for 代码部分 -->
    <li class="topic-item" v-for="item in topics" :key="item.id">
      <p class="topic-title">#{{ item.name }}#</p>
      <p class="topic-desc">{{ item.desc }}</p>
  	</li>
  </base-list>
</div>

我们需要想办法将 v-for 部分放入子组件之中,然而这里又会产生一个问题,将 v-for 部分放入子组件后,数据如何传递给子组件呢?

插槽是具有编译作用域的,**父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。**这也意味着只凭借 Props 传递列表数据是不行的,父组件的内容仍然无法获得 v-for 渲染的每一项的数据。

好在,Vue 给我们提供了作用域插槽,为了让列表数据在父级的插槽内容中可用,我们可以将 item 作为 <slot> 元素的一个 attribute 绑定上去,这被称为插槽 prop。这样就可以让子组件的指定内容被允许在父组件中使用。

list.vue 子组件

<div class="card">
  <h2 class="card-header">{{ title }}</h2>
  <div class="card-section">
    <ul class="list">
      <!-- 绑定在 <slot> 元素上的 attribute 被称为插槽 prop -->
      <!-- 让列表数据在父级的插槽内容中可用 -->
      <li v-for="item in items" :key="item.id">
        <slot :item="item" />
      </li>
    </ul>
  </div>
</div>

parent.vue 父组件

<div id="app">
  <!-- 在父级作用域中,我们可以使用带值的 v-slot 来定义我们提供的插槽 prop 的名字 -->
  <base-list title="实时热搜榜" :items="messages" v-slot="slotProps">
    <div>
      <span>{{ slotProps.item.index }}</span>
      {{ slotProps.item.name }}
      <small>{{ slotProps.item.count }}百万次</small>
    </div>
  </base-list>
  <base-list title="热门话题" :items="topics" v-slot="slotProps">
    <div class="topic-item">
      <p class="topic-title">#{{ slotProps.item.name }}#</p>
      <p class="topic-desc">{{ slotProps.item.desc }}</p>
    </div>
  </base-list>
</div>

image.png
关于 插槽 Prop 的相关语法请参考 Vue.js 官方文档,这里也不再赘述了。

我们完成了作用域插槽,但看起来代码量似乎并没有减少,虽然我们将 v-for 部分移到了子组件之中,但是又多使用了 v-slot 等语句,逻辑还变得更加复杂了,有点得不偿失?

不过,这样一来,父子组件的耦合性显著降低了,结构变得更加清晰,这是最佳的做法。

结语

再来回顾一下,虽然我们在本文只完成了一个简单的 list 组件,但是完全的将 Vue 插槽的特性如具名插槽、编译作用域、作用域插槽等内容都实践了一遍,在面对更加复杂的项目开发时,相信也能得心应手、举一反三。

参考

本文在书写过程中,参考了以下文章中的内容,在此致以诚挚的感谢:

vue 官方文档 - 插槽

Chor - 「译」一个案例搞懂 Vue.js 的作用域插槽

Essentric - 从一个简单的 list 组件搞懂 Vue 插槽

YXi - Vue 插槽(slot)使用(通俗易懂)