Appearance
元件的 v-model 與 defineModel
上一章學過原生表單元素的 v-model,當時我們預告了它在元件上的用法。上一節的結尾也遇到了實際需求:有一種父子溝通模式,是子元件要「借用並回寫」父元件的一筆資料。用 props 加 emits 當然做得到,但這個模式太常見,Vue 為它準備了元件版的 v-model。
元件也能用 v-model
想像一個訂購數量的元件:父元件擁有 quantity 這筆資料,子元件顯示數字、提供加減按鈕。依照單向資料流,子元件不能直接改 quantity,所以完整的迴路是:父元件把值用 prop 傳下來,子元件想改的時候 emit 事件,父元件收到再更新。
每個這種「值下去、更新上來」的迴路都要寫一對 prop 與 event,寫多了全是樣板。元件版的 v-model 就是把這對收發合併成一個綁定:
html
<QuantityPicker v-model="quantity" />跟原生表單元素的 v-model 一樣:quantity 的值會同步給子元件,子元件的修改也會同步回來。
defineModel() (Vue 3.4 新增)
子元件這邊,用 defineModel() 宣告「我是一個可以被 v-model 綁定的元件」:
src/components/QuantityPicker.vue:
vue
<script setup>
// 回傳 ref:script 裡用 .value 讀寫,模板會自動解包
const quantity = defineModel()
</script>
<template>
<div class="picker">
<button type="button" @click="quantity--">−</button>
<span>{{ quantity }}</span>
<button type="button" @click="quantity++">+</button>
</div>
</template>
<style scoped>
.picker {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
</style>src/App.vue:
vue
<script setup>
import { ref } from 'vue'
import QuantityPicker from './components/QuantityPicker.vue'
const quantity = ref(1)
</script>
<template>
<main class="app">
<label>
父元件數量
<input v-model.number="quantity" type="number" min="0">
</label>
<QuantityPicker v-model="quantity" />
<p>訂購 {{ quantity }} 本</p>
</main>
</template>
<style scoped>
.app {
padding: 2rem;
}
</style>1
訂購 1 本
按下加減按鈕,子元件裡的數字變了,父元件的 <p> 也同步更新;反過來,若父元件改了 quantity,子元件顯示的數字也會跟上。雙向都通了。
這裡最重要的觀念是:defineModel() 回傳的是一個 ref。所以在 <script setup> 裡讀寫它要用 .value,在模板裡則像其他頂層 ref 一樣自動解包(範例中的 quantity++ 之所以能動,就是模板解包在工作)。對子元件來說,它用起來幾乎就像一個自己的 ref,只是這個 ref 的「另一端」接在父元件的資料上。
底層契約:modelValue 與 update:modelValue
defineModel() 是 Vue 3.4 才有的巨集,但元件 v-model 這件事存在得更早。它的底層契約其實就是上一節學過的 props 與 emits:
- 父元件的
v-model="quantity",展開後是:modelValue="quantity"加上@update:modelValue="quantity = $event"。 - 子元件的
defineModel(),展開後是宣告一個名為modelValue的 prop,加上一個名為update:modelValue的事件;對回傳 ref 的賦值,就是在 emit 這個事件。
也就是說,quantity.value++ 並沒有直接改到父元件的資料,它發出了一個 update:modelValue 事件,由父元件自己完成更新。單向資料流沒有被打破,只是這套收發被巨集藏起來了。
理解這層契約有兩個實際的好處:讀舊程式碼時,會認得 props: ['modelValue'] 加 emit('update:modelValue', ...) 就是手工版的 v-model;除錯時,也知道 Vue DevTools 的事件紀錄裡要找的是 update:modelValue。
v-model 修飾子與預設值
defineModel() 可以像 props 一樣宣告型別與預設值:
js
const quantity = defineModel({ type: Number, default: 1 })常見錯誤:以為 default 會同步回父元件
default: 1 只在「父元件沒有透過 v-model 傳值」時,作為子元件這端的預設值。它不會回頭把父元件的資料也設成 1:如果父元件的 quantity 一開始是 undefined,此時子元件看到的是 1、父元件仍是 undefined,兩邊的值是不同步的,直到子元件第一次 emit 更新為止。
實務上建議讓資料的擁有者(父元件)負責給初始值,像前面範例的 ref(1),default 只作為元件單獨被使用時的後備。
元件的 v-model 也能掛自訂修飾子,子元件用陣列解構的形式取得:
js
const [quantity, modifiers] = defineModel()
modifiers.step5 // 父元件寫 v-model.step5 時為 true知道有這個入口即可,本章不展開。
v-model 的完整應用在表單章
一個元件可以有多個具名的 v-model (defineModel('title'))、搭配表單驗證與非同步狀態的做法,這些牽涉表單的整體設計,第八章「表單與資料流」會有完整的討論。
props、emits 與 defineModel 都看過了。最後來看元件在建立、更新與卸載時,程式可以在哪些時間點介入,也就是元件的生命週期。
