iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 18 篇

Vue 走過路過不要錯過 Day18 - 組件的 v-model:defineModel 讓雙向綁定變簡單

  • 分享至 

  • xImage
  •  

Day 13 我們把 v-model 綁在 input、checkbox、select 這些原生表單元素上,打字、勾選、下拉,資料就自動同步。

今天的問題是:如果要綁定的不是原生 input,而是我們自己寫的組件呢?

例如你做了一個好看的輸入框組件 MyInput,你會很自然地想這樣寫:

<MyInput v-model="keyword" />

這是可以的。不過要讓它動起來,子組件裡面得做一些事。這件事在 Vue 3.4 以前有點囉嗦,3.4 之後多了一個叫 defineModel 的東西,讓它變得非常簡單。

如果你還不太確定「3.4 更新了什麼」,沒關係,這篇會從頭講起。


v-model 到底幫我們做了什麼?

先回顧 Day 13。在原生 input 上,v-model 其實是一個「語法糖」,也就是 Vue 幫你把兩件事合成一個寫法:

<input v-model="text" />
 
<!-- 實際上等於 -->
<input
  :value="text"
  @input="text = $event.target.value"
/>
  1. 用 :value 把資料「放進去」
  2. 用 @input 在使用者打字時把新值「拿回來」
    那放到組件上呢?Vue 一樣會幫你展開,只是名字換了:
<MyInput v-model="text" />
 
<!-- 實際上等於 -->
<MyInput
  :modelValue="text"
  @update:modelValue="newValue => text = newValue"
/>

用白話說就是:

父層把值透過一個叫 modelValue 的 prop 交給子組件,同時留一支「改值專線」叫 update:modelValue。
子組件想改值時,不能自己動手改,而是打這支專線通知父層:「請幫我改成這個值」,由父層自己去改。

為什麼子組件不能自己改?因為 Vue 的規則是單向資料流:資料往下傳(props),事件往上報(emit)。資料的主人是父層,只有主人能改它。這樣資料出問題時,你永遠知道要去哪裡找。


3.4 以前:自己把專線接好

在沒有 defineModel 的時候,子組件要自己宣告「我會收 modelValue」,也要自己宣告「我會打 update:modelValue 這支專線」:

<!-- MyInput.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
 
<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

這樣可以動,但有兩個麻煩。

麻煩一:想在子組件裡也寫 v-model,會被擋下來。

<!-- ❌ 這樣寫 Vue 會警告:props 是唯讀的 -->
<input v-model="props.modelValue" />

因為這等於子組件自己動手改父層的資料,違反了上面說的規則。

麻煩二:要繞過去,得再包一層 computed。

<script setup>
import { computed } from 'vue'
 
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
 
const value = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})
</script>
 
<template>
  <input v-model="value" />
</template>

讀的時候拿 props,寫的時候改成 emit。邏輯是對的,但為了「雙向綁定一個值」要寫三段程式碼,而且每個組件都要重來一次。


3.4 之後:defineModel 一行搞定

Vue 3.4 在 2023 年底發布,其中一個重點就是把 defineModel 從「實驗功能」轉為正式功能。

如果你的專案是最近用 npm create vue@latest 建的,版本早就超過 3.4,可以直接用。不確定的話,打開 package.json 看 "vue" 後面的版本號就知道了。

剛剛那一大段,現在變成這樣:

<!-- MyInput.vue -->
<script setup>
const model = defineModel()
</script>
 
<template>
  <input v-model="model" />
</template>

父層的寫法完全不用改:

<MyInput v-model="text" />

白話理解 defineModel():

它回傳一個 ref。
讀它,就是讀父層傳下來的值。
改它,就會自動幫你打專線通知父層。

所以在 script 裡也可以直接改:

<script setup>
const model = defineModel()
 
function clear() {
  model.value = '' // 自動 emit('update:modelValue', '')
}
</script>

拆開來看:底層其實還是 props + emit

defineModel 看起來像是「子組件終於可以直接改父層的資料了」,其實不是。

還記得 Day 2 用過的 SFC Playground 嗎?把上面的 MyInput.vue 貼進去,切到 JS 分頁看編譯結果,會看到類似這樣的東西:

props: {
  "modelValue": {},
  "modelModifiers": {}
},
emits: ["update:modelValue"],
setup(__props) {
  const model = _useModel(__props, "modelValue")
  // ...
}

modelValue prop 還在,update:modelValue emit 也還在。

defineModel 是一個「編譯器巨集」,意思是它不是執行時才跑的函式,而是 Vue 在編譯時幫你把那三段樣板程式碼寫好。單向資料流的規則一點都沒變,只是不用你親手寫了。

小彩蛋:父層沒傳 v-model 也能用

如果父層只寫 <MyInput />,沒有綁 v-model,model 就會像一個普通的本地 ref,在組件內部自己存值,不會壞掉。


進階一:幫 v-model 取名字,一次綁好幾個

預設的 v-model 只能綁一個值。如果一個組件要同時編輯「姓」和「名」,可以給 defineModel 一個名字:

<!-- UserName.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
 
