Skip to content

props 與 emits:元件的輸入與輸出

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

第一節結尾的 <BookCard /> 有個問題:不管放幾個,內容都一模一樣。元件要能重複使用,就得能接收外部傳進來的資料,也得能把內部發生的事通知外面。前者靠 props,後者靠 emits,兩者合起來就是元件的輸入與輸出介面。

用 props 把資料傳進元件

props 是父元件傳給子元件的資料。子元件用 defineProps() 宣告自己接收哪些 props,父元件在標籤上以屬性的形式傳入:

src/components/BookCard.vue

vue
<script setup>
const props = defineProps({
  title: String,
  stock: Number
})
</script>

<template>
  <article class="card">
    <h2>{{ title }}</h2>
    <p>剩餘 {{ stock }} 本</p>
  </article>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}
</style>

src/App.vue

vue
<script setup>
import BookCard from './components/BookCard.vue'
</script>

<template>
  <main class="app">
    <BookCard title="重新認識 Vue.js" :stock="8" />
    <BookCard title="008 天絕對看不完" :stock="3" />
  </main>
</template>

<style scoped>
.app {
  display: grid;
  gap: 1rem;
  padding: 2rem;
}
</style>

同一個元件,兩張卡片顯示不同內容。這裡有兩個細節值得停下來看:

  • title="重新認識 Vue.js" 傳的是字串字面值;:stock="8"v-bind 的冒號,引號裡是 JavaScript 運算式,所以傳進去的是數字 8。忘了冒號的 stock="8" 傳的會是字串 "8"
  • defineProps()defineEmits() 這類 define 開頭的巨集是編譯器巨集 (compiler macro):只能在 <script setup> 頂層使用,而且不需要 import

宣告後的 props 在模板中直接用名字存取;在 <script setup> 裡則透過回傳的物件,例如 props.title

另外,defineProps 也接受陣列簡寫:defineProps(['title', 'stock']),只列名字、不驗證型別,不少教學會用它起步。本書直接採物件寫法:多花一點字,宣告本身就是這個元件的使用說明。

TypeScript 版本:用型別宣告 props

如果專案使用 TypeScript,defineProps 可以直接用型別參數宣告,不需要執行期的物件:

vue
<script setup lang="ts">
const props = defineProps<{
  title: string
  stock: number
}>()
</script>

型別系統的完整討論留給第九章,這裡先知道有這個寫法即可。

props 驗證

defineProps() 傳入物件時,每個 prop 除了型別,還可以宣告是否必填、預設值與自訂驗證:

src/components/BookCard.vue

vue
<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  stock: {
    type: Number,
    default: 0,
    validator: (value) => value >= 0   // 回傳 false 只會警告,不會擋下這個值
  }
})
</script>

<template>
  <article class="card">
    <h2>{{ title }}</h2>
    <p v-if="stock > 0">剩餘 {{ stock }} 本</p>
    <p v-else>已售完</p>
  </article>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}
</style>

如果 prop 是物件或陣列,default 有一條特別規定:必須用函式回傳預設值

js
const props = defineProps({
  tags: {
    type: Array,
    default: () => []        // 正確:每個元件實體各拿到一份新陣列
    // default: []           // 錯誤:Vue 會在 console 警告
  }
})

原因跟參照有關:如果直接寫 default: [],所有沒傳這個 prop 的元件實體,拿到的會是同一個陣列,一個實體改了、其他實體全部遭殃。工廠函式讓每個實體各自拿到一份新的預設值。

驗證失敗(例如漏傳 title,或 stock 傳了負數)不會讓程式掛掉:console 會出現警告,但那個值照樣會被接受、拿去渲染validator 不會擋下或替換它。這些警告值得認真對待:它們把「元件被用錯」的問題在開發階段就暴露出來,而不是等到畫面壞掉才回頭查。

也因此要認清它的定位:props 驗證是開發階段的輔助,正式環境 (production build) 不會執行這些檢查。它防的是「同事把元件用錯」,不是「使用者傳來壞資料」;真正不可信任的輸入(例如 API 回應),要在資料進入元件之前另外處理。

單向資料流

props 的資料流向是單向的:父元件流向子元件。父元件更新,子元件的 props 跟著更新;反過來不成立,子元件不應該修改 props。

常見錯誤:直接修改 props

「售出一本,庫存減一」,直覺可能會在子元件裡這樣寫:

js
function sell() {
  props.stock--   // 開發模式下 console 會警告,而且父元件的資料不會變
}

props 是唯讀的:像上面這樣直接對 prop 賦值,開發模式下 console 會警告,父元件的資料也不會變。

物件與陣列不同,prop 傳的是參照。子元件修改 props.book.stock 時,Vue 不會攔截,父元件手上的同一個物件也會跟著改。程式看似能跑,但之後要追查「這筆資料在哪裡被改動」就麻煩了。

正確的做法依需求二選一:

  • 這筆資料的變動需要讓父元件知道:向上 emit 事件,由擁有資料的父元件來改(下一小節就講)。
  • 只是想以 prop 為初始值做本地加工:另開本地狀態或 computed,例如 const localStock = ref(props.stock)const label = computed(() => props.title.toUpperCase())

