Vue3 Composition API(核心)
Props 與 Emit 在 setup 中的使用
簡介
在 Vue 3 中,Composition API 為我們提供了更彈性的邏輯組織方式。相較於 Options API,setup() 函式成為了組件的入口點,所有的響應式狀態、生命週期、以及父子組件的溝通(props、emit)都在此處完成。
對於 props(父層傳入的資料)與 emit(子層向父層發送事件)而言,正確的使用方式不僅影響程式的可讀性,也直接關係到型別安全、效能與維護成本。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整呈現在 setup 中操作 props 與 emit 的全貌,讓您在開發 Vue3 應用時能夠胸有成竹。
核心概念
1. 在 setup 中取得 props 與 emit
setup(props, context) 會在組件初始化時被呼叫,第一個參數即為 props(一個只讀的響應式對象),第二個參數則是一個包含 emit、attrs、slots 等屬性的 context 物件。
export default {
name: 'MyButton',
props: {
label: String,
disabled: Boolean,
},
setup(props, { emit }) {
// props 是只讀的,不能直接賦值
// emit 用來向父層觸發自訂事件
return {}
},
}
⚠️ 注意:
props本身是只讀的,若需要在組件內部修改,應使用ref或computed產生衍生的可變狀態。
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>
重點:
title與value皆為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讓我們直接取得modelValue的ref,而不需額外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/computed:const localTitle = ref(props.title)。 |
忘記在 emits 中聲明事件 |
在非開發模式下不會警告,可能導致父層無法收到事件。 | 使用 emits: [] 或 defineEmits 明確列出所有事件。 |
| 事件名稱大小寫不一致 | 父層使用 @myEvent 而子層 emit 'my-event',會造成無法捕捉。 |
建議統一使用 kebab-case(my-event)作為事件名稱。 |
過度依賴 $attrs |
直接把 $attrs 轉發給子組件會讓 API 不明確。 |
明確列出需要的 props,僅在「透傳」場景使用 $attrs。 |
最佳實踐總結
- 永遠使用
toRefs/toRef來保持 props 的響應式。 - 在
setup中以defineEmits列出所有自訂事件,配合 TypeScript 時更能確保型別安全。 - 避免在子組件內直接修改 props,若需要本地變更,先建立
ref再操作。 - 統一事件命名規則(kebab-case)並在文件中說明每個事件的 payload 結構。
- 利用
v-model的自訂名稱(modelValue、update:modelValue)實現雙向綁定,讓組件更具可重用性。
實際應用場景
1️⃣ 表單元件庫
在建構像是 Input、Select、DatePicker 等可重用的表單元件時,常會:
- 透過
props接收modelValue、disabled、placeholder等設定。 - 使用
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 }
}
透過 setup 的 emit,我們可以在任何層級靈活地 重構事件流,不必依賴全域事件總線。
總結
- props 在
setup中是 只讀的響應式對象,必須使用toRefs/toRef才能保持解構後的響應式。 - emit 透過
defineEmits(或context.emit)向父層發送自訂事件,配合emits選項可在開發階段即時檢查錯誤。 - 在 TypeScript 環境下,使用泛型或物件寫法為
emit定義型別,能大幅提升開發安全性。 - 透過 v-model、自訂事件、以及 toRefs 的組合,我們可以在 Composition API 中建立 清晰、可維護、型別安全 的父子溝通機制。
掌握上述技巧後,您將能在 Vue 3 中以 Composition API 的方式,寫出結構化且易於測試的元件,無論是簡單的按鈕、複雜的表單,或是高互動性的圖表,都能以一致的方式處理資料流與事件回報。祝開發順利,期待您在實務專案中活用本篇內容!