Appearance
用 slots 分發內容
上一章的 <BookCard /> 用 props 接收書名與庫存,資料進得去,但有一種東西 props 傳起來很彆扭:畫面內容本身。這一章從這個缺口開始,把元件的組合工具一件一件補上。
props 傳不了的東西
想做一個通用的卡片外框元件:圓角、邊框、內距都固定,但卡片「裡面」要放什麼,由使用它的人決定。可能是一段文字,可能是一張圖加兩顆按鈕。
用 props 硬做,就得把內容拆成 text、imageUrl、buttonLabel⋯⋯一堆欄位,而且永遠有下一個欄位。問題的根源是: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>#header 是 v-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.vue;BaseCard 內部的變數對它來說根本不存在。如果子元件想把自己的資料交給 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 要解決的問題。
