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

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

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

基本用法

預設插槽

首先新建 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> 元素(也就是“插槽”),標籤內的內容就會被“插入”插槽對應的位置:

具名插槽

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

  • 在元件中,使用 <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>

預設內容

有時候元件的可變部分有預設值,並不必須在使用時指定(例如預設提示語)。在元件的 <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 的作用。

在子元件的 <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>

注意:如前文所述,插槽內容是在父頁面中渲染的。因此其中元素的樣式(例如這裡 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>