Appearance
元件的 v-model 與 defineModel
上一章最後把原生文字輸入框的 v-model 拆成 v-bind:value 與 v-on:input:值往元素流,事件帶著新值流回來。元件版的 v-model 延續同一個收發迴路,只是兩端換成 props 與 emits。上一節的結尾也遇到了實際需求:有一種父子溝通模式,是子元件要「借用並回寫」父元件的一筆資料。用 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 事件,由父元件自己完成更新。單向資料流沒有被打破,只是這套收發被巨集藏起來了。
理解這層契約有兩個實際的好處:除錯時,知道 Vue DevTools 的事件紀錄裡要找的是 update:modelValue;讀舊程式碼時,能認出各種手工版的 v-model。手工版長什麼樣子?Options API 時代是 props: ['modelValue'] 加 emit('update:modelValue', ...);而 3.4 之前的 <script setup>,慣用寫法是用第二章教過的 writable computed,把「讀 prop、寫則 emit」包成一個可讀可寫的值:
vue
<script setup>
import { computed } from 'vue'
const props = defineProps({ modelValue: Number })
const emit = defineEmits(['update:modelValue'])
const quantity = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
</script>這個 quantity 用起來跟 defineModel() 回傳的 ref 幾乎一樣,但讀與寫各走一條路:
- 讀走
get:quantity.value拿到的是父元件當下傳進來的props.modelValue。 - 寫走
set:對quantity.value賦值(例如quantity.value++)並不會直接改動任何資料,只是把新值透過update:modelValue事件發出去。父元件收到、更新自己的資料,新的值再順著 props 流回get,讀到的才跟著變。
繞完這一圈,值才真正改變,單向資料流從頭到尾沒有被打破。對照之下,const quantity = defineModel() 一行做的正是這三段宣告的總和:modelValue prop、update:modelValue 事件、get/set 的包裝,全由巨集代勞。新程式碼直接用 defineModel() 即可;讀 3.4 之前的專案時,看到「get 讀 modelValue、set 發 update:modelValue」形狀的 computed,要能認出它就是手工版的元件 v-model。
常見錯誤:v-model 綁物件或陣列,就地修改不會觸發更新事件
上一節的單向資料流警告提過:物件與陣列的 prop,傳的是參照。放到 v-model 的場景,同一個雷有個更隱蔽的變形。假設子元件綁的是一個陣列:
js
const items = defineModel() // 父元件:<CartList v-model="cartItems" />
items.value.push(newBook) // 畫面會動,但 update:modelValue 沒有發出去
items.value[0].qty = 2 // 同上items.value 讀到的是父元件那個陣列的參照,push 或改屬性是就地修改:畫面看起來正常(因為改到的就是父元件手上的資料),但從頭到尾沒有走到「寫」的那條路,也就沒有 update:modelValue 事件。Vue DevTools 的事件紀錄裡毫無痕跡,「這筆資料在哪裡被改動」又變成無從追查。手工版的 computed 寫法行為完全相同:就地修改不會經過 set。
要讓更新走契約,就對 .value 賦一個新值,讓「寫」這條路有機會執行:
js
items.value = [...items.value, newBook] // 賦新值,update:modelValue 才會發出去或者退一步想:如果一筆資料常常需要在子元件裡就地修改,通常代表擁有權的界線畫錯了。這時更適合回到上一節的做法:資料留在父元件,子元件 emit 具體的事件(例如 add-item),由擁有資料的父元件來改。
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 都看過了。最後來看元件在建立、更新與卸載時,程式可以在哪些時間點介入,也就是元件的生命週期。
