Day 13 我們把 v-model 綁在 input、checkbox、select 這些原生表單元素上,打字、勾選、下拉,資料就自動同步。
今天的問題是:如果要綁定的不是原生 input,而是我們自己寫的組件呢?
例如你做了一個好看的輸入框組件 MyInput,你會很自然地想這樣寫:
<MyInput v-model="keyword" />
這是可以的。不過要讓它動起來,子組件裡面得做一些事。這件事在 Vue 3.4 以前有點囉嗦,3.4 之後多了一個叫 defineModel 的東西,讓它變得非常簡單。
如果你還不太確定「3.4 更新了什麼」,沒關係,這篇會從頭講起。
先回顧 Day 13。在原生 input 上,v-model 其實是一個「語法糖」,也就是 Vue 幫你把兩件事合成一個寫法:
<input v-model="text" />
<!-- 實際上等於 -->
<input
:value="text"
@input="text = $event.target.value"
/>
:value 把資料「放進去」@input 在使用者打字時把新值「拿回來」<MyInput v-model="text" />
<!-- 實際上等於 -->
<MyInput
:modelValue="text"
@update:modelValue="newValue => text = newValue"
/>
用白話說就是:
父層把值透過一個叫
modelValue的 prop 交給子組件,同時留一支「改值專線」叫update:modelValue。
子組件想改值時,不能自己動手改,而是打這支專線通知父層:「請幫我改成這個值」,由父層自己去改。
為什麼子組件不能自己改?因為 Vue 的規則是單向資料流:資料往下傳(props),事件往上報(emit)。資料的主人是父層,只有主人能改它。這樣資料出問題時,你永遠知道要去哪裡找。
在沒有 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。邏輯是對的,但為了「雙向綁定一個值」要寫三段程式碼,而且每個組件都要重來一次。
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>
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 在編譯時幫你把那三段樣板程式碼寫好。單向資料流的規則一點都沒變,只是不用你親手寫了。
如果父層只寫 <MyInput />,沒有綁 v-model,model 就會像一個普通的本地 ref,在組件內部自己存值,不會壞掉。
預設的 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 })
假設父層這樣寫:
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 的意思是「在通知父層之前,先把值加工一下」。回傳什麼,父層就收到什麼。
這是新手最常出事的地方。假設父層傳下來的是一整個表單物件:
// 父層
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),每個欄位各自綁定,就不會有這個問題。
最後用一個簡單的開關組件把今天的東西串起來:
<!-- 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 適合的情境是:這個組件的工作就是「編輯某一個值」,例如輸入框、開關、評分星星、日期選擇器。
如果子組件只是要通知父層「發生了某件事」,例如「按了刪除」、「按了送出」,那就不是雙向綁定的情境,用一般的 emit 會清楚很多:
<TodoItem @delete="removeTodo(item.id)" />
不要因為 defineModel 好寫,就什麼都塞進 v-model。
v-model,展開後就是 modelValue prop + update:modelValue 事件defineModel() 回傳一個 ref:讀它是讀 prop,改它會自動 emittype、required、default 和自訂修飾符default 不會同步回父層;物件型 model 要整個重新指定,不要直接改內部屬性