Skip to content

元件的 v-model 與 defineModel

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

上一章學過原生表單元素的 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 的「另一端」接在父元件的資料上。

底層契約:modelValueupdate: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 都看過了。最後來看元件在建立、更新與卸載時,程式可以在哪些時間點介入,也就是元件的生命週期。