Vue slot 插槽用法:自定義列表組件

Vue 框架的插槽(slot)功能相對於常用的 v-for, v-if 等指令使用頻率少得多,但在實現可復用的自定義組件時十分有用。例如,如果經常使用前端組件庫的話,就會經常看到類似的用法:

<card>
	<template slot="title">訂製卡片組件的標題欄,例如插入icon、操作按鈕等</template>
</card>

之前在寫前端時,發現產品原型的多個頁面中多次出現了基本一致的資訊欄,如下圖。如果只在一個頁面中出現一兩次,複製幾遍列表程式碼,寫一套樣式也關係不大;但在多個頁面中來回複製粘貼就很麻煩,增加無效程式碼量,以後也不好修改(眾所周知,前端 UI 修改並不罕見)。因此將這個資訊欄抽象成一個組件,可以多次復用,實現內容與樣式分離。接下來借這個例子分享一下 Vue 插槽的幾種主要用法。

image-20210809214936330

基本用法

默認插槽

首先新建 InfoCard.vue 組件,寫好基本的模板結構和樣式:上面一條標題欄,下面一個列表(項目中用的是 antd 組件庫中的組件,只是樣式,不影響理解)。CSS 不重要就不貼了。

<!-- InfoCard.vue -->
<template>
  <div class="side-card">
    <div class="side-card-title">這裡是標題</div>
    <a-list>
      <a-list-item>
        <slot></slot>
      </a-list-item>
    </a-list>
  </div>
</template>

在頁面中引入組件後可以在模板中用標籤使用:

<!-- index.vue -->
<info-card>Hello</info-card>

由於組件中只有一個 <slot> 元素(也就是「插槽」),標籤內的內容就會被「插入」插槽對應的位置:

image-20210809223855670

具名插槽

如果組件中有不止一個插槽,就需要通過名字來區分。

  • 在組件中,使用 <slot name="xx"> 屬性指定插槽的名字
  • 在頁面中,使用 <template slot="xx"> 屬性將內容分發到對應的插槽
<!-- InfoCard.vue -->
<div class="side-card">
  <div class="side-card-title" v-if="!hideTitle">
    <slot name="title"></slot>
  </div>
  <a-list>
    <a-list-item>
      <slot name="content"></slot>
    </a-list-item>
  </a-list>
</div>


<!-- index.vue -->
<info-card>
  <template slot="title">
    <p>Hello <a-icon type="smile" /></p>
  </template>
  <template slot="content"> world </template>
</info-card>

image-20210809230138962

預設內容

有時候組件的可變部分有默認值,並不必須在使用時指定(例如默認提示語)。在組件的 <slot> 標籤內部的內容就是該插槽的預設內容,如果在使用時沒有傳入相應內容,將使用預設內容進行渲染。

在這個例子中,標題部分多數情況下是純文本,少數情況下才需要使用 HTML 進行訂製(例如包含操作按鈕)。因此可以配合組件的傳入參數,讓標題定義變得更簡潔,不需要為了一行文本去寫整個標籤。(順便增加一個參數可以隱藏標題欄。)

提示:組件傳參的時候注意區分title="xxx":title="xxx",平時寫多了冒號容易手滑。加冒號是簡寫的 v-bind 指令,這個「xxx」代表的是 data 中一個叫做 xxx 的變數;不加冒號的才是傳入字元串「xxx」作為參數 title 的值。

<!-- InfoCard.vue -->
<div class="side-card">
  <div class="side-card-title" v-if="!hideTitle">
    <slot name="title">{{ title }}</slot>
  </div>
  <a-list>
    <a-list-item>
      <slot name="content"></slot>
    </a-list-item>
  </a-list>
</div>


<!-- index.vue -->
<info-card title="Hello">
  <template slot="content">world</template>
</info-card>

作用域

列表組件應該接收一個數組作為參數,使用 v-for 循環顯示,並且每個列表項的具體內容由頁面傳入的插槽內容決定(因為不同列表裡的對象不一致)。

<a-list>
  <a-list-item v-for="item in items" :key="item.id">
    <slot name="content"></slot>
  </a-list-item>
</a-list>

但是如果在頁面中這樣使用,會產生報錯 Cannot read property 'tag' of undefined

<info-card title="Hello" :items="hotTags">
  <template slot="content">
    <a-tag># {{ item.tag }}</a-tag>
    <span class="number">{{ item.count }}</span>
  </template>
</info-card>

