Vue3 Composition API 核心 – defineProps() 與 defineEmits()
簡介
在 Vue 3 中,Composition API 讓我們以更靈活、可組合的方式撰寫元件邏輯。當使用 <script setup> 語法時,defineProps() 與 defineEmits() 成為與父子元件溝通的兩大入口:前者用來 接收父層傳入的屬性,後者則用來 向父層發射事件。
相較於 Vue 2 的 props、$emit,這兩個函式在編譯階段即被 Vue 處理,能提供更好的型別推斷、Tree‑shaking 效果,並讓程式碼結構更為直觀。掌握它們的使用方式,是寫好可重用、可維護的 Vue 3 元件的關鍵。
核心概念
1. defineProps() 基本用法
defineProps() 會返回一個 只讀 的物件,裡面包含了父層傳入的所有屬性。使用方式有兩種:
- 字面量型別推斷(不需要額外的 TypeScript 定義)
- 型別介面(interface)或型別別名(type),配合 TypeScript 获得更完整的型別檢查。
<script setup>
// 方式一:直接寫字面量
const props = defineProps({
title: String,
count: {
type: Number,
default: 0
}
})
// 方式二:使用 TypeScript 介面
interface CardProps {
title: string
count?: number // 可選屬性
}
const props = defineProps<CardProps>()
</script>
重點:
defineProps()回傳的props是 只讀 的,若直接修改會在開發環境拋出警告。若需要衍生出可變的資料,請使用ref或computed包裝。
2. defineEmits() 基本用法
defineEmits() 用來宣告元件會 發射哪些事件,同樣支援字面量與 TypeScript 型別兩種寫法。宣告後即可直接呼叫回傳的 emit 函式。
<script setup>
// 方式一:字面量宣告
const emit = defineEmits(['update', 'delete'])
// 方式二:型別宣告(支援參數型別檢查)
type CardEmits = {
(e: 'update', payload: number): void
(e: 'delete'): void
}
const emit = defineEmits<CardEmits>()
</script>
呼叫方式:
// 在某個事件處理器內發射
function onIncrement() {
emit('update', props.count + 1) // payload 必須符合型別
}
function onRemove() {
emit('delete')
}
提示:在
<script setup>中,emit只在本檔案可見,外部仍然是透過v-on或@綁定事件。
3. 結合 defineProps() 與 defineEmits() 的完整範例
以下是一個簡易的 計數卡片(CounterCard)元件,示範如何同時使用 defineProps() 與 defineEmits()。
<script setup>
/* Props 定義 */
interface CounterProps {
/** 卡片標題 */
title: string
/** 初始值,預設為 0 */
initial?: number
}
const props = defineProps<CounterProps>()
const count = ref(props.initial ?? 0)
/* Emits 定義 */
type CounterEmits = {
(e: 'change', newValue: number): void
}
const emit = defineEmits<CounterEmits>()
/* 方法 */
function increment() {
count.value++
emit('change', count.value) // 立即回報新值
}
function decrement() {
if (count.value > 0) {
count.value--
emit('change', count.value)
}
}
</script>
<template>
<div class="counter-card">
<h3>{{ props.title }}</h3>
<p>Count: {{ count }}</p>
<button @click="decrement">-</button>
<button @click="increment">+</button>
</div>
</template>
<style scoped>
.counter-card { border: 1px solid #ddd; padding: 1rem; border-radius: 4px; }
button { margin: 0 0.5rem; }
</style>
說明
props.title直接取自defineProps(),且因為是readonly,不會被誤改。count為本地的ref,可自由變更。- 每次計數變動,都會透過
emit('change', count.value)把最新值傳回父層,父層可用@change="handleChange"監聽。
4. 動態 Props 與 Event 名稱
有時候我們想讓元件接受 動態屬性(如 v-bind="[obj]")或 動態事件名稱(如 v-on="[eventObj]")。defineProps() 允許使用 索引簽名(index signature)來接受任意鍵值:
<script setup lang="ts">
interface AnyProps {
[key: string]: any // 接受任何屬性
}
const props = defineProps<AnyProps>()
</script>
同理,defineEmits() 也可以接受字串陣列或泛型函式簽名,讓 任意事件 都能被正確推斷:
<script setup lang="ts">
type AnyEmits = (event: string, ...args: any[]) => void
const emit = defineEmits<AnyEmits>()
emit('custom-event', { foo: 'bar' })
</script>
注意:使用索引簽名會失去編譯期的嚴格檢查,僅在真的需要「完全開放」的情況下才建議使用。
5. 在普通 <script> 中使用 defineProps / defineEmits
如果不想使用 <script setup>,仍可在普通 <script> 中透過 setup() 函式取得同樣的功能:
<script>
export default {
props: {
title: String,
value: Number
},
emits: ['update'],
setup(props, { emit }) {
// props 為只讀物件,emit 為發射函式
function change(val) {
emit('update', val)
}
return { change }
}
}
</script>
這裡的 props 與 emit 與 defineProps()、defineEmits() 的行為相同,只是寫法較為冗長。建議在新專案或新元件中優先採用 <script setup>。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
直接修改 props |
defineProps() 回傳的物件是唯讀的,直接賦值會觸發 Vue 警告。 |
使用 ref、reactive 或 computed 包裝,或在 setup 中建立本地變數。 |
忘記宣告 emits |
未在 defineEmits() 中列出事件,編譯器不會提供型別檢查,且在 v-on 時不會自動提示。 |
必須 使用 defineEmits() 明確列出所有會發射的事件。 |
| 事件名稱拼寫不一致 | 父層使用 @my-event,子層卻發射 emit('myEvent')(大小寫不同),會導致事件無法被捕獲。 |
統一使用 kebab-case(my-event)或 camelCase(myEvent),並在 defineEmits 中保持一致。 |
| Payload 型別不匹配(TS) | emit('update', 'string') 卻在 defineEmits 中宣告為 number,會在編譯階段報錯。 |
利用 TypeScript 的函式簽名或字面量陣列,讓 IDE 即時提示錯誤。 |
過度使用 any |
為了省事把所有 Props/Emits 設為 any,失去型別安全。 |
只在真的需要動態屬性時才使用索引簽名,其餘情況應明確定義每個屬性與事件。 |
最佳實踐
- 盡量使用 TypeScript:
defineProps<T>()、defineEmits<T>()能提供完整的型別推斷,減少執行時錯誤。 - 保持單一職責:每個元件的
props應只描述「外部可設定的資料」,emits只描述「外部可訂閱的行為」。不要在同一元件內混合過多的邏輯。 - 使用
readonly斷言:若真的需要在內部暫時修改props,可使用const mutable = ref(props.someKey as unknown as number),但必須確保不會影響父層狀態。 - 事件名稱使用 kebab-case:Vue 在模板中自動把
kebab-case轉為camelCase,遵守此慣例可避免不必要的錯誤。 - 在測試中驗證 Emits:使用
@vue/test-utils的emitted()方法確認事件是否正確發射,確保元件行為符合預期。
實際應用場景
1. 表單元件(Input、Select)
表單元件常需要把 使用者輸入的值 以 v-model 形式回傳父層。使用 defineProps 接收 modelValue,defineEmits 發射 update:modelValue:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
}>()
function onInput(e: Event) {
const target = e.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<template>
<input :value="props.modelValue" @input="onInput" />
</template>
2. 資料表格子元件(Row、Cell)
父層傳入每列資料 (rowData),子元件在點擊時發射 select 事件,讓父層決定如何處理:
<script setup>
const props = defineProps<{ rowData: Record<string, any> }>()
const emit = defineEmits(['select'])
function onClick() {
emit('select', props.rowData)
}
</script>
<template>
<tr @click="onClick">
<td v-for="(val, key) in props.rowData" :key="key">{{ val }}</td>
</tr>
</template>
3. 動態 UI 組件(Modal、Drawer)
Modal 元件接受 visible prop,並在點擊關閉按鈕時發射 close 事件:
<script setup>
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits(['close'])
function close() {
emit('close')
}
</script>
<template>
<Teleport to="body" v-if="props.visible">
<div class="overlay" @click.self="close">
<div class="modal">
<slot />
<button @click="close">關閉</button>
</div>
</div>
</Teleport>
</template>
這樣父層只需要 v-model:visible 或 @close 即可控制顯示與隱藏,邏輯與 UI 完全解耦。
總結
defineProps()與defineEmits()是 Vue 3<script setup>中與父層溝通的核心工具,提供 只讀 Props 與 型別安全的事件發射。- 使用 TypeScript 能最大化它們的優勢:自動補全、編譯期錯誤檢查、文件生成。
- 常見錯誤包括直接修改
props、忘記宣告emits、事件名稱不一致等,遵守最佳實踐可有效避免。 - 在表單、資料表格、彈窗等常見 UI 元件中,
defineProps/defineEmits能讓元件保持 單向資料流、高度可重用,同時提升開發效率與可維護性。
掌握這兩個 API,您就能在 Vue 3 中寫出更乾淨、可組合、且易於測試的元件,為大型專案奠定堅實的基礎。祝開發順利 🎉!