本文 AI 產出,尚未審核

Vue3 Composition API(核心)

Props 與 Emit 在 setup 中的使用


簡介

在 Vue 3 中,Composition API 為我們提供了更彈性的邏輯組織方式。相較於 Options API,setup() 函式成為了組件的入口點,所有的響應式狀態、生命週期、以及父子組件的溝通(props、emit)都在此處完成。

對於 props(父層傳入的資料)與 emit(子層向父層發送事件)而言,正確的使用方式不僅影響程式的可讀性,也直接關係到型別安全、效能與維護成本。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整呈現在 setup 中操作 props 與 emit 的全貌,讓您在開發 Vue3 應用時能夠胸有成竹。


核心概念

1. 在 setup 中取得 propsemit

setup(props, context) 會在組件初始化時被呼叫,第一個參數即為 props(一個只讀的響應式對象),第二個參數則是一個包含 emitattrsslots 等屬性的 context 物件。

export default {
  name: 'MyButton',
  props: {
    label: String,
    disabled: Boolean,
  },
  setup(props, { emit }) {
    // props 是只讀的,不能直接賦值
    // emit 用來向父層觸發自訂事件
    return {}
  },
}

⚠️ 注意props 本身是只讀的,若需要在組件內部修改,應使用 refcomputed 產生衍生的可變狀態。


2. 使用 toRefs 讓單個 prop 變成獨立的 ref

當我們希望在 setup 中解構 props,直接寫 const { label } = props 會失去響應式。此時可以利用 toRefs(或 toRef)把每個屬性轉成獨立的 ref,保持響應式。

import { toRefs } from 'vue'

setup(props) {
  const { label, disabled } = toRefs(props)   // label、disabled 皆為 ref
  // 現在可以在模板或其他 reactive 內使用 .value
  return { label, disabled }
}

3. emit 的類型安全(TS)與命名慣例

在 TypeScript 專案中,我們可以為 emit 定義事件名稱與參數型別,避免錯字或傳遞錯誤資料。

import { defineComponent } from 'vue'

export default defineComponent({
  name: 'Counter',
  emits: {
    // 事件名稱: 參數驗證函式
    increment: (payload: number) => typeof payload === 'number',
  },
  setup(_, { emit }) {
    const add = (step: number) => emit('increment', step)
    return { add }
  },
})

最佳實踐:使用 emits 選項列出所有自訂事件,Vue 會在開發模式自動檢查事件名稱與參數,提升開發體驗。


4. Props 的預設值、驗證與型別推斷

在 Composition API 中,props 的型別仍由 defineProps(在 <script setup>)或 props 選項提供。若使用 <script setup>,可直接寫:

<script setup lang="ts">
interface Props {
  title: string
  count?: number
}
const props = defineProps<Props>()
// props.title 為必填 string,props.count 為可選 number
</script>

程式碼範例

以下提供 5 個實用範例,示範在 setup 中操作 props 與 emit 的常見情境。每段程式碼皆附上說明註解,您可以直接複製到專案中測試。

範例 1️⃣ 基本 Props 讀取與顯示

<template>
  <h2>{{ title }}</h2>
  <p>目前值:{{ value }}</p>
</template>

<script setup>
import { toRefs } from 'vue'

// 定義 props,title 為必填、value 為可選
const props = defineProps({
  title: { type: String, required: true },
  value: { type: Number, default: 0 },
})

// 使用 toRefs 保持響應式
const { title, value } = toRefs(props)
</script>

重點titlevalue 皆為 ref,在模板中直接使用即可,Vue 會自動解包。


範例 2️⃣ 子組件向父層發送事件(emit)

<template>
  <button :disabled="disabled" @click="handleClick">
    {{ label }}
  </button>
</template>

<script setup>
import { toRefs } from 'vue'

const props = defineProps({
  label: String,
  disabled: Boolean,
})
const { label, disabled } = toRefs(props)

// 取得 emit 函式
const emit = defineEmits(['click'])

// 點擊時向父層傳遞自訂事件
function handleClick() {
  // 可以傳遞任意資料,這裡傳遞當前時間戳
  emit('click', Date.now())
}
</script>

父層使用方式:

<template>
  <MyButton label="送出" @click="onButtonClick" />
</template>

<script setup>
import MyButton from '@/components/MyButton.vue'

function onButtonClick(timestamp) {
  console.log('子組件在', timestamp, '觸發 click 事件')
}
</script>

範例 3️⃣ 透過 emit 回傳表單驗證結果

<template>
  <form @submit.prevent="onSubmit">
    <input v-model="name" placeholder="姓名" />
    <button type="submit">送出</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['submit'])

const name = ref('')

// 表單送出時,先驗證再 emit
function onSubmit() {
  if (name.value.trim() === '') {
    // 直接在子組件內部顯示錯誤訊息
    alert('請輸入姓名')
    return
  }
  // 把驗證成功的資料傳給父層
  emit('submit', { name: name.value })
}
</script>

父層接收:

<template>
  <UserForm @submit="handleUserSubmit" />
</template>

<script setup>
import UserForm from '@/components/UserForm.vue'

function handleUserSubmit(payload) {
  console.log('收到使用者資料:', payload)
  // 例如發送 API
}
</script>

範例 4️⃣ 使用 toRef 建立雙向綁定(v-model)

Vue 3 允許自訂 v-model 名稱,下面示範子組件如何使用 modelValue prop 與 update:modelValue 事件配合 setup

