Vue3 元件基礎:emit 事件回傳與參數
簡介
在 Vue3 的組件化開發流程中,父子元件之間的 資料傳遞 是最常見也最重要的需求之一。雖然 props 讓父層可以把資料「推」給子層,但若要讓子層把使用者的操作或計算結果「回傳」給父層,就必須透過 自訂事件 (emit) 來完成。
emit 不僅是單純的訊號,還可以攜帶 任意數量與型別的參數,讓父層可以依據這些資訊執行對應的邏輯。掌握 emit 的正確寫法與最佳實踐,能讓你的 Vue 專案在可讀性、維護性與擴充性上都有大幅提升。
本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整呈現 Vue3 中 emit 事件回傳與參數 的全貌,適合初學者快速上手,也能讓中級開發者檢視自己的寫法是否符合最佳實踐。
核心概念
1. 為什麼需要 emit?
- 單向資料流:Vue 推崇「父傳子、子回傳」的單向資料流,
props用於「向下」傳遞,emit用於「向上」回傳。 - 解耦合:子元件不需要知道父元件的實作細節,只要觸發約定好的事件名稱即可。
- 可重用:同一個子元件可以在多個父元件中使用,只要父元件監聽相同的事件,就能得到相同的回傳資料。
2. 基本語法
在 子元件 中使用 defineEmits(Composition API)或 $emit(Options API)來宣告與觸發事件:
// Composition API
import { defineComponent, defineEmits } from 'vue'
export default defineComponent({
setup(_, { emit }) {
const clickHandler = () => {
// 觸發名為 'submit' 的事件,並傳遞兩個參數
emit('submit', { name: 'Alice' }, 42)
}
return { clickHandler }
}
})
// Options API
export default {
methods: {
clickHandler() {
// 觸發 'submit' 事件
this.$emit('submit', { name: 'Bob' }, 42)
}
}
}
在 父元件 中使用 v-on(或簡寫 @)監聽:
<ChildComponent @submit="handleSubmit" />
export default {
methods: {
handleSubmit(payload, count) {
console.log('收到子元件回傳:', payload, count)
}
}
}
3. 事件名稱的命名慣例
| 命名風格 | 推薦寫法 | 說明 |
|---|---|---|
| kebab-case | @my-event |
Vue 在模板中會自動將 kebab-case 轉成 camelCase,最常見且兼容 HTML 標準。 |
| camelCase | @myEvent |
只能在 JSX 或 render function 中使用。 |
| PascalCase | @MyEvent |
不建議,易與元件名稱混淆。 |
建議:在模板裡統一使用
kebab-case,保持可讀性與一致性。
4. 參數傳遞的技巧
- 單一物件:把多個欄位包在一個物件裡,讓 API 更具可擴充性。
emit('update', { id: 1, title: '新標題', completed: false }) - 多參數:若事件意義明確且參數固定,可直接傳多個參數。
emit('page-change', newPage, pageSize) - 回傳 Promise:子元件也可以透過
emit回傳 Promise,讓父元件使用await處理非同步流程。// 子元件 emit('save', formData, (resolve, reject) => { // 這裡是子元件內部的非同步操作 })
5. TypeScript 中的型別定義
使用 defineEmits 時可以提供型別,讓 IDE 具備自動補全與錯誤檢查:
import { defineComponent, defineEmits } from 'vue'
type SubmitPayload = { name: string; age: number }
export default defineComponent({
emits: {
// 事件名稱: 參數驗證函式(可省略,僅作型別宣告)
submit: (payload: SubmitPayload, count: number) => true
},
setup(_, { emit }) {
const send = () => {
const data: SubmitPayload = { name: 'Cathy', age: 28 }
emit('submit', data, 1)
}
return { send }
}
})
程式碼範例
下面提供 5 個實用範例,逐步展示 emit 的不同寫法與應用情境。每個範例皆附上說明與常見注意事項。
範例 1:最簡單的按鈕點擊回傳
<!-- Parent.vue -->
<template>
<ChildBtn @clicked="onChildClicked" />
</template>
<script setup>
import ChildBtn from './ChildBtn.vue'
function onChildClicked() {
alert('子元件的按鈕被點了!')
}
</script>
// ChildBtn.vue (Composition API)
<script setup>
import { defineEmits } from 'vue'
const emit = defineEmits(['clicked'])
function handleClick() {
// 只傳遞事件名稱,無參數
emit('clicked')
}
</script>
<template>
<button @click="handleClick">點我</button>
</template>
重點:即使不帶參數,也必須在
defineEmits中列出事件名稱,讓 Vue 能正確檢查。
範例 2:傳遞單一物件作為參數
<!-- Parent.vue -->
<template>
<UserForm @submit="handleFormSubmit" />
</template>
<script setup>
import UserForm from './UserForm.vue'
function handleFormSubmit(data) {
console.log('表單資料:', data)
}
</script>
// UserForm.vue (Options API)
<template>
<form @submit.prevent="onSubmit">
<input v-model="name" placeholder="姓名" />
<input v-model.number="age" type="number" placeholder="年齡" />
<button type="submit">送出</button>
</form>
</template>
<script>
export default {
data() {
return { name: '', age: null }
},
methods: {
onSubmit() {
// 把所有欄位包成一個物件
this.$emit('submit', { name: this.name, age: this.age })
}
}
}
</script>
技巧:使用 單一物件 可以在未來新增欄位時,只要在子元件內部補上屬性,父層的接收程式碼不需要變更。
範例 3:多參數 + 型別驗證(Composition API + TypeScript)
<!-- Parent.vue -->
<template>
<Pagination @page-change="onPageChange" />
</template>
<script setup lang="ts">
import Pagination from './Pagination.vue'
function onPageChange(page: number, size: number) {
console.log(`切換至第 ${page} 頁,每頁 ${size} 筆`)
}
</script>
// Pagination.vue
<script setup lang="ts">
import { defineEmits, ref } from 'vue'
const emit = defineEmits<{
(e: 'page-change', page: number, size: number): void
}>()
const currentPage = ref(1)
const pageSize = ref(10)
function goTo(page: number) {
currentPage.value = page
emit('page-change', page, pageSize.value)
}
</script>
<template>
<button @click="goTo(currentPage - 1)" :disabled="currentPage === 1">上一頁</button>
<span>第 {{ currentPage }} 頁</span>
<button @click="goTo(currentPage + 1)">下一頁</button>
</template>
重點:使用
defineEmits的泛型寫法,可在編譯階段捕捉錯誤,避免傳錯參數型別。
範例 4:非同步回傳(Promise)
<!-- Parent.vue -->
<template>
<AsyncUploader @upload="handleUpload" />
</template>
<script setup>
import AsyncUploader from './AsyncUploader.vue'
async function handleUpload(file) {
try {
const url = await uploadToServer(file) // 假設有此 API
console.log('上傳成功,檔案網址:', url)
} catch (e) {
console.error('上傳失敗', e)
}
}
</script>
// AsyncUploader.vue
<script setup>
import { defineEmits } from 'vue'
const emit = defineEmits(['upload'])
function onFileChange(event) {
const file = event.target.files[0]
// 直接把檔案物件 emit 給父層,父層自行處理上傳
emit('upload', file)
}
</script>
<template>
<input type="file" @change="onFileChange" />
</template>
說明:
emit本身不會回傳 Promise,這裡的非同步處理是 父層 完成的。子層只負責 傳遞資料,保持職責單一。
範例 5:使用 v-model 自訂事件(雙向綁定)
Vue3 允許自訂 v-model 的 modelValue 與 update:modelValue 事件,實際上就是 emit 的應用:
<!-- Parent.vue -->
<template>
<CustomSwitch v-model="isOn" />
<p>開關狀態:{{ isOn }}</p>
</template>
<script setup>
import CustomSwitch from './CustomSwitch.vue'
import { ref } from 'vue'
const isOn = ref(false)
</script>
// CustomSwitch.vue
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
function toggle() {
emit('update:modelValue', !props.modelValue)
}
</script>
<template>
<button @click="toggle">
{{ props.modelValue ? 'ON' : 'OFF' }}
</button>
</template>
關鍵:
v-model背後等同於:modelValue="..." @update:modelValue="...",只要正確emitupdate:modelValue,雙向綁定即可順利運作。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記在 defineEmits / emits 中宣告事件 |
Vue 會在開發模式下警告「未宣告的事件」 | 在子元件的 emits(Options API)或 defineEmits(Composition API)裡列出所有會被觸發的事件名稱 |
| 事件名稱大小寫不一致 | 在模板使用 kebab-case,在 JS 使用 camelCase,若混用會導致監聽失敗 |
統一:模板寫 @my-event,JS 中 emit('myEvent') |
| 過度傳遞多個參數 | 父層接收者需要記住參數順序,易出錯且不易維護 | 優先使用單一物件,將相關資訊封裝;若多參數是固定且語意清晰,才使用 |
在子元件內部直接修改 props |
props 為只讀,會觸發 Vue 警告 |
若需要本地化修改,先 const local = ref(props.xxx),或使用 emit 回傳變更 |
忘記 event.preventDefault() |
在表單或按鈕事件裡未阻止預設行為,會導致頁面重新載入 | 在模板上使用 @submit.prevent 或在方法內手動 event.preventDefault() |
| 父層忘記監聽事件 | 子元件 emit 正常,但父層無回應 |
確認父層模板有正確的 @event-name="handler",或檢查是否使用了 v-on="..." 的 動態事件 |
| 非同步錯誤未捕獲 | 父層在 await emit(其實不會)或在回調中拋錯,會導致未處理的例外 |
父層處理非同步時自行 try/catch,子元件僅負責 emit,不必包裝 Promise |
最佳實踐
- 明確的事件命名:使用動詞開頭,如
update,delete,submit,讓事件意圖一目了然。 - 一次只傳遞一個「意圖」:若要回傳「成功」與「失敗」兩種結果,建議分別使用
success、error兩個事件,而非在同一事件內傳布林值。 - 使用 TypeScript 時提供完整型別:能在開發階段即捕捉錯誤,減少 runtime bug。
- 保持子元件的「純粹」:子元件只負責 UI 與
emit,不應該直接呼叫 API;讓父層或 Vuex/Pinia 處理業務邏輯。 - 文件化自訂事件:在元件說明檔(README、Storybook)中列出所有
emit事件與參數結構,提升團隊協作效率。
實際應用場景
1. 表單元件的即時驗證
- 子元件:
<InputField>內部監聽input,每次變更emit('validate', { value, valid })。 - 父元件:收集所有欄位的驗證結果,決定「送出」按鈕是否啟用。
2. 列表項目操作(編輯 / 刪除)
- 子元件:
<TodoItem>內部提供「編輯」與「刪除」按鈕,分別emit('edit', item)、emit('remove', item.id)。 - 父元件:集中管理 Todo 列表,根據事件更新資料來源(Vuex/Pinia)或呼叫後端 API。
3. 複雜 UI 元件的雙向綁定
- 自訂
v-model:如上面的CustomSwitch、DatePicker、RichTextEditor,皆透過emit('update:modelValue', newValue)讓父層的資料自動同步。
4. 子元件觸發全局通知
- 子元件:
<FileUploader>完成上傳後emit('complete', fileInfo)。 - 父層或全局事件總線:接收到
complete後,呼叫全局的 toast/notification 系統顯示「上傳成功」訊息。
5. 多層級嵌套的事件傳遞
- 情境:
GrandParent -> Parent -> Child。 - 做法:子層
emit('action', payload),父層若不直接處理,可 重新emit給上層:// Parent.vue <Child @action="forwardAction" /> methods: { forwardAction(payload) { this.$emit('action', payload) // 轉發給 GrandParent } } - 此技巧讓 中間層保持薄弱,只負責「轉發」或「簡單處理」即可。
總結
emit是 Vue3 中 子元件向父元件回傳資訊 的核心機制,搭配props完成完整的 單向資料流。- 透過 正確的事件命名、參數封裝與型別宣告,可以讓元件間的溝通更清晰、維護更容易。
- 常見的陷阱(忘記宣告事件、大小寫不一致、過度傳遞參數)只要遵守 最佳實踐(單一物件參數、使用 TypeScript、保持子元件純粹)即可避免。
- 在實務開發中,
emit廣泛應用於表單驗證、列表操作、雙向綁定、全局通知與多層級事件轉發等場景,是打造可重用、可擴充 Vue 元件的關鍵工具。
掌握了 emit 的使用方式與注意事項,你就能在 Vue3 專案中更自信地構建 乾淨、可維護、具備良好可讀性的元件,為前端開發的品質與效率奠定堅實基礎。祝你開發順利 🚀