<template>
  <input v-model="lastName" placeholder="姓" />
  <input v-model="firstName" placeholder="名" />
</template>

父層用 v-model:名字 來對應:

<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>

有名字的 model,背後對應的就是 firstName prop 和 update:firstName 事件,道理跟預設的一樣。


進階二:設定型別、必填、預設值

defineModel 可以接受跟 defineProps 一樣的選項:

// 預設的 v-model
const model = defineModel({ type: String, required: true })
 
// 有名字的 v-model
const count = defineModel('count', { type: Number, default: 0 })

⚠️ 小心 default 的坑

假設父層這樣寫:

const count = ref() // 沒給初始值,是 undefined
<Counter v-model:count="count" />

子組件設了 default: 0,畫面會顯示 0;但父層的 count 其實還是 undefined。兩邊的值不一樣了。

原因是:default 只是「父層沒給值時,子組件自己先用這個」,它不會回頭幫父層設定初始值。

最簡單的解法:資料的初始值交給父層決定,子組件的 default 只當作保險。


進階三:修飾符

Day 13 學過 .trim、.number 這些修飾符,好消息是它們綁在組件上也能用:

<MyInput v-model.trim="text" />

如果想做自己的修飾符,例如 .capitalize(首字大寫),可以把 defineModel 的回傳值拆成兩個,第二個就是父層用了哪些修飾符:

<!-- MyInput.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>
 
<template>
  <input v-model="model" />
</template>
<MyInput v-model.capitalize="text" />

set 的意思是「在通知父層之前,先把值加工一下」。回傳什麼,父層就收到什麼。


最容易踩的坑:當 model 是一個物件

這是新手最常出事的地方。假設父層傳下來的是一整個表單物件:

// 父層
const form = ref({ name: '', email: '' })
<UserForm v-model="form" />

子組件裡這樣寫:

const form = defineModel()
 
function updateName(name) {
  form.value.name = name // ❌ 看起來能動,但其實出事了
}

畫面會更新,也不會有任何警告,看起來一切正常。問題在哪?

前面說過,defineModel 只有在你重新指定 .value 的時候才會打專線。form.value.name = name 沒有重新指定 .value,只是改了物件裡面的某個屬性,所以根本沒有 emit。

那為什麼父層的資料還是變了?因為物件傳下來的是同一個參考,子組件等於拿著父層的物件直接改內容。

用白話比喻:父層請你幫忙看家,你沒打電話問一聲就直接把家具搬走了。結果是一樣的,但父層完全不知道是誰、在什麼時候動的。專案一大,這種資料就很難追。

正確的做法是整個換掉,而不是改裡面:

function updateName(name) {
  form.value = { ...form.value, name } // ✅ 重新指定 .value,會 emit
}

或者乾脆換個設計:把物件拆成好幾個有名字的 v-model(像前面的 first-name、last-name),每個欄位各自綁定,就不會有這個問題。


實作:做一個開關 Toggle

最後用一個簡單的開關組件把今天的東西串起來:

<!-- ToggleSwitch.vue -->
<script setup>
const checked = defineModel({ type: Boolean, default: false })
</script>
 
<template>
  <button
    type="button"
    class="toggle"
    :class="{ 'is-on': checked }"
    @click="checked = !checked"
  >
    {{ checked ? 'ON' : 'OFF' }}
  </button>
</template>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import ToggleSwitch from './ToggleSwitch.vue'
 
const darkMode = ref(false) // 初始值由父層決定
</script>
 
<template>
  <ToggleSwitch v-model="darkMode" />
  <p>深色模式:{{ darkMode }}</p>
</template>

點按鈕時,checked = !checked 會觸發 emit,父層的 darkMode 跟著改,下面的文字也一起更新。

同樣的寫法也很適合 Day 14 提過的 Modal:用 v-model:open 控制開關,父層一行就能決定 Modal 要不要顯示。


什麼時候不該用 v-model?

v-model 適合的情境是:這個組件的工作就是「編輯某一個值」,例如輸入框、開關、評分星星、日期選擇器。

如果子組件只是要通知父層「發生了某件事」,例如「按了刪除」、「按了送出」,那就不是雙向綁定的情境,用一般的 emit 會清楚很多:

<TodoItem @delete="removeTodo(item.id)" />

不要因為 defineModel 好寫,就什麼都塞進 v-model。


小結

  • 組件上的 v-model,展開後就是 modelValue prop + update:modelValue 事件
  • 3.4 以前要自己寫 props、emit,想在內部用 v-model 還要再包 computed
  • defineModel() 回傳一個 ref:讀它是讀 prop,改它會自動 emit
  • 它是編譯器巨集,底層還是 props + emit,單向資料流沒有改變
  • 可以取名字綁多個 v-model,也能設定 type、required、default 和自訂修飾符
  • 兩個要小心的地方:default 不會同步回父層;物件型 model 要整個重新指定,不要直接改內部屬性

上一篇
Vue 走過路過不要錯過 Day17 - emits:子組件怎麼把事情告訴父組件
下一篇
Vue 走過路過不要錯過 Day19 - slots:插槽其實是一個函式
系列文
重新認識Vue 走過路過不要錯過 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言