iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 17 篇

Vue 走過路過不要錯過 Day17 - emits:子組件怎麼把事情告訴父組件

  • 分享至 

  • xImage
  •  

上一篇講 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>

整個流程拆開來看是這樣:

  1. 使用者按下收藏按鈕,子組件呼叫 emit('toggleFavorite', id)
  2. 父組件用 @toggle-favorite 監聽到這個事件,handleToggleFavorite 收到 id
  3. 父組件改了自己的 products
  4. 新的 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)" />

為什麼要寫 defineEmits?

在模板裡其實可以直接用 $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
  }
})

要注意的是,驗證失敗只會在開發環境跳警告,事件還是會照樣發出去。它比較像是提醒開發者「你傳錯東西了」,不是攔截機制,真正的防呆還是要寫在邏輯裡。

emit 不是 DOM 事件,它不會冒泡

看到「事件」兩個字,很容易聯想到原生 DOM 的 click:在子元素觸發,會一路往上冒泡,祖先元素都聽得到。

組件的 emit 不一樣,它只會傳一層,只有直接使用這個組件的父組件聽得到。

假設結構變成三層:

ProductList
└── ProductCard
    └── FavoriteButton

FavoriteButton 發出的事件,ProductList 是聽不到的。ProductCard 必須接住,再自己發一次:

<!-- ProductCard.vue -->
<FavoriteButton @toggle="emit('toggleFavorite', product.id)" />

兩三層還好,如果要穿過很多層,一層一層轉發會很痛苦。這時候通常會改用 provide / inject 或 Pinia,那是之後的主題了。

emit 背後其實就是呼叫一個函式

為什麼 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 的時候,與其想辦法繞過規矩,不如問自己一句:「這件事應該由我決定,還是我只要回報就好?」大部分的時候,答案都是後者。


上一篇
Vue 走過路過不要錯過 Day16 - props:defineProps 與單向資料流的規矩
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言