上一篇講 props 的時候,留了一個很常見的情境:商品卡片上有個「收藏」按鈕,卡片手上的 product 是父組件給的,照單向資料流的規矩,子組件不該直接去改它。
那按鈕按下去之後,到底該怎麼辦?
做法其實上一篇稍微提過:子組件不自己動手,而是「說一聲」,讓真正擁有資料的父組件去改。這個「說一聲」的機制,就是今天的主角 emits。
先看子組件 ProductCard.vue:
<script setup>
const props = defineProps({
product: { type: Object, required: true }
})
const emit = defineEmits(['toggleFavorite'])
function onClickFavorite() {
emit('toggleFavorite', props.product.id)
}
</script>
<template>
<div class="card">
<h3>{{ product.name }}</h3>
<p>NT$ {{ product.price }}</p>
<button @click="onClickFavorite">
{{ product.isFavorite ? '已收藏' : '收藏' }}
</button>
</div>
</template>
再看父組件 ProductList.vue:
<script setup>
import { ref } from 'vue'
import ProductCard from './ProductCard.vue'
const products = ref([
{ id: 1, name: '全罩式安全帽', price: 3200, isFavorite: false },
{ id: 2, name: '3/4 罩安全帽', price: 2400, isFavorite: false }
])
function handleToggleFavorite(id) {
const target = products.value.find(item => item.id === id)
if (target) target.isFavorite = !target.isFavorite
}
</script>
<template>
<ProductCard
v-for="item in products"
:key="item.id"
:product="item"
@toggle-favorite="handleToggleFavorite"
/>
</template>
整個流程拆開來看是這樣:
emit('toggleFavorite', id)
@toggle-favorite 監聽到這個事件,handleToggleFavorite 收到 id
products
product 透過 props 流回子組件,畫面更新事件名稱的部分,emit 裡習慣寫 camelCase(toggleFavorite),模板上監聽習慣寫 kebab-case(@toggle-favorite),Vue 會自動幫你轉換,兩邊對得起來。
這樣拆有一個很實際的好處。
同一張 ProductCard,放在「商品列表頁」時,按收藏就是切換愛心狀態;但放到「我的收藏」頁面時,你可能希望取消收藏之後,這張卡片直接從列表裡消失。
如果收藏的邏輯寫死在子組件裡,你就得幫卡片加一堆判斷:「我現在在哪一頁?」。但用 emit 的話,卡片完全不用改,它永遠只負責喊一聲「有人按了收藏」,至於要怎麼處理,交給各自的父組件決定:
// 收藏頁的父組件
function handleToggleFavorite(id) {
favorites.value = favorites.value.filter(item => item.id !== id)
}
這也呼應上一篇講 props 的初衷:組件不去管外面的事,才能被重複使用。props 讓組件不用自己找資料,emits 讓組件不用自己決定後果。
emit 第一個參數是事件名稱,後面可以接任意數量的參數,父組件的處理函式會依序收到:
// 子組件:加入購物車,帶商品 id 和數量
emit('addToCart', props.product.id, quantity.value)
// 父組件
function handleAddToCart(id, quantity) {
cart.value.push({ id, quantity })
}
<ProductCard @add-to-cart="handleAddToCart" />
如果在模板裡寫行內表達式,要注意 $event 只會拿到第一個參數。需要多個參數的話,改用箭頭函式比較清楚:
<ProductCard @add-to-cart="(id, qty) => handleAddToCart(id, qty)" />
在模板裡其實可以直接用 $emit,不宣告也能動。那為什麼還要特地寫 defineEmits?
這是最直接的理由。打開一個組件,看到 defineProps 就知道它吃什麼資料,看到 defineEmits 就知道它會回報哪些事。不用翻遍整個模板去找 $emit。
這個坑比較隱密。假設我們包了一個自己的按鈕組件:
<!-- BaseButton.vue,沒有宣告 emits -->
<template>
<button class="btn" @click="$emit('click')">
<slot />
</button>
</template>
父組件這樣用:
<BaseButton @click="save">儲存</BaseButton>
結果按一下,save 跑了兩次。
原因是 Vue 有「屬性透傳」(fallthrough attributes)的機制:父組件傳給子組件、但子組件沒有宣告接收的東西,會自動掛到子組件的根元素上。@click 沒被宣告成 emits,所以它被當成一般屬性,直接掛到了 <button> 上,變成原生的 click 監聽。
於是按一下會發生兩件事:原生 click 觸發一次 save,$emit('click') 又觸發一次 save。
只要宣告一下就解決了:
<script setup>
defineEmits(['click'])
</script>
宣告過的事件會從透傳屬性裡排除,@click 就只會由 emit 觸發。
defineEmits 也可以寫成物件,對每個事件的參數做檢查:
const emit = defineEmits({
addToCart: (id, quantity) => {
if (quantity > 0) return true
console.warn('數量必須大於 0')
return false
}
})
要注意的是,驗證失敗只會在開發環境跳警告,事件還是會照樣發出去。它比較像是提醒開發者「你傳錯東西了」,不是攔截機制,真正的防呆還是要寫在邏輯裡。
看到「事件」兩個字,很容易聯想到原生 DOM 的 click:在子元素觸發,會一路往上冒泡,祖先元素都聽得到。
組件的 emit 不一樣,它只會傳一層,只有直接使用這個組件的父組件聽得到。
假設結構變成三層:
ProductList
└── ProductCard
└── FavoriteButton
FavoriteButton 發出的事件,ProductList 是聽不到的。ProductCard 必須接住,再自己發一次:
<!-- ProductCard.vue -->
<FavoriteButton @toggle="emit('toggleFavorite', product.id)" />
兩三層還好,如果要穿過很多層,一層一層轉發會很痛苦。這時候通常會改用 provide / inject 或 Pinia,那是之後的主題了。
為什麼 emit 不會冒泡?打開 Vue SFC Playground 看編譯結果就懂了。
<ProductCard :product="item" @toggle-favorite="handleToggleFavorite" />
編譯後大概長這樣(簡化過):
_createVNode(ProductCard, {
product: item,
onToggleFavorite: handleToggleFavorite
})
注意 onToggleFavorite。父組件監聽的事件,其實是被轉成一個 on 開頭的屬性,跟 product 一起傳進了子組件。
而子組件呼叫 emit 時做的事,概念上就是這樣:
// 概念版,省略了 kebab-case 轉換、once 修飾符等細節
function emit(event, ...args) {
const handlerName = 'on' + event[0].toUpperCase() + event.slice(1)
const handler = instance.vnode.props[handlerName]
if (handler) handler(...args)
}
去父組件傳進來的屬性裡找 onToggleFavorite,找到就把參數丟進去呼叫。
所以 emit 本質上就是 JavaScript 最基本的 callback:父組件把一個函式交給子組件,子組件在適當的時機呼叫它。它從頭到尾沒有經過 DOM 事件系統,自然也就沒有冒泡這回事。
有寫過 React 的話應該很眼熟,React 就是直接把 onXxx 函式當 props 傳下去。Vue 只是把這件事包裝成「事件」的寫法,再多給了宣告、驗證、.once 修飾符和透傳排除這些功能。
| props | emits | |
|---|---|---|
| 方向 | 父 → 子 | 子 → 父 |
| 傳的是什麼 | 資料 | 「發生了什麼事」的通知 |
| 誰擁有資料 | 父組件 | 父組件 |
| 誰決定怎麼改 | 父組件 | 父組件 |
props 和 emits 是一組的:資料往下流,事件往上報,改資料的權力始終留在擁有資料的那一層。
子組件想改 props 的時候,與其想辦法繞過規矩,不如問自己一句:「這件事應該由我決定,還是我只要回報就好?」大部分的時候,答案都是後者。