Skip to content

用 slots 分發內容

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一章的 <BookCard /> 用 props 接收書名與庫存,資料進得去,但有一種東西 props 傳起來很彆扭:畫面內容本身。這一章從這個缺口開始,把元件的組合工具一件一件補上。

props 傳不了的東西

想做一個通用的卡片外框元件:圓角、邊框、內距都固定,但卡片「裡面」要放什麼,由使用它的人決定。可能是一段文字,可能是一張圖加兩顆按鈕。

用 props 硬做,就得把內容拆成 textimageUrlbuttonLabel⋯⋯一堆欄位,而且永遠有下一個欄位。問題的根源是:props 適合傳資料,不適合傳畫面結構。Vue 為後者準備的機制是 slot:元件在模板裡留一個洞,使用者決定洞裡放什麼。

預設 slot 與 fallback 內容

子元件用 <slot> 標出洞的位置:

src/components/BaseCard.vue

vue
<template>
  <div class="card">
    <slot>暫無內容</slot>   <!-- 標籤之間是 fallback:外面沒填內容時顯示 -->
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}
</style>

父元件寫在標籤之間的內容,會被放進這個洞:

src/App.vue

vue
<script setup>
import BaseCard from './components/BaseCard.vue'
</script>

<template>
  <main class="app">
    <BaseCard>
      <h2>重新認識 Vue.js</h2>
      <p>從 Vite 到 Pinia 的現代開發指南。</p>
    </BaseCard>

    <BaseCard />   <!-- 沒填內容,顯示 fallback:「暫無內容」 -->
  </main>
</template>

<style scoped>
.app {
  display: grid;
  gap: 1rem;
  padding: 2rem;
}
</style>

第一張卡片顯示標題與介紹,第二張顯示「暫無內容」。BaseCard 不需要知道內容長什麼樣子,它只負責提供外框,這正是它可以被到處重複使用的原因。

具名 slots

一個洞不夠用的時候,可以開好幾個,用 name 區分:

src/components/BaseCard.vue

vue
<template>
  <div class="card">
    <header class="card-header">
      <slot name="header">未命名卡片</slot>
    </header>

    <slot>暫無內容</slot>   <!-- 沒有 name 的就是預設 slot -->

    <footer v-if="$slots.footer" class="card-footer">
      <slot name="footer" />   <!-- $slots.footer:沒提供內容就連外框一起省略 -->
    </footer>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}

.card-header {
  font-weight: 700;
  margin-bottom: 0.5rem;
}

.card-footer {
  margin-top: 0.75rem;
  color: #64748b;
}
</style>

父元件用 <template #名稱> 指定內容要進哪個洞:

src/App.vue

vue
<script setup>
import BaseCard from './components/BaseCard.vue'
</script>

<template>
  <main class="app">
    <BaseCard>
      <template #header>重新認識 Vue.js</template>

      <p>從 Vite 到 Pinia 的現代開發指南。</p>

      <template #footer>剩餘 8 本</template>
    </BaseCard>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

#headerv-slot:header 的縮寫,跟 @ 之於 v-on 同一個模式。沒被 <template #...> 包住的內容(範例中的 <p>),一律進預設 slot。

渲染作用域

slot 內容是寫在父元件模板裡的,所以它能用的資料,也是父元件的資料。這條規則叫渲染作用域 (render scope):內容寫在哪個元件的模板裡,就屬於哪個元件的作用域。

常見錯誤:以為 slot 內容讀得到子元件的資料

BaseCard 裡面明明有宣告資料,在 slot 內容裡卻拿不到:

vue
<script setup>
import { ref } from 'vue'
import BaseCard from './components/BaseCard.vue'

const bookTitle = ref('重新認識 Vue.js')
</script>

<template>
  <BaseCard>
    {{ bookTitle }}      <!-- 可以:這是 App.vue 自己的資料 -->
    {{ cardState }}      <!-- 錯誤:cardState 是 BaseCard 內部的資料,這裡讀不到 -->
  </BaseCard>
</template>

這段 slot 內容寫在 App.vue 的模板裡,作用域就是 App.vueBaseCard 內部的變數對它來說根本不存在。如果子元件想把自己的資料交給 slot 內容使用,要靠本節最後的 scoped slots。

slot 的樣式作用域

渲染作用域管的是資料,樣式也有一條對應的規則:slot 內容是父元件建立的,所以屬於父元件的樣式作用域。上一章學過的 <style scoped> 在這裡多了一層細節:

  • 子元件的 <style scoped> 預設只套用到子元件自己建立的元素,管不到父元件傳進來的 slot 內容。
  • 子元件若刻意要替 slot 內容設計樣式,用 :slotted() 選擇器明確聲明。

src/components/BaseCard.vue

vue
<template>
  <div class="card">
    <slot />
  </div>
</template>

<style scoped>
/* 預設只套用 BaseCard 自己建立的元素 */
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}

/* 明確套用父元件傳進來的 slot 內容 */
:slotted(.book-title) {
  color: #2563eb;
}
</style>

父元件的 slot 內容裡若有 class="book-title" 的元素,就會吃到這條藍色樣式;沒有 :slotted() 的其他規則,slot 內容一概不受影響。

也提醒一句:這些細節都建立在「有加 scoped」的前提上。沒加 scoped<style> 依然是全域 CSS,slot 內容、其他元件、其他頁面照污染不誤,上一章的規則在這裡沒有例外。

scoped slots:子元件把資料交還給你

先解決一個容易混淆的命名:接下來的 scoped slots 跟剛剛的 <style scoped> 毫無關係,一個講資料、一個講樣式,只是名字都有 scoped。

有時候分工是反過來的:資料在子元件手上,畫面長相由父元件決定。例如一個書單元件,它負責管理書籍資料,但每一本書要怎麼呈現,交給使用它的人。

子元件在 <slot> 上綁定屬性,把資料傳出去:

src/components/BookList.vue

vue
<script setup>
import { ref } from 'vue'

const books = ref([
  { id: 1, title: '重新認識 Vue.js', stock: 8 },
  { id: 2, title: '008 天絕對看不完', stock: 0 }
])
</script>

<template>
  <ul class="book-list">
    <li v-for="book in books" :key="book.id">
      <slot :book="book" />   <!-- 把每一本書交給 slot 內容 -->
    </li>
  </ul>
</template>

<style scoped>
.book-list {
  padding-left: 1.25rem;
  display: grid;
  gap: 0.5rem;
}
</style>

父元件用 v-slot 接住這些資料,再決定怎麼渲染:

src/App.vue

vue
<script setup>
import BookList from './components/BookList.vue'
</script>

<template>
  <main class="app">
    <BookList v-slot="{ book }">
      <strong>{{ book.title }}</strong>
      <span v-if="book.stock === 0">(已售完)</span>
    </BookList>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

v-slot="{ book }" 解構出子元件綁在 <slot> 上的 book。渲染作用域的規則沒有被打破:slot 內容仍然屬於父元件,只是子元件明確地把一份資料「遞」了過來,就像函式的參數。

這個模式的價值在於分工:BookList 擁有資料與列表邏輯,呈現方式完全開放。同一個 BookList,這個頁面渲染成純文字清單,另一個頁面渲染成圖文卡片,資料層完全不用動。第五章講 composables 時,各位讀者會再看到「邏輯歸邏輯、畫面歸畫面」這個思路以另一種形式出現。

內容的洞補完了。不過元件之間要溝通的不只內容,還有資料:當資料要跨過好幾層元件傳遞時,props 會開始讓人疲勞,這就是下一節 provide 與 inject 要解決的問題。