做專案一定寫過共用按鈕:樣式統一、圓角統一、hover 效果統一,每個頁面只要放上不同的文字就好。
這種「外框是固定的,內容由使用的人決定」的需求,Vue 給的答案就是 slot(插槽)。
今天會用一顆按鈕,從最簡單的用法一路長大,最後打開編譯結果,看看標題那句話是什麼意思:slot 其實是一個函式。
最直覺的寫法,可能是用 prop 把文字傳進去:
<!-- BaseButton.vue -->
<script setup>
defineProps({ text: String })
</script>
<template>
<button class="btn">{{ text }}</button>
</template>
<BaseButton text="送出" />
這樣完全能用。那換成 slot 版本:
<!-- BaseButton.vue -->
<template>
<button class="btn">
<slot />
</button>
</template>
<BaseButton>送出</BaseButton>
<slot /> 就像在組件裡挖了一個洞,父組件寫在標籤中間的內容會被放進這個洞裡。
看起來只是換個寫法,那 slot 到底贏在哪?
假設下週設計稿改了:送出按鈕左邊要加一個 icon,刪除按鈕的筆數要粗體。
用 prop 的版本會開始頭痛:加一個 icon prop?那粗體呢?再加一個 boldText?每多一種需求,就得回去改一次 BaseButton。
用 slot 的版本,BaseButton 一行都不用動:
<BaseButton>
<IconSend /> 送出
</BaseButton>
<BaseButton>
刪除 <strong>3</strong> 筆資料
</BaseButton>
差別在於:prop 傳的是一個值,slot 傳的是一段畫面。 畫面裡要放文字、HTML 還是其他組件,由使用的人決定。
slot 標籤中間可以放預設內容,父組件什麼都沒傳時就顯示它:
<button class="btn">
<slot>按鈕</slot>
</button>
<BaseButton /> <!-- 顯示「按鈕」 -->
<BaseButton>送出</BaseButton> <!-- 顯示「送出」 -->
按鈕送出後要顯示 loading,loading 期間原本的內容要被轉圈圖示取代。
<!-- BaseButton.vue -->
<script setup>
defineProps({
loading: Boolean,
variant: { type: String, default: 'primary' }
})
</script>
<template>
<button
:class="['btn', `btn-${variant}`]"
:disabled="loading"
>
<span v-if="loading" class="spinner" />
<slot v-else />
</button>
</template>
<BaseButton :loading="isSubmitting" @click="submit">
<IconSend /> 送出
</BaseButton>
這裡先停一下,想一個問題:
<IconSend /> 送出 這段內容,是父組件先做好、整包交給子組件,還是子組件需要的時候才叫它做出來?
如果是前者,loading 的時候這段內容其實已經做好了,只是沒擺上去。如果是後者,loading 的時候它根本不會被做出來。
答案是後者,原因要打開編譯結果才看得到。
Day 2 用過的 SFC Playground 又派上用場了。把父組件的這段貼進去:
<BaseButton>送出</BaseButton>
切到 JS 分頁,會看到類似這樣的東西:
_createVNode($setup["BaseButton"], null, {
default: _withCtx(() => [
_createTextVNode("送出")
])
})
注意第三個參數,它是一個物件:
default,也就是插槽的名字<slot /> 會被編譯成類似這樣:_renderSlot(_ctx.$slots, "default")
意思是:從 $slots 裡拿出 default 這個函式,呼叫它。
所以整件事是這樣運作的:
回到剛剛的 loading 按鈕:<slot v-else /> 在 loading 時根本不會執行,函式沒被呼叫,<IconSend /> 送出 就不會被建立。
可以,而且這是 slot 最實用的地方。
接著上面的按鈕:如果父組件想自己決定 loading 時的文字,例如顯示「送出中…」,它需要知道現在是不是 loading。
子組件只要在呼叫 slot 時把資料傳出去:
<!-- BaseButton.vue -->
<template>
<button
:class="['btn', `btn-${variant}`]"
:disabled="loading"
>
<slot :loading="loading" />
</button>
</template>
父組件用 v-slot 接住:
<BaseButton :loading="isSubmitting" v-slot="{ loading }">
{{ loading ? '送出中…' : '送出' }}
</BaseButton>
這種會傳資料出來的 slot,叫作作用域插槽(scoped slot)。
再去 Playground 看編譯結果,父組件那邊會變成:
{
default: _withCtx(({ loading }) => [
_createTextVNode(_toDisplayString(loading ? '送出中…' : '送出'))
])
}
子組件那邊則變成:
_renderSlot(_ctx.$slots, "default", { loading: $props.loading })
v-slot="{ loading }" 其實就是函式的參數解構,而 <slot :loading="loading" /> 就是呼叫函式時傳入的引數。
所以作用域插槽並不是什麼特別的新功能,它只是「呼叫函式時順便傳參數」而已。
再想一個問題。父組件有一個 count,子組件也有一個 count:
<!-- 父組件 -->
<script setup>
import { ref } from 'vue'
const count = ref(10)
</script>
<template>
<BaseButton>點了 {{ count }} 次</BaseButton>
</template>
畫面上顯示的是父組件的 count,還是子組件的?
答案是父組件的。
用「函式」的角度就很好理解:這個函式是在父組件的模板裡寫出來的,它能看到的變數自然是父組件的,就跟一般 JS 函式只看得到自己被定義的地方的變數一樣。子組件只負責呼叫它,看不到也碰不到子組件的變數。
那子組件的資料要怎麼讓 slot 用到?只能透過參數傳進去,也就是上一段的作用域插槽。
Vue 官方文件把這件事說成一句規則:父組件模板裡的東西只能存取父組件的作用域,子組件模板裡的東西只能存取子組件的作用域。
因為 slot 函式是在子組件渲染時才被呼叫,slot 內容用到的資料,會被記在子組件的依賴裡。
以上面的例子來說,count 改變時,需要重新渲染的是 BaseButton,父組件本身不一定要跟著重跑。slot 內容多、父組件又很大的時候,這個差別就會出現。
按鈕只需要一個洞,但卡片通常有標題、內容、底部三塊。這時可以幫 slot 取名字:
<!-- BaseCard.vue -->
<template>
<div class="card">
<header class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<footer class="card-footer">
<slot name="footer" />
</footer>
</div>
</template>
父組件用 <template #名字> 對應:
<BaseCard>
<template #header>會員資料</template>
<p>這裡是內容,沒寫 template 的就會進 default</p>
<template #footer>
<BaseButton>儲存</BaseButton>
</template>
</BaseCard>
#header 是 v-slot:header 的縮寫。
對照編譯結果,$slots 就是一個裝著好幾個函式的物件:
{
header: () => [...],
default: () => [...],
footer: () => [...]
}
既然 $slots 是一個物件,子組件就可以先檢查某個函式存不存在:
<!-- BaseCard.vue -->
<template>
<div class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div>
</template>
父組件沒傳 footer,就不會留下一個空的 <footer> 和它的 padding、border。這在切版上很實用,可以省掉一堆 showHeader、showFooter 這種 props。
在 <script setup> 裡要用的話,可以用 useSlots():
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
console.log(!!slots.header) // 父組件有沒有傳 header
</script>
把前面學到的全部組合起來,做一個實際專案裡很常見的東西:共用表格。
需求是這樣的:大部分欄位直接顯示文字就好,但有些頁面的「狀態」欄要顯示彩色標籤,「操作」欄要放編輯、刪除按鈕。
表格的處境是:它知道每一列的資料,但不知道每一格該長什麼樣子。 頁面的處境剛好相反。這正是作用域插槽的用武之地。
<!-- DataTable.vue -->
<script setup>
defineProps({
columns: Array, // [{ key: 'name', label: '名稱' }, ...]
rows: Array
})
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.key">
<slot
:name="`cell-${col.key}`"
:row="row"
:value="row[col.key]"
>
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
這裡用到三個技巧:
:name="cell-${col.key}",每個欄位自動有一個對應的插槽row 和這一格的值 value 交給父組件<script setup>
const columns = [
{ key: 'title', label: '案件名稱' },
{ key: 'status', label: '狀態' },
{ key: 'actions', label: '操作' }
]
</script>
<template>
<DataTable :columns="columns" :rows="cases">
<template #cell-status="{ value }">
<span :class="['badge', `badge-${value}`]">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<BaseButton @click="edit(row)">編輯</BaseButton>
<BaseButton variant="danger" @click="remove(row.id)">刪除</BaseButton>
</template>
</DataTable>
</template>
title 欄沒寫任何 template,自動走預設內容。之後哪個頁面要多一種欄位樣式,只要在那個頁面多寫一個 template,DataTable 完全不用改。
slot 很好用,但不是所有「共用」都該交給它。
如果你想共用的只有邏輯,例如抓資料的 loading 狀態、滑鼠座標、視窗寬度,在 Vue 3 應該寫成 composable:
// useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const loading = ref(true)
fetch(url)
.then(res => res.json())
.then(json => { data.value = json })
.finally(() => { loading.value = false })
return { data, loading }
}
slot 適合的情況是:組件要掌控整體結構,但把其中幾塊的長相交給外部決定。 按鈕、卡片、表格、Modal、下拉選單都屬於這一類。
簡單判斷方式:
| 想共用的東西 | 用什麼 |
|---|---|
| 只有邏輯和狀態 | composable |
| 固定的外框,內容由外部決定 | slot |
| 固定的外框,內容由外部決定,還需要組件內的資料 | 作用域插槽 |
<slot /> 就是呼叫這個函式
$slots.xxx 判斷有沒有傳)