Vue 3 元件基礎:父子元件通信(props & emit)
簡介
在 Vue 3 的單頁應用中,元件是組成 UI 的基本單位。大多數情況下,畫面會由多層次的父子元件構成,而這些元件之間必須能夠傳遞資料與觸發行為,才能形成完整的互動流程。
props 與 emit 正是 Vue 官方提供的兩套「單向」資料流機制:父層透過 props 把資料 向下 傳遞給子層,子層則透過 emit 把事件 向上 通知父層。掌握這兩者的使用方法,不僅能讓程式碼保持 可預測、易維護,也能避免常見的「雙向綁定」所帶來的副作用。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你建立對 父子元件通信 的完整認知,適合 Vue 初學者與有一定基礎的中階開發者閱讀。
核心概念
1. Props:父層 → 子層的單向資料傳遞
props(properties)是父元件向子元件傳遞資料的唯一官方管道。子元件在宣告 props 後,Vue 會自動將父層傳入的值注入為只讀屬性,確保子元件不會直接改變父層的狀態。
1.1 基本使用
<!-- ParentComponent.vue -->
<template>
<ChildComponent :title="pageTitle" />
</template>
<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
const pageTitle = ref('Vue 3 教學');
</script>
<!-- ChildComponent.vue -->
<template>
<h2>{{ title }}</h2>
</template>
<script setup>
/* 使用 defineProps 宣告接收的屬性 */
const props = defineProps({
title: {
type: String,
required: true,
},
});
</script>
重點:子元件的
props為 只讀,若在子元件內部直接改寫props.title,Vue 會在開發環境拋出警告。
1.2 Prop 類型驗證與預設值
// ChildComponent.vue
<script setup>
const props = defineProps({
/** 必填字串 */
title: { type: String, required: true },
/** 數字,若未傳入則預設為 0 */
count: { type: Number, default: 0 },
/** 限制只能是 'info'、'warning'、'error' */
status: {
type: String,
validator: (value) => ['info', 'warning', 'error'].includes(value),
default: 'info',
},
});
</script>
使用 validator 可以在開發階段即捕捉不合法的資料,提升元件的健壯性。
1.3 Prop 的解構與別名
有時候我們想把 props 解構成局部變數,或是給予更易讀的別名:
<script setup>
const { title: pageTitle, count } = defineProps({
title: String,
count: Number,
});
</script>
解構後的變數同樣是只讀的,若需要可變的本地值,請使用 ref 或 computed 再包裝。
2. Emit:子層 → 父層的事件通知
子元件如果要讓父層知道「某件事」已發生(例如按鈕點擊、表單送出),就需要使用 emit 觸發自訂事件。父層在使用子元件時,透過 @事件名 或 v-on:事件名 監聽。
2.1 基本 Emit
<!-- ChildComponent.vue -->
<template>
<button @click="handleClick">點我</button>
</template>
<script setup>
const emit = defineEmits(['confirm']);
function handleClick() {
// 觸發名為 'confirm' 的事件,無參數
emit('confirm');
}
</script>
<!-- ParentComponent.vue -->
<template>
<ChildComponent @confirm="onConfirm" />
</template>
<script setup>
function onConfirm() {
alert('子元件點擊了!');
}
</script>
技巧:
defineEmits允許以陣列或物件形式宣告,若使用物件可同時定義參數驗證。
2.2 帶參數的 Emit
// ChildComponent.vue
<script setup>
const emit = defineEmits({
/** payload 必須是物件且包含 name 與 age */
submit: (payload) => {
return typeof payload === 'object' && 'name' in payload && 'age' in payload;
},
});
function submitForm() {
const data = { name: 'Alice', age: 28 };
emit('submit', data); // 將資料傳給父層
}
</script>
<!-- ParentComponent.vue -->
<ChildComponent @submit="handleSubmit" />
<script setup>
function handleSubmit(payload) {
console.log('收到子元件送出的資料', payload);
}
</script>
2.3 多個自訂事件
一個子元件可以同時 emit 多個事件,常見於表單元件:
// FormInput.vue
<script setup>
const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
function onInput(e) {
emit('update:modelValue', e.target.value);
}
function onFocus() {
emit('focus');
}
function onBlur() {
emit('blur');
}
</script>
<template>
<input
:value="modelValue"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
/>
</template>
父層使用時:
<FormInput
v-model="username"
@focus="onFocusInput"
@blur="onBlurInput"
/>
2.4 v-model 的底層原理
Vue 3 允許在子元件內部自行定義 v-model 的 prop 名稱與 event 名稱:
// MySwitch.vue
<script setup>
const props = defineProps({
modelValue: Boolean, // 預設 v-model 綁定的 prop
});
const emit = defineEmits(['update:modelValue']);
function toggle() {
emit('update:modelValue', !props.modelValue);
}
</script>
<template>
<button @click="toggle">
{{ props.modelValue ? 'ON' : 'OFF' }}
</button>
</template>
父層:
<MySwitch v-model="isOn" />
若想改成 v-model:checked:
// MyCheckbox.vue
<script setup>
const props = defineProps({
checked: Boolean,
});
const emit = defineEmits(['update:checked']);
function toggle() {
emit('update:checked', !props.checked);
}
</script>
<template>
<input type="checkbox" :checked="checked" @change="toggle" />
</template>
使用方式:
<MyCheckbox v-model:checked="agree" />
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 直接修改 Props | 子元件內直接改寫 props.xxx 會觸發 Vue 警告,且會破壞單向資料流 |
使用本地 state (ref/reactive) 來保存可變值,或透過 emit 讓父層更新 |
| 事件名稱拼寫不一致 | 父層監聽的事件名稱與子元件 emit 的名稱大小寫不一致時,事件不會被觸發 |
統一命名規則:建議使用 kebab-case (@my-event) 或 camelCase (@myEvent) 並在兩端保持相同 |
| 忘記在 defineEmits 中聲明事件 | 雖然可以直接使用 emit('xxx'),但未在 defineEmits 宣告會失去型別提示與參數驗證 |
使用物件形式 defineEmits({ myEvent: (payload) => true }) 以獲得 IDE 支援 |
| 過度使用 $emit 造成父層耦合 | 子元件發射太多不同事件,導致父層需要寫大量監聽程式碼 | 抽象成自訂 composable 或 使用 provide/inject 於深層結構中傳遞資料 |
| Props 預設值是物件/陣列時的共享問題 | 若在 props 定義中直接寫 default: [],所有實例會共用同一個陣列 |
使用 factory function:default: () => [] |
最佳實踐
- 單向資料流:始終保持「父 → 子」的資料流向,子層只負責「發射事件」。
- 明確的事件語意:事件名稱應描述行為而非狀態,例如
save、delete-item,避免使用update之類過於籠統的名稱。 - 使用 TypeScript 或 JSDoc:在
defineProps/defineEmits中加入型別,可在編譯期捕捉錯誤。 - 避免過深的 Prop 傳遞:若需要跨多層傳遞,同時使用
provide/inject或全域狀態管理(Pinia)會更清晰。 - 保持事件彈性:
emit時盡量傳遞 payload(物件)而非多個參數,未來若需擴充資訊,只要在物件裡加屬性即可。
實際應用場景
範例 1:商品列表與購物車
- 父層
ProductList.vue取得商品資料,透過props傳給子元件ProductItem.vue。 - 子層 點擊「加入購物車」時,
emit('add-to-cart', productId)。 - 父層 監聽事件,更新 Pinia store 中的購物車狀態。
<!-- ProductList.vue -->
<template>
<div v-for="item in products" :key="item.id">
<ProductItem :product="item" @add-to-cart="addToCart" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import ProductItem from './ProductItem.vue';
import { useCartStore } from '@/stores/cart';
const products = ref([...]); // 從 API 抓取
const cartStore = useCartStore();
function addToCart(productId) {
cartStore.add(productId);
}
</script>
<!-- ProductItem.vue -->
<script setup>
const props = defineProps({
product: {
type: Object,
required: true,
},
});
const emit = defineEmits(['add-to-cart']);
function handleAdd() {
emit('add-to-cart', props.product.id);
}
</script>
<template>
<div class="card">
<h3>{{ product.name }}</h3>
<p>{{ product.price }} 元</p>
<button @click="handleAdd">加入購物車</button>
</div>
</template>
範例 2:表單驗證與即時回饋
在大型表單中,每個欄位都是獨立的子元件。子元件負責 本地驗證,驗證結果透過 emit('validation', { field, valid }) 回傳給父層,父層彙總後決定「提交」按鈕是否可用。
<!-- FormWrapper.vue -->
<template>
<form @submit.prevent="onSubmit">
<FormInput
v-model="form.email"
label="Email"
@validation="onFieldValidate"
/>
<FormInput
v-model="form.password"
type="password"
label="Password"
@validation="onFieldValidate"
/>
<button :disabled="!isFormValid">送出</button>
</form>
</template>
<script setup>
import { reactive, computed } from 'vue';
import FormInput from './FormInput.vue';
const form = reactive({
email: '',
password: '',
});
const validationMap = reactive({ email: false, password: false });
function onFieldValidate({ field, valid }) {
validationMap[field] = valid;
}
const isFormValid = computed(() => Object.values(validationMap).every(Boolean));
function onSubmit() {
console.log('送出資料', form);
}
</script>
<!-- FormInput.vue -->
<script setup>
const props = defineProps({
modelValue: String,
label: String,
type: { type: String, default: 'text' },
});
const emit = defineEmits(['update:modelValue', 'validation']);
function onInput(e) {
const value = e.target.value;
emit('update:modelValue', value);
// 簡易驗證:非空
emit('validation', { field: props.label.toLowerCase(), valid: !!value });
}
</script>
<template>
<label>{{ label }}</label>
<input :type="type" :value="modelValue" @input="onInput" />
</template>
此模式讓 表單驗證邏輯 完全在子元件內部完成,父層只負責收集結果,維持了乾淨的職責分離。
總結
- Props 為父→子的單向資料流入口,使用
defineProps宣告類型、必填與預設值,可在開發階段即捕捉錯誤。 - Emit 為子→父的事件機制,透過
defineEmits定義可發射的事件與參數驗證,讓父層以@事件名監聽。 - 正確的 單向資料流 能提升元件的可預測性與維護性,避免因直接修改
props或濫用$emit而產生的耦合問題。 - 常見陷阱包括直接改寫
props、事件命名不一致、預設值共享等,只要遵守 最佳實踐(明確命名、型別驗證、適度使用 provide/inject)即可有效避免。 - 在實務上,父子通信是 列表渲染、表單驗證、彈窗交互、狀態同步 等多種情境的核心,熟練掌握
props與emit,即可在 Vue 3 中構建出結構清晰、可擴充的前端應用。
下一步:若你的專案已超過簡單的父子層級,建議逐步導入 Pinia 作為全域狀態管理,或使用 provide/inject 處理跨多層的資料傳遞,讓應用程式的可維護性更上一層樓。祝你在 Vue 3 的旅程中寫出更乾淨、更高效的程式碼!