簡單記:資料由父元件擁有,就由父元件修改;子元件只負責回報事件。

Reactive Props Destructure (Vue 3.5 新增)

前一節才說過「解構 reactive() 的原始型別屬性會中斷響應性」,而 defineProps() 回傳的 props 物件也是響應式的,那解構 props 也會斷嗎?

在 Vue 3.5 之前會,所以舊程式碼都乖乖寫 props.title。Vue 3.5 之後,在同一個 <script setup> 裡解構 defineProps() 可以保持響應性,還能順便宣告預設值:

src/components/BookCard.vue

vue
<script setup>
// 解構同時宣告預設值,響應性由編譯器維持
const { title, stock = 0 } = defineProps({
  title: { type: String, required: true },
  stock: Number
})
</script>

<template>
  <article class="card">
    <h2>{{ title }}</h2>
    <p>剩餘 {{ stock }} 本</p>
  </article>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}
</style>

stock = 0 取代了驗證物件裡的 default: 0,寫起來就是普通的 JavaScript 解構預設值。

不過要知道它為什麼能成立:這是 defineProps() 專屬的編譯器轉換。編譯器看得到同一個 <script setup> 裡的 title 來自 defineProps 的解構,會在編譯時把它自動改寫回 props.title。它不是新的響應式機制,一般物件與 reactive() 的解構行為完全沒有改變,上一節的規則依然成立。

也因為是編譯期改寫,離開編譯器看得到的範圍就要留意:把解構出來的 prop 傳給 watch,或傳給需要持續追蹤變化的外部函式/composable 時,要包成 getter:

js
watch(stock, () => { /* ... */ })          // 錯誤:傳給 watch 的是當下的數字
watch(() => stock, () => { /* ... */ })    // 正確:包成 getter,watch 才追蹤得到

如果函式只是要拿當下的值做一次性計算,直接傳值即可,不必包 getter。

用 emits 把事件送出去

輸入解決了,來處理輸出。子元件用 defineEmits() 宣告會發出哪些事件,需要時呼叫 emit 往上送,父元件用 @事件名 接聽,跟監聽 DOM 事件的寫法一致:

src/components/BookCard.vue

vue
<script setup>
const { title, stock = 0 } = defineProps({
  title: { type: String, required: true },
  stock: Number
})

const emit = defineEmits(['purchase'])

function handleClick() {
  emit('purchase', title)   // 事件名稱+要附給父元件的資料
}
</script>

<template>
  <article class="card">
    <h2>{{ title }}</h2>
    <p v-if="stock > 0">剩餘 {{ stock }} 本</p>
    <p v-else>已售完</p>
    <button type="button" :disabled="stock === 0" @click="handleClick">
      購買
    </button>
  </article>
</template>

<style scoped>
.card {
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 1rem;
}
</style>

src/App.vue

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

const message = ref('')

function handlePurchase(bookTitle) {
  // 參數就是子元件 emit 時附上的資料
  message.value = `已將《${bookTitle}》加入訂單`
}
</script>

<template>
  <main class="app">
    <BookCard title="重新認識 Vue.js" :stock="8" @purchase="handlePurchase" />
    <BookCard title="008 天絕對看不完" :stock="0" @purchase="handlePurchase" />
    <p v-if="message">{{ message }}</p>
  </main>
</template>

<style scoped>
.app {
  display: grid;
  gap: 1rem;
  padding: 2rem;
}
</style>

按下「購買」,子元件發出 purchase 事件並附上書名;父元件收到後更新自己的 message。注意分工:庫存與訂單資料都在父元件手上,子元件只負責「回報發生了什麼事」,這正是上一小節單向資料流的完整樣貌:props 往下、events 往上。

自訂事件的寫法跟 DOM 事件一致,行為卻有幾個關鍵差異:

  • 不會冒泡。DOM 事件會沿著元素一路往上傳,自訂事件只有渲染這個元件的父元件聽得到,不會再往更外層傳。要跨多層溝通,得用別的工具,第四章的 provide/inject 與第七章的狀態管理都是為此而生。
  • 參數是 payload,不是 event 物件。父元件 handler 收到的是 emit 時附上的資料(範例中的書名字串);這裡沒有瀏覽器的 event 物件,自然也沒有 preventDefault() 可言,上一章的事件修飾子在這裡也多半用不到。
  • 命名會自動對應。以 camelCase 發出的事件(如 emit('addToCart')),父元件模板可以用 kebab-case 監聽(@add-to-cart),Vue 會自動轉換,這也是模板裡慣用的寫法。

TypeScript 版本:用型別宣告 emits

defineEmits 同樣可以用型別參數,宣告每個事件的參數型別:

vue
<script setup lang="ts">
const emit = defineEmits<{
  purchase: [title: string]
}>()
</script>

同樣地,細節留給第九章。

props 搭配 emits,已經能應付大多數的父子溝通。不過有一種模式常見到 Vue 特地為它做了專屬語法:子元件要「借用並回寫」父元件的一筆資料,也就是元件版的 v-model。下一節就來看。