本文 AI 產出,尚未審核

Vue 3 元件基礎:父子元件通信(props & emit)


簡介

在 Vue 3 的單頁應用中,元件是組成 UI 的基本單位。大多數情況下,畫面會由多層次的父子元件構成,而這些元件之間必須能夠傳遞資料觸發行為,才能形成完整的互動流程。

propsemit 正是 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>

解構後的變數同樣是只讀的,若需要可變的本地值,請使用 refcomputed 再包裝。


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-modelprop 名稱與 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 functiondefault: () => []

最佳實踐

  1. 單向資料流:始終保持「父 → 子」的資料流向,子層只負責「發射事件」。
  2. 明確的事件語意:事件名稱應描述行為而非狀態,例如 savedelete-item,避免使用 update 之類過於籠統的名稱。
  3. 使用 TypeScript 或 JSDoc:在 defineProps / defineEmits 中加入型別,可在編譯期捕捉錯誤。
  4. 避免過深的 Prop 傳遞:若需要跨多層傳遞,同時使用 provide/inject 或全域狀態管理(Pinia)會更清晰。
  5. 保持事件彈性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)即可有效避免。
  • 在實務上,父子通信是 列表渲染、表單驗證、彈窗交互、狀態同步 等多種情境的核心,熟練掌握 propsemit,即可在 Vue 3 中構建出結構清晰、可擴充的前端應用。

下一步:若你的專案已超過簡單的父子層級,建議逐步導入 Pinia 作為全域狀態管理,或使用 provide/inject 處理跨多層的資料傳遞,讓應用程式的可維護性更上一層樓。祝你在 Vue 3 的旅程中寫出更乾淨、更高效的程式碼!