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.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>

插槽最佳實踐

  1. 使用有意義的插槽名稱:如 headerfootertitle 而不是 slot1slot2

  2. 提供預設內容:讓元件在沒有插槽內容時也能正常顯示

  3. 文件化插槽:記錄每個插槽的用途和可用的 props

  4. 不要過度使用:太多插槽會讓元件難以理解和使用

  5. 考慮使用 renderless 元件:如果元件主要是邏輯而非 UI,可以完全由插槽控制渲染