Vue Slots 插槽
插槽(Slots)是 Vue 元件的一個強大功能,它讓父元件可以傳遞模板內容給子元件,使元件更加靈活和可重用。
預設插槽(Default Slot)
最基本的插槽用法:
<!-- AlertBox.vue -->
<template>
<div class="alert-box">
<strong>注意!</strong>
<!-- 插槽出口:父元件傳入的內容會顯示在這裡 -->
<slot></slot>
</div>
</template>
<style scoped>
.alert-box {
padding: 15px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
</style>
使用元件時,標籤之間的內容會被插入到 <slot> 的位置:
<template>
<AlertBox>
這是一則重要的警告訊息!
</AlertBox>
</template>
渲染結果:
<div class="alert-box">
<strong>注意!</strong>
這是一則重要的警告訊息!
</div>
預設內容(Fallback Content)
可以為插槽指定預設內容,當父元件沒有提供內容時顯示:
<!-- SubmitButton.vue -->
<template>
<button type="submit">
<slot>送出</slot> <!-- 預設顯示「送出」 -->
</button>
</template>
<template>
<!-- 使用預設內容 -->
<SubmitButton /> <!-- 顯示:送出 -->
<!-- 自訂內容 -->
<SubmitButton>確認訂單</SubmitButton> <!-- 顯示:確認訂單 -->
</template>
具名插槽(Named Slots)
當元件需要多個插槽時,可以使用具名插槽來區分:
<!-- BaseLayout.vue -->
<template>
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 預設插槽 -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
使用 v-slot 指令(簡寫 #)來指定內容要放到哪個插槽:
<template>
<BaseLayout>
<!-- 具名插槽使用 template + v-slot -->
<template v-slot:header>
<h1>網站標題</h1>
</template>
<!-- 預設插槽內容 -->
<p>這是主要內容</p>
<p>這也是主要內容</p>
<!-- 簡寫語法 # -->
<template #footer>
<p>版權所有 © 2024</p>
</template>
</BaseLayout>
</template>
預設插槽的顯式寫法
當同時使用具名插槽和預設插槽時,預設插槽也可以顯式寫出:
<template>
<BaseLayout>
<template #header>
<h1>標題</h1>
</template>
<!-- 預設插槽也可以用 #default -->
<template #default>
<p>主要內容</p>
</template>
<template #footer>
<p>頁尾</p>
</template>
</BaseLayout>
</template>
作用域插槽(Scoped Slots)
有時候插槽內容需要存取子元件的資料。作用域插槽讓子元件可以將資料傳遞給插槽:
<!-- TodoList.vue -->
<script setup>
defineProps({
items: Array
})
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<!-- 將 item 傳遞給插槽 -->
<slot :item="item" :index="index">
<!-- 預設內容 -->
{{ item.text }}
</slot>
</li>
</ul>
</template>
父元件透過 v-slot 接收資料:
<script setup>
import { ref } from 'vue'
import TodoList from './TodoList.vue'
const todos = ref([
{ id: 1, text: '學習 Vue', done: true },
{ id: 2, text: '寫專案', done: false }
])
</script>
<template>
<TodoList :items="todos">
<!-- 接收子元件傳來的 item -->
<template #default="{ item }">
<span :class="{ done: item.done }">{{ item.text }}</span>
<button @click="item.done = !item.done">
{{ item.done ? '取消' : '完成' }}
</button>
</template>
</TodoList>
</template>
解構插槽 Props
可以直接在 v-slot 中解構:
<template>
<TodoList :items="todos">
<!-- 解構並重新命名 -->
<template #default="{ item: todo, index: i }">
{{ i + 1 }}. {{ todo.text }}
</template>
</TodoList>
</template>
具名作用域插槽
<!-- UserCard.vue -->
<script setup>
defineProps({
user: Object
})
</script>
<template>
<div class="user-card">
<header>
<slot name="header" :user="user">
<h2>{{ user.name }}</h2>
</slot>
</header>
<main>
<slot :user="user">
<p>{{ user.bio }}</p>
</slot>
</main>
<footer>
<slot name="footer" :user="user">
<span>{{ user.email }}</span>
</slot>
</footer>
</div>
</template>
<template>
<UserCard :user="user">
<template #header="{ user }">
<h2>👤 {{ user.name }}</h2>
</template>
<template #default="{ user }">
<p><strong>簡介:</strong>{{ user.bio }}</p>
<p><strong>加入日期:</strong>{{ user.joinDate }}</p>
</template>
<template #footer="{ user }">
<a :href="`mailto:${user.email}`">📧 聯絡我</a>
</template>
</UserCard>
</template>
條件插槽
可以使用 $slots 來檢查插槽是否有內容:
<!-- Card.vue -->
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
</script>
<template>
<div class="card">
<!-- 只有當 header 插槽有內容時才渲染 -->
<header v-if="slots.header" class="card-header">
<slot name="header"></slot>
</header>
<main class="card-body">
<slot></slot>
</main>
<footer v-if="slots.footer" class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
動態插槽名稱
插槽名稱可以是動態的:
<script setup>
import { ref } from 'vue'
const currentSlot = ref('header')
</script>
<template>
<BaseLayout>
<template #[currentSlot]>
<p>動態插入到 {{ currentSlot }} 插槽</p>
</template>
</BaseLayout>
</template>
實際範例
表格元件
<!-- DataTable.vue -->
<script setup>
defineProps({
columns: Array,
data: Array
})
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<slot :name="`header-${col.key}`" :column="col">
{{ col.title }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="row.id || index">
<td v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :index="index">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
}
</style>
使用:
<script setup>
import { ref } from 'vue'
import DataTable from './DataTable.vue'
const columns = [
{ key: 'name', title: '姓名' },
{ key: 'status', title: '狀態' },
{ key: 'actions', title: '操作' }
]
const users = ref([
{ id: 1, name: 'Alice', status: 'active' },
{ id: 2, name: 'Bob', status: 'inactive' }
])
function deleteUser(id) {
users.value = users.value.filter(u => u.id !== id)
}
</script>
<template>
<DataTable :columns="columns" :data="users">
<!-- 自訂狀態欄位 -->
<template #cell-status="{ value }">
<span :class="['badge', value]">
{{ value === 'active' ? '啟用' : '停用' }}
</span>
</template>
<!-- 自訂操作欄位 -->
<template #cell-actions="{ row }">
<button @click="editUser(row)">編輯</button>
<button @click="deleteUser(row.id)">刪除</button>
</template>
</DataTable>
</template>
<style>
.badge {
padding: 4px 8px;
border-radius: 4px;
}
.badge.active { background: #d4edda; color: #155724; }
.badge.inactive { background: #f8d7da; color: #721c24; }
</style>
Modal 對話框
<!-- Modal.vue -->
<script setup>
import { useSlots } from 'vue'
defineProps({
visible: Boolean,
title: String
})
const emit = defineEmits(['close'])
const slots = useSlots()
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<header class="modal-header">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button class="close-btn" @click="emit('close')">×</button>
</header>
<main class="modal-body">
<slot></slot>
</main>
<footer v-if="slots.footer" class="modal-footer">
<slot name="footer"></slot>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.modal {
background: white;
border-radius: 8px;
min-width: 400px;
max-width: 90vw;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #eee;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 16px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
</style>
使用:
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const showModal = ref(false)
function handleConfirm() {
console.log('確認')
showModal.value = false
}
</script>
<template>
<button @click="showModal = true">開啟對話框</button>
<Modal :visible="showModal" title="確認刪除" @close="showModal = false">
<p>確定要刪除這個項目嗎?此操作無法復原。</p>
<template #footer>
<button @click="showModal = false">取消</button>
<button class="danger" @click="handleConfirm">刪除</button>
</template>
</Modal>
</template>
列表元件(無限靈活)
<!-- RenderList.vue -->
<script setup>
defineProps({
items: {
type: Array,
required: true
},
keyField: {
type: String,
default: 'id'
}
})
</script>
<template>
<div class="list">
<slot name="header"></slot>
<div v-if="items.length === 0" class="empty">
<slot name="empty">
<p>沒有資料</p>
</slot>
</div>
<template v-else>
<div
v-for="(item, index) in items"
:key="item[keyField] || index"
class="list-item"
>
<slot :item="item" :index="index">
{{ item }}
</slot>
</div>
</template>
<slot name="footer"></slot>
</div>
</template>
使用:
<template>
<!-- 簡單使用 -->
<RenderList :items="names">
<template #default="{ item }">
{{ item }}
</template>
</RenderList>
<!-- 複雜使用 -->
<RenderList :items="products" key-field="id">
<template #header>
<h2>商品列表</h2>
</template>
<template #default="{ item, index }">
<div class="product">
<span>{{ index + 1 }}.</span>
<strong>{{ item.name }}</strong>
<span>${{ item.price }}</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<p>🛒 購物車是空的</p>
<button>去購物</button>
</div>
</template>
<template #footer>
<p>共 {{ products.length }} 件商品</p>
</template>
</RenderList>
</template>
插槽最佳實踐
使用有意義的插槽名稱:如
header、footer、title而不是slot1、slot2提供預設內容:讓元件在沒有插槽內容時也能正常顯示
文件化插槽:記錄每個插槽的用途和可用的 props
不要過度使用:太多插槽會讓元件難以理解和使用
考慮使用 renderless 元件:如果元件主要是邏輯而非 UI,可以完全由插槽控制渲染