Vue3 元件通信:Props & Emit 完全指南
簡介
在 Vue3 專案中,元件(Component) 是打造可重用 UI 的基礎單位。無論是簡單的表單、複雜的儀表板,還是跨頁面的共享狀態,最常見的資訊流都是 父子元件之間的傳遞。props 與 emit 正是 Vue 官方推薦的 單向資料流 解法:父層把資料以 props 傳給子層,子層若需要回報事件或變更,則透過 emit 向父層發射事件。這樣的設計不僅讓資料流向清晰、易於追蹤,也能避免不必要的耦合,提升維護性與測試性。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用場景,完整呈現 props / emit 在 Vue3 中的使用方式,協助初學者快速上手,也讓中階開發者能在大型專案中穩健運用。
核心概念
1. Props:父層向子層單向傳遞資料
- 宣告:子元件必須在
props選項中明確列出可接受的屬性,並可設定型別、預設值與驗證。 - 單向:父層傳入的資料是只讀的,子層不應直接修改
props,若需要本地化的變更,應先在子層建立本地狀態(ref/reactive)再操作。
// ChildComponent.vue
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
// 必填的字串型別
title: {
type: String,
required: true
},
// 帶預設值的數字型別
count: {
type: Number,
default: 0
}
})
</script>
<template>
<h3>{{ title }}</h3>
<p>目前計數:{{ count }}</p>
</template>
// ParentComponent.vue
<script setup>
import ChildComponent from './ChildComponent.vue'
</script>
<template>
<ChildComponent title="計數器" :count="parentCount" />
</template>
小技巧:使用
:count="parentCount"時,:代表 綁定,確保傳遞的是數值而非字串。
2. Emit:子層向父層回報事件
- 發射事件:子元件使用
emit方法向父層傳遞訊息,父層在使用子元件時以@事件名監聽。 - 事件命名:建議使用 kebab-case(例如
update-count)或 camelCase(在模板中自動轉換)保持一致性。
// ChildComponent.vue
<script setup>
import { defineEmits, ref } from 'vue'
const emit = defineEmits(['update-count'])
const localCount = ref(0)
function increase() {
localCount.value++
// 向父層發射事件,攜帶最新的計數值
emit('update-count', localCount.value)
}
</script>
<template>
<button @click="increase">+1</button>
</template>
// ParentComponent.vue
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentCount = ref(0)
function handleUpdate(newCount) {
parentCount.value = newCount
}
</script>
<template>
<p>父層顯示的計數:{{ parentCount }}</p>
<ChildComponent @update-count="handleUpdate" />
</template>
注意:
emit的第一個參數是事件名稱,後面的參數會依序傳給監聽器的回呼函式。
3. Props 與 Emit 的配合:v-model 的底層原理
Vue3 內建的 v-model 其實是 props + emit 的語法糖。預設情況下,v-model 會把父層的資料綁定到子層的 modelValue prop,並在子層 emit('update:modelValue', newValue) 時同步回父層。
// MyInput.vue
<script setup>
import { defineProps, defineEmits, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const input = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
input.value = val
})
function onInput(e) {
emit('update:modelValue', e.target.value)
}
</script>
<template>
<input :value="input" @input="onInput" />
</template>
// Parent.vue
<template>
<MyInput v-model="username" />
<p>輸入的名稱:{{ username }}</p>
</template>
<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'
const username = ref('')
</script>
重點:若要自訂
v-model的 prop 與事件名稱,只需要在子元件defineProps中使用modelValue以外的名稱,並在defineEmits中使用update:自訂名稱,父層則使用v-model:自訂名稱。
4. 多層傳遞:從祖父元件到孫子元件
雖然 props 可以層層傳遞,但過深的嵌套會使程式碼難以維護。此時可以考慮 provide / inject 或 pinia 等全域狀態管理,但在大多數情況下,只在需要時才使用深層傳遞,保持單向資料流的原則。
// GrandParent.vue
<template>
<Parent :msg="greeting" @reply="handleReply" />
</template>
<script setup>
import { ref } from 'vue'
import Parent from './Parent.vue'
const greeting = ref('哈囉!')
function handleReply(payload) {
console.log('GrandParent 收到回覆', payload)
}
</script>
// Parent.vue
<template>
<Child :msg="msg" @reply="forwardReply" />
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import Child from './Child.vue'
const props = defineProps(['msg'])
const emit = defineEmits(['reply'])
function forwardReply(payload) {
// 直接把子層事件往上層傳遞
emit('reply', payload)
}
</script>
// Child.vue
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps(['msg'])
const emit = defineEmits(['reply'])
function sendReply() {
emit('reply', { text: '收到!', from: 'Child' })
}
</script>
<template>
<p>父層傳來的訊息:{{ msg }}</p>
<button @click="sendReply">回覆</button>
</template>
常見陷阱與最佳實踐
| 陷阱 | 可能的問題 | 解決方案 / 最佳實踐 |
|---|---|---|
| 直接修改 Props | 會觸發 Vue 警告,且破壞單向資料流 | 在子元件內使用 ref 或 computed 產生本地副本,再操作副本 |
| 事件名稱不一致 | 父層監聽不到子層發射的事件 | 統一使用 kebab-case(update-count)或在 TypeScript 中使用 as const 定義事件字串 |
| 過度傳遞多層 Props | 元件樹過深,維護成本提升 | 考慮使用 provide/inject、Pinia 或 Vuex,僅在必要時傳遞 |
忘記在父層使用 .sync 或 v-model |
子元件發射的更新無法同步回父層 | 直接在父層寫 @update:prop="handler" 或使用 v-model:prop |
| 事件參數過多 | 事件回呼函式變得難以閱讀 | 只傳遞必要的資料,若需要多個值可包成物件 { foo, bar } |
未使用 defineEmits (Composition API) |
失去編譯時類型檢查,容易拼寫錯誤 | 在 <script setup> 中使用 defineEmits(['eventName']),配合 IDE 提示 |
最佳實踐摘要
- Props 必須明確宣告:使用
type、required、default,讓組件自說明。 - 永遠不要直接改變 Props:若需要本地化修改,先
const local = ref(props.xxx)。 - 事件名稱保持語意化:例如
update-count、delete-item,能讓閱讀者快速了解行為。 - 利用
v-model簡化雙向綁定:適用於表單元件或需要同步父子狀態的情境。 - 適度使用 Provide/Inject:只在跨多層傳遞且不頻繁變更的資料時使用。
實際應用場景
1. 表單元件的雙向綁定
自訂 SelectBox、DatePicker 等表單元件時,使用 v-model(背後的 props + emit)能讓父層像原生 <input> 一樣簡潔。
2. 列表項目與父層狀態同步
在商品清單中,每個項目都有「加入購物車」按鈕。子項目透過 emit('add-to-cart', productId) 向父層回報,父層再更新全域購物車狀態(可配合 Pinia)。
3. 動態表格的欄位排序
父層提供資料陣列與排序方式(sortKey、sortOrder)作為 props,子表格元件在表頭點擊時 emit('update:sortKey', newKey),父層即重新計算排序後的資料。
4. 多步驟表單(Wizard)
每一步都是子元件,父層持有整體表單資料。子元件在完成本步驟時 emit('next', stepData),父層合併資料並切換至下一步。
總結
- Props 與 Emit 是 Vue3 中最核心的 單向資料流 機制,讓父子元件之間的溝通既直觀又可預測。
- 正確宣告
props、避免直接修改、使用defineEmits、以及遵循一致的事件命名規則,是避免常見錯誤的關鍵。 - 在需要雙向綁定時,
v-model提供了簡潔的語法糖;而在跨多層傳遞時,適度採用provide/inject或全域狀態管理工具,可提升可維護性。 - 透過上述概念與實作範例,你可以快速在 Vue3 專案中建立清晰的元件通信,從簡單的表單元件到複雜的多步驟流程,都能保持程式碼的可讀性與可測試性。
實務建議:在開發新元件前,先思考它的 資料來源(props)與 回饋方式(emit),再決定是否需要
v-model或其他狀態管理手段。這樣的先行設計,將讓你的 Vue 應用在成長過程中更具彈性與可維護性。