產生錯誤的原因在於,父頁面插槽中的內容先在父頁面中渲染,之後才整體插入子組件的插槽;而不是先插入 HTML 後再一起渲染。很顯然,items、item 都是定義在子組件中的變數數據,在父組件中沒有定義,自然也無法訪問(父頁面中的數據是 hotTags)。

插槽 prop

這裡使用的是 Vue 2.6.0 起更新的語法,原來的作用域插槽 slot-scope 屬性已棄用

頁面傳遞給子組件的參數作用域在子組件內部,而列表項的內容需要在父頁面中定義;因此,需要一種在父組件訪問子組件數據的機制。這就是插槽 prop 的作用。

image-20210810165849267

在子組件的 <slot> 標籤中使用 v-bind 綁定的屬性就是插槽 prop(這裡為了清晰才區分命名了 itemprop 和 item,其實實際用的時候全命名成一樣的即可,省的倒來倒去)。

頁面使用組件時,通過命令 v-slot:name="slotProps" 即可通過 slotProps 訪問 name 插槽中綁定的插槽 prop。

<!-- InfoCard.vue -->
<a-list>
  <a-list-item v-for="item in items" :key="item.id">
    <slot name="content" :itemprop="item">
      {{ item }}
    </slot>
  </a-list-item>
</a-list>


<!-- index.vue -->
<info-card hideTitle :items="hotTags">
  <template v-slot:content="props">
    <a-tag># {{ props.itemprop.tag }}</a-tag>
    <span class="number">{{ props.itemprop.count }}</span>
  </template>
</info-card>

image-20210810170542473

注意:如前文所述,插槽內容是在父頁面中渲染的。因此其中元素的樣式(例如這裡 a-tag 的樣式)也應該定義在父頁面中。

簡寫

  • v-slot: 指令可以簡寫為 #
  • 可以使用ES2015 解構解析插槽 prop 中的各個屬性,更加清晰簡潔
<template #content="{ itemprop }">
  <a-tag># {{ itemprop.tag }}</a-tag>
  <span class="number">{{ itemprop.count }}</span>
</template>

結語

以上是藉助自定義表單組件案例對 Vue 插槽基本用法的介紹,希望對你有所幫助,如有疏漏歡迎留言指正討論。

文末附上開頭圖片中資訊欄案例的大部分實現程式碼,可以對照進行參考。

參考資料:Vue slot

附錄

以下是 InfoCard.vue 的全部程式碼:

<template>
  <div class="side-card">
    <div class="side-card-title" v-if="!hideTitle">
      <slot name="title">{{ title }}</slot>
    </div>
    <a-list>
      <a-list-item v-for="item in items" :key="item.id">
        <slot name="content" :item="item">
          {{ item }}
        </slot>
      </a-list-item>
    </a-list>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      default: '',
    },
    hideTitle: {
      type: Boolean,
      default: false,
    },
    items: {
      type: Array,
      required: true,
    },
  },
}
</script>

<style lang="less" scoped>
.side-card {
  border-radius: 4px;
  background-color: @item-background;
  .side-card-title {
    height: 36px;
    line-height: 36px;
    padding: 0 20px;
    color: #ffffff;
    border-radius: 4px 4px 0 0;
    background: linear-gradient(90deg, #1375ff 0%, #a4fffa 149.57%);
    display: flex;
    justify-content: space-between;
  }
  .ant-list {
    padding: 0 20px;
    .ant-list-item {
      display: flex;
    }
  }
}
</style>

以下是實現開頭三個資訊欄的父頁面程式碼(缺少一些 icon):

<list-card hideTitle :items="myData" class="side-card">
  <template #content="{ item }">
    <span>{{ item.title }}</span>
    <a-tag class="number">{{ item.count }}</a-tag>
  </template>
</list-card>

<list-card title="本周熱搜 TOP5" :items="hotTags" class="side-card">
  <template #content="{ item }">
    <a-tag># {{ item.tag }}</a-tag>
    <span class="number">{{ item.count }}</span>
  </template>
</list-card>

<list-card :items="suggestScholars" class="side-card">
  <template slot="title">
    <span>可能感興趣的人</span>
    <span>換一批</span>
  </template>
  <template #content="{ item }">
    <div class="scholar">
      <div class="name">
        <h2>{{ item.name }}</h2>
        <a-button v-if="item.followed" shape="round" class="btn">
          已關注
        </a-button>
        <a-button v-else type="primary" shape="round" class="btn">
          關注
        </a-button>
      </div>
      <div>研究領域:{{ item.field }}</div>
      <div>{{ item.institution }} · {{ item.position }}</div>
    </div>
  </template>
</list-card>