<!-- MyInput.vue -->
<template>
  <input :value="modelValue" @input="onInput" />
</template>

<script setup>
import { toRef } from 'vue'

const props = defineProps({
  modelValue: String,
})
const emit = defineEmits(['update:modelValue'])

const modelValue = toRef(props, 'modelValue')

function onInput(event) {
  emit('update:modelValue', event.target.value)
}
</script>

父層使用:

<template>
  <MyInput v-model="username" />
  <p>輸入的名稱:{{ username }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from '@/components/MyInput.vue'

const username = ref('')
</script>

說明toRef 讓我們直接取得 modelValueref,而不需額外 computed 包裝。


範例 5️⃣ 以 TypeScript 定義 emit 的型別安全

// Counter.vue
<script lang="ts" setup>
import { defineEmits, ref } from 'vue'

// 先列出所有自訂事件與參數型別
const emit = defineEmits<{
  (e: 'increase', amount: number): void
  (e: 'decrease', amount: number): void
}>()

const count = ref(0)

function increase(step = 1) {
  count.value += step
  emit('increase', step)   // ✅ 正確型別
}

function decrease(step = 1) {
  count.value -= step
  emit('decrease', step)   // ✅ 正確型別
}
</script>

<template>
  <div>
    <p>計數:{{ count }}</p>
    <button @click="increase">+</button>
    <button @click="decrease">-</button>
  </div>
</template>

父層:

<script lang="ts" setup>
import Counter from '@/components/Counter.vue'

function onIncrease(amount: number) {
  console.log('增加了', amount)
}
function onDecrease(amount: number) {
  console.log('減少了', amount)
}
</script>

<template>
  <Counter @increase="onIncrease" @decrease="onDecrease" />
</template>

好處:編譯階段即能捕捉錯誤,如錯傳字串或少傳參數。


常見陷阱與最佳實踐

陷阱 說明 解決方案
直接解構 props const { foo } = props 會失去響應式,更新不會觸發 UI。 使用 toRefs(props)toRef(props, 'foo')
修改 props props.title = 'new' 會在開發模式拋錯,因為 props 為只讀。 若需要本地可變值,建立 ref/computedconst localTitle = ref(props.title)
忘記在 emits 中聲明事件 在非開發模式下不會警告,可能導致父層無法收到事件。 使用 emits: []defineEmits 明確列出所有事件。
事件名稱大小寫不一致 父層使用 @myEvent 而子層 emit 'my-event',會造成無法捕捉。 建議統一使用 kebab-casemy-event)作為事件名稱。
過度依賴 $attrs 直接把 $attrs 轉發給子組件會讓 API 不明確。 明確列出需要的 props,僅在「透傳」場景使用 $attrs

最佳實踐總結

  1. 永遠使用 toRefs / toRef 來保持 props 的響應式。
  2. setup 中以 defineEmits 列出所有自訂事件,配合 TypeScript 時更能確保型別安全。
  3. 避免在子組件內直接修改 props,若需要本地變更,先建立 ref 再操作。
  4. 統一事件命名規則(kebab-case)並在文件中說明每個事件的 payload 結構。
  5. 利用 v-model 的自訂名稱modelValueupdate:modelValue)實現雙向綁定,讓組件更具可重用性。

實際應用場景

1️⃣ 表單元件庫

在建構像是 Input、Select、DatePicker 等可重用的表單元件時,常會:

  • 透過 props 接收 modelValuedisabledplaceholder 等設定。
  • 使用 emit('update:modelValue', newValue) 讓父層取得最新輸入值。

這樣的模式讓表單元件可以在 Vue 3 + Vite + TypeScript 的大型專案中保持一致性與可測試性。

2️⃣ 互動式圖表或地圖

圖表元件往往需要 外部資料(props)事件回報(emit)

// 父層傳入資料
<BarChart :data="salesData" @bar-click="onBarClick" />

子組件在 setup 中:

setup(props, { emit }) {
  const chartRef = ref(null)

  watch(() => props.data, drawChart, { deep: true })

  function drawChart() {
    // 產生圖表,並在每個 bar 加入 click 監聽
    // on click -> emit('bar-click', barInfo)
  }
}

這樣的分離讓 資料流向 明確,圖表本身只負責渲染與事件發射。

3️⃣ 多層組件溝通(中介組件)

父子祖父三層 的結構中,子組件的事件可以直接 emit 給最近的父層,若需要跨層傳遞,可在中介層 re-emit

// 中介層
setup(_, { emit }) {
  const forward = payload => emit('custom-event', payload)
  return { forward }
}

透過 setupemit,我們可以在任何層級靈活地 重構事件流,不必依賴全域事件總線。


總結

  • propssetup 中是 只讀的響應式對象,必須使用 toRefs/toRef 才能保持解構後的響應式。
  • emit 透過 defineEmits(或 context.emit)向父層發送自訂事件,配合 emits 選項可在開發階段即時檢查錯誤。
  • TypeScript 環境下,使用泛型或物件寫法為 emit 定義型別,能大幅提升開發安全性。
  • 透過 v-model自訂事件、以及 toRefs 的組合,我們可以在 Composition API 中建立 清晰、可維護、型別安全 的父子溝通機制。

掌握上述技巧後,您將能在 Vue 3 中以 Composition API 的方式,寫出結構化且易於測試的元件,無論是簡單的按鈕、複雜的表單,或是高互動性的圖表,都能以一致的方式處理資料流與事件回報。祝開發順利,期待您在實務專案中活用本篇內容!