重新認識 Vue.js
剩餘 8 本
Appearance
第一節結尾的 <BookCard /> 有個問題:不管放幾個,內容都一模一樣。元件要能重複使用,就得能接收外部傳進來的資料,也得能把內部發生的事通知外面。前者靠 props,後者靠 emits,兩者合起來就是元件的輸入與輸出介面。
props 是父元件傳給子元件的資料。子元件用 defineProps() 宣告自己接收哪些 props,父元件在標籤上以屬性的形式傳入:
src/components/BookCard.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:
<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 可以直接用型別參數宣告,不需要執行期的物件:
<script setup lang="ts">
const props = defineProps<{
title: string
stock: number
}>()
</script>型別系統的完整討論留給第九章,這裡先知道有這個寫法即可。
defineProps() 傳入物件時,每個 prop 除了型別,還可以宣告是否必填、預設值與自訂驗證:
src/components/BookCard.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 有一條特別規定:必須用函式回傳預設值:
const props = defineProps({
tags: {
type: Array,
default: () => [] // 正確:每個元件實體各拿到一份新陣列
// default: [] // 錯誤:Vue 會在 console 警告
}
})原因跟參照有關:如果直接寫 default: [],所有沒傳這個 prop 的元件實體,拿到的會是同一個陣列,一個實體改了、其他實體全部遭殃。工廠函式讓每個實體各自拿到一份新的預設值。
驗證失敗(例如漏傳 title,或 stock 傳了負數)不會讓程式掛掉:console 會出現警告,但那個值照樣會被接受、拿去渲染,validator 不會擋下或替換它。這些警告值得認真對待:它們把「元件被用錯」的問題在開發階段就暴露出來,而不是等到畫面壞掉才回頭查。
也因此要認清它的定位:props 驗證是開發階段的輔助,正式環境 (production build) 不會執行這些檢查。它防的是「同事把元件用錯」,不是「使用者傳來壞資料」;真正不可信任的輸入(例如 API 回應),要在資料進入元件之前另外處理。
props 的資料流向是單向的:父元件流向子元件。父元件更新,子元件的 props 跟著更新;反過來不成立,子元件不應該修改 props。
常見錯誤:直接修改 props
「售出一本,庫存減一」,直覺可能會在子元件裡這樣寫:
function sell() {
props.stock-- // 開發模式下 console 會警告,而且父元件的資料不會變
}props 是唯讀的:像上面這樣直接對 prop 賦值,開發模式下 console 會警告,父元件的資料也不會變。
物件與陣列不同,prop 傳的是參照。子元件修改 props.book.stock 時,Vue 不會攔截,父元件手上的同一個物件也會跟著改。程式看似能跑,但之後要追查「這筆資料在哪裡被改動」就麻煩了。
正確的做法依需求二選一:
computed,例如 const localStock = ref(props.stock) 或 const label = computed(() => props.title.toUpperCase())。簡單記:資料由父元件擁有,就由父元件修改;子元件只負責回報事件。
前一節才說過「解構 reactive() 的原始型別屬性會中斷響應性」,而 defineProps() 回傳的 props 物件也是響應式的,那解構 props 也會斷嗎?
在 Vue 3.5 之前會,所以舊程式碼都乖乖寫 props.title。Vue 3.5 之後,在同一個 <script setup> 裡解構 defineProps() 可以保持響應性,還能順便宣告預設值:
src/components/BookCard.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:
watch(stock, () => { /* ... */ }) // 錯誤:傳給 watch 的是當下的數字
watch(() => stock, () => { /* ... */ }) // 正確:包成 getter,watch 才追蹤得到如果函式只是要拿當下的值做一次性計算,直接傳值即可,不必包 getter。
輸入解決了,來處理輸出。子元件用 defineEmits() 宣告會發出哪些事件,需要時呼叫 emit 往上送,父元件用 @事件名 接聽,跟監聽 DOM 事件的寫法一致:
src/components/BookCard.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:
<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>剩餘 8 本
已售完
按下「購買」,子元件發出 purchase 事件並附上書名;父元件收到後更新自己的 message。注意分工:庫存與訂單資料都在父元件手上,子元件只負責「回報發生了什麼事」,這正是上一小節單向資料流的完整樣貌:props 往下、events 往上。
自訂事件的寫法跟 DOM 事件一致,行為卻有幾個關鍵差異:
emit 時附上的資料(範例中的書名字串);這裡沒有瀏覽器的 event 物件,自然也沒有 preventDefault() 可言,上一章的事件修飾子在這裡也多半用不到。emit('addToCart')),父元件模板可以用 kebab-case 監聽(@add-to-cart),Vue 會自動轉換,這也是模板裡慣用的寫法。TypeScript 版本:用型別宣告 emits
defineEmits 同樣可以用型別參數,宣告每個事件的參數型別:
<script setup lang="ts">
const emit = defineEmits<{
purchase: [title: string]
}>()
</script>同樣地,細節留給第九章。
props 搭配 emits,已經能應付大多數的父子溝通。不過有一種模式常見到 Vue 特地為它做了專屬語法:子元件要「借用並回寫」父元件的一筆資料,也就是元件版的 v-model。下一節就來看。