iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 19 篇

Vue 走過路過不要錯過 Day19 - slots:插槽其實是一個函式

  • 分享至 

  • xImage
  •  

做專案一定寫過共用按鈕:樣式統一、圓角統一、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 到底贏在哪?


prop 傳的是值,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,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 的時候它根本不會被做出來。

答案是後者,原因要打開編譯結果才看得到。


打開 SFC Playground 看一下

Day 2 用過的 SFC Playground 又派上用場了。把父組件的這段貼進去:

<BaseButton>送出</BaseButton>

切到 JS 分頁,會看到類似這樣的東西:

_createVNode($setup["BaseButton"], null, {
  default: _withCtx(() => [
    _createTextVNode("送出")
  ])
})

注意第三個參數,它是一個物件:

  • key 是 default,也就是插槽的名字
  • value 是一個箭頭函式,執行它才會產生「送出」這段 VNode
    再看子組件 BaseButton,<slot /> 會被編譯成類似這樣:
_renderSlot(_ctx.$slots, "default")

意思是:從 $slots 裡拿出 default 這個函式,呼叫它。

所以整件事是這樣運作的:

  1. 父組件把「怎麼做出這段畫面」寫成一個函式,交給子組件
  2. 子組件在自己的模板裡,決定什麼時候、要不要呼叫這個函式
    這就是標題說的:slot 其實是一個函式。

回到剛剛的 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" /> 就是呼叫函式時傳入的引數。

所以作用域插槽並不是什麼特別的新功能,它只是「呼叫函式時順便傳參數」而已。


slot 裡的變數,是誰的?

再想一個問題。父組件有一個 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: () => [...]
}

沒傳的 slot,就不要畫外框

既然 $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>

這裡用到三個技巧:

  1. 動態插槽名稱::name="cell-${col.key}",每個欄位自動有一個對應的插槽
  2. 傳參數:把整列資料 row 和這一格的值 value 交給父組件
  3. 預設內容:父組件沒客製的欄位,就直接顯示原始文字
    使用的頁面只要處理需要客製的欄位:
<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?

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 讓組件「外框固定,內容由使用的人決定」,傳的是畫面而不是值
  • 在編譯結果裡,父組件傳給子組件的 slot 是一個函式,子組件的 <slot /> 就是呼叫這個函式
  • 因為是函式,子組件可以決定要不要呼叫(loading 時不渲染、$slots.xxx 判斷有沒有傳)
  • 因為是函式,子組件呼叫時可以傳參數,這就是作用域插槽
  • 因為函式寫在父組件,slot 內容只看得到父組件的變數,子組件的資料只能透過參數拿到
  • 只想共用邏輯時,用 composable 會比 slot 更簡單
    下次寫共用組件,發現 props 越加越多的時候,可以停下來想一下:這裡是不是挖一個洞,交給使用的人決定就好?


上一篇
Vue 走過路過不要錯過 Day18 - 組件的 v-model:defineModel 讓雙向綁定變簡單
系列文
重新認識Vue 走過路過不要錯過 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言