🧩 Vue 元件系統:Props、Emit 與插槽
📌 為什麼要拆元件?
元件就像樂高積木——把大型 UI 拆成可重複使用的小塊。
App.vue
├── Header.vue (導航列)
├── Sidebar.vue (側邊欄)
├── MainContent.vue (主要內容)
│ ├── ArticleCard.vue (文章卡片 × N)
│ └── Pagination.vue (分頁)
└── Footer.vue (頁尾)
不拆元件 vs 拆元件
<!-- ❌ 不拆元件:全部寫在一個檔案,500+ 行 -->
<template>
<div>
<nav>...</nav> <!-- 50 行 -->
<aside>...</aside> <!-- 80 行 -->
<main>...</main> <!-- 200 行 -->
<footer>...</footer> <!-- 30 行 -->
</div>
</template>
<!-- ✅ 拆元件:每個檔案只負責一個功能 -->
<template>
<div>
<AppHeader />
<AppSidebar />
<MainContent />
<AppFooter />
</div>
</template>
📌 defineProps — 父傳子
父元件透過屬性 (props) 把資料傳給子元件。
子元件:UserCard.vue
<template>
<div class="card">
<h3>{{ name }}</h3>
<p>年齡:{{ age }}</p>
<p v-if="email">信箱:{{ email }}</p>
<span :class="['badge', levelClass]">{{ level }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
// defineProps 是 Vue 的編譯器巨集 (compiler macro)
// 不需要 import!(這也不是 JS 原生語法)
const props = defineProps({
name: {
type: String,
required: true
},
age: {
type: Number,
default: 0
},
email: {
type: String,
default: ''
},
level: {
type: String,
default: 'beginner',
validator: (value) => ['beginner', 'intermediate', 'advanced'].includes(value)
}
})
const levelClass = computed(() => {
return {
beginner: 'bg-green',
intermediate: 'bg-blue',
advanced: 'bg-red'
}[props.level]
})
</script>
父元件使用
<template>
<div>
<!-- 靜態 props -->
<UserCard name="小明" :age="25" email="ming@example.com" />
<!-- 動態 props -->
<UserCard
v-for="user in users"
:key="user.id"
:name="user.name"
:age="user.age"
:level="user.level"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'
const users = ref([
{ id: 1, name: '小明', age: 25, level: 'beginner' },
{ id: 2, name: '小華', age: 30, level: 'intermediate' },
{ id: 3, name: '小美', age: 28, level: 'advanced' }
])
</script>
📌 defineEmits — 子傳父
子元件透過事件 (emit) 把資料傳回父元件。
子元件:SearchBox.vue
<template>
<div class="search-box">
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
placeholder="搜尋..."
/>
<button @click="handleSearch">搜尋</button>
<button @click="$emit('clear')">清除</button>
</div>
</template>
<script setup>
// defineEmits 也是 Vue 編譯器巨集
const emit = defineEmits(['update:modelValue', 'search', 'clear'])
const props = defineProps({
modelValue: { type: String, default: '' }
})
function handleSearch() {
// 觸發自訂事件,把搜尋關鍵字傳給父元件
emit('search', props.modelValue)
}
</script>
父元件使用
<template>
<div>
<!-- v-model 是 :modelValue + @update:modelValue 的語法糖 -->
<SearchBox
v-model="keyword"
@search="onSearch"
@clear="keyword = ''"
/>
<p>目前關鍵字:{{ keyword }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const keyword = ref('')
function onSearch(query) {
console.log('搜尋:', query)
// 呼叫 API...
}
</script>
📌 Slot 插槽 — 內容分發
插槽讓父元件可以「塞內容」到子元件裡。
基本插槽
<!-- Card.vue(子元件) -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">預設標題</slot>
</div>
<div class="card-body">
<slot>預設內容</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<!-- 父元件使用 -->
<template>
<Card>
<template #header>
<h2>自訂標題</h2>
</template>
<!-- 預設插槽 -->
<p>這是卡片的主要內容</p>
<template #footer>
<button>確認</button>
<button>取消</button>
</template>
</Card>
</template>
作用域插槽 (Scoped Slots)
<!-- DataList.vue(子元件) -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<!-- 把 item 傳給父元件的插槽 -->
<slot :item="item" :index="items.indexOf(item)">
{{ item.name }}
</slot>
</li>
</ul>
</template>
<script setup>
defineProps({
items: { type: Array, required: true }
})
</script>
<!-- 父元件使用 -->
<template>
<DataList :items="products">
<!-- 透過 v-slot 接收子元件傳來的資料 -->
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }} — ${{ item.price }}</span>
</template>
</DataList>
</template>
📌 動態元件 & keep-alive
<template>
<div>
<!-- 頁籤切換 -->
<button
v-for="tab in tabs"
:key="tab"
@click="currentTab = tab"
:class="{ active: currentTab === tab }"
>
{{ tab }}
</button>
<!-- component :is 動態切換元件 -->
<!-- keep-alive 保留元件狀態(不會被銷毀) -->
<keep-alive>
<component :is="tabComponents[currentTab]" />
</keep-alive>
</div>
</template>
<script setup>
import { ref, shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabProfile from './TabProfile.vue'
import TabSettings from './TabSettings.vue'
const tabs = ['首頁', '個人檔案', '設定']
const currentTab = ref('首頁')
const tabComponents = {
'首頁': TabHome,
'個人檔案': TabProfile,
'設定': TabSettings
}
</script>
📌 實作範例:可重用的表單元件
<!-- FormInput.vue -->
<template>
<div class="form-group">
<label :for="id">{{ label }}</label>
<input
:id="id"
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
:class="{ error: errorMessage }"
/>
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: '' },
label: { type: String, required: true },
type: { type: String, default: 'text' },
placeholder: { type: String, default: '' },
id: { type: String, default: () => `input-${Math.random().toString(36).slice(2)}` },
errorMessage: { type: String, default: '' }
})
defineEmits(['update:modelValue'])
</script>
<!-- 使用 -->
<template>
<form @submit.prevent="submitForm">
<FormInput v-model="form.name" label="姓名" placeholder="請輸入姓名" />
<FormInput v-model="form.email" label="Email" type="email"
:error-message="errors.email" />
<FormInput v-model="form.password" label="密碼" type="password" />
<button type="submit">送出</button>
</form>
</template>
💡 常見陷阱
- ❌ 直接修改 props → 改用 emit 通知父元件
- ❌ 忘記加
:key→ 動態列表一定要有唯一 key - ❌ 把所有東西放同一個元件 → 超過 200 行就該考慮拆分
- ✅
defineProps和defineEmits不需要 import(編譯器巨集)