假設你在做一個商品列表頁,每個商品都要顯示名稱、價格。最直覺的做法是把卡片的 HTML 直接複製貼上,再一張張改文字。商品只有三個的時候還撐得住,等到變成三十個,改一個樣式就要改三十個地方。
Day 15 我們已經學會把畫面拆成組件。拆出來的卡片組件只負責「長什麼樣子」,至於「顯示哪一個商品」,應該交給使用它的人決定。這個「由外面把資料傳進來」的機制,就是 props。
如果一份資料不是全域都需要,只有某一塊畫面用得到,最自然的做法是由父組件握有這份資料,需要的時候再傳給子組件。子組件不用知道資料從哪來,只管拿到什麼就顯示什麼。這樣同一個組件餵不同的資料,就能長出不同的內容,復用起來也很輕鬆。
相對地,像是登入狀態、購物車這類很多頁面都要讀寫的資料,就不適合一層一層傳下去,那是全域狀態要處理的事(Pinia 之後會談到)。判斷的方式很簡單:只有這個組件和它的上下層用得到,就用 props;很多地方都要用,再考慮全域。
在 <script setup> 裡,用 defineProps 宣告這個組件接受哪些 props。它是編譯巨集,不需要 import 就能直接使用。
<script setup>
const props = defineProps({
title: { type: String, required: true },
price: { type: Number, default: 0 },
tags: { type: Array, default: () => [] }
})
</script>
<template>
<div class="card">
<h3>{{ title }}</h3>
<p>NT$ {{ price }}</p>
</div>
</template>
幾個常見的重點:
title,不用寫 props.title;在 script 裡則要透過 props.title 存取。type 讓 Vue 在開發模式下幫你檢查型別,傳錯會在 console 出現警告。required: true 代表一定要傳,default 是沒傳時的預設值。() => []),避免多個組件實例共用同一個參考。父組件這邊這樣使用:
<script setup>
import { ref } from 'vue'
import ProductCard from './ProductCard.vue'
const products = ref([
{ id: 1, title: '安全帽 A 款', price: 1200 },
{ id: 2, title: '安全帽 B 款', price: 1500 }
])
</script>
<template>
<ProductCard
v-for="item in products"
:key="item.id"
:title="item.title"
:price="item.price"
/>
</template>
這裡有個新手常踩的小地方:如果寫成 price="1200"(沒有冒號),傳進去的是字串 "1200",會和 Number 型別對不上而出現警告。要傳「JS 的值」而不是純文字,記得加上 :(也就是 v-bind)。
Vue 對 props 訂了一條規矩:資料由父組件流向子組件,子組件不應該反過來修改它。
先看最直接的違規寫法:
<script setup>
const props = defineProps({
count: Number
})
function add() {
props.count++ // 直接改 props
}
</script>
這樣寫,開發模式下 Vue 會在 console 警告 props 是唯讀的,值也不會被改掉。
但事情沒有這麼單純。如果傳進來的是物件呢?
<script setup>
const props = defineProps({
product: { type: Object, required: true }
})
function toggleFavorite() {
props.product.isFavorite = true // 改的是物件內部的屬性
}
</script>
這一次 Vue 不會警告,而且畫面還真的會更新。原因是 props 的唯讀只擋「整個 prop 被重新賦值」這一層,物件內部的屬性並沒有被凍結。子組件拿到的其實是父組件那個物件的同一個參考,所以這一行改到的是父組件的資料。
看起來能動,為什麼還是不好?
isFavorite 是誰改的,你得翻遍所有拿到這個物件的子組件。單向資料流的意義就在這裡:資料的修改權集中在擁有它的那一層,任何變動都能循著同一條路徑追蹤。
實務上常見的情況大致有三種,各有各的處理方式。
1. 只是把 props 當作初始值
例如傳進來一個初始數量,子組件之後自己管理。這時候複製一份到本地的 ref:
<script setup>
import { ref } from 'vue'
const props = defineProps({
initialCount: { type: Number, default: 0 }
})
const count = ref(props.initialCount)
</script>
要注意這只會在建立時複製一次,之後父組件再改 initialCount,本地的 count 不會跟著變。
2. 需要根據 props 算出另一個值
例如傳進來原價,卡片要顯示打折後的價格。這種情況用 computed,會跟著 props 的變化自動更新:
<script setup>
import { computed } from 'vue'
const props = defineProps({
price: { type: Number, default: 0 }
})
const salePrice = computed(() => Math.round(props.price * 0.8))
</script>
3. 子組件想「請父組件」修改資料
像前面的收藏按鈕,子組件不自己改資料,而是通知父組件「使用者按了收藏」,由父組件決定要不要改、怎麼改。這正是 emit 的工作,下一篇會詳細介紹。這邊先記住核心觀念:props 往下傳,事件往上報。
Day 5 我們談過,一般的 reactive 物件解構後會失去響應式。props 也有類似的情況:如果在 script 裡用 const { title } = props 這種寫法取值,之後 title 就只是一個普通變數,不會再跟著更新。
Vue 3.5 之後,直接在 defineProps 上解構(const { title } = defineProps(...))會由編譯器幫你保持響應式。但如果你的專案版本較舊,或是在其他地方解構,就要改用 toRefs(props) 或直接使用 props.title。
defineProps 是編譯巨集,不需要 import,可以搭配 type、required、default 做基本驗證。