v-model 雙向綁定的原理
v-model 是 Vue 面試裡非常常見的題目,因為它看起來簡單,實際上可以一路追問到資料流、元件設計、表單封裝與編譯轉換。
一句話回答:v-model 是一個語法糖,它把「資料傳入」和「事件更新」包在一起,讓表單狀態可以用更直覺的方式同步。
一、 必考觀念
1. v-model 解決什麼問題?
在沒有 v-model 時,表單狀態通常要自己處理兩件事:
- 把狀態傳給輸入框。
- 監聽輸入事件,手動把新值寫回狀態。
<template>
<input
:value="username"
@input="username = $event.target.value"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const username = ref('')
</script>使用 v-model 後,可以寫成:
<template>
<input v-model="username" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
const username = ref('')
</script>這兩段在心智模型上是同一件事:畫面顯示來自狀態,使用者輸入再更新狀態。
2. 原生表單元素上的 v-model
v-model 會根據不同表單元素,使用不同的 DOM 屬性與事件。
| 元素 | 綁定重點 | 常見事件 |
|---|---|---|
input[type="text"]、textarea | value | input |
input[type="checkbox"] | checked 或陣列值 | change |
input[type="radio"] | checked 與 value 比對 | change |
select | 選中的 value | change |
所以 v-model 不是永遠等於 :value + @input。在不同表單控制項上,Vue 會使用符合該元素語義的屬性與事件。
例如文字輸入:
<input v-model="message" />可以粗略理解成:
<input
:value="message"
@input="message = $event.target.value"
/>但 checkbox 更接近:
<input
type="checkbox"
:checked="enabled"
@change="enabled = $event.target.checked"
/>這也是面試常見陷阱:不要把所有 v-model 都背成 value 和 input。
3. 元件上的 v-model
自訂元件上的 v-model 本質是 Props 加 Emit。
<BaseInput v-model="title" />在 Vue 3 中,會展開成:
<BaseInput
:model-value="title"
@update:model-value="title = $event"
/>因此子元件應該接收 modelValue,並在需要更新時發出 update:modelValue:
<!-- BaseInput.vue -->
<template>
<input
:value="modelValue"
@input="handleInput"
/>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>父元件仍然是資料來源,子元件只是透過事件要求父元件更新。
4. v-model 和單向資料流衝突嗎?
不衝突。
v-model 看起來像雙向綁定,但在元件上仍然遵守單向資料流:
- 父元件把資料透過 Props 傳給子元件。
- 子元件透過 Emit 告訴父元件要更新。
- 父元件更新自己的狀態後,再把新值傳回子元件。
真正的資料修改權仍然在父元件。
這就是為什麼在子元件裡直接改 Props 不好。你應該發事件,而不是直接改父元件傳下來的資料。
5. 多個 v-model
Vue 3 支援在同一個元件上使用多個 v-model:
<UserNameInput
v-model:first-name="firstName"
v-model:last-name="lastName"
/>它會展開成:
<UserNameInput
:first-name="firstName"
@update:first-name="firstName = $event"
:last-name="lastName"
@update:last-name="lastName = $event"
/>子元件可以這樣寫:
<script setup lang="ts">
defineProps<{
firstName: string
lastName: string
}>()
const emit = defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
}>()
</script>多個 v-model 適合用在一個元件真的有多個並列狀態時,例如日期區間、姓名欄位、座標輸入。但如果狀態彼此高度耦合,直接傳一個物件可能更清楚。
6. defineModel
在新版本 Vue 中,defineModel 可以簡化元件裡接收與更新 v-model 的寫法。
傳統寫法:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>可以改成:
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>如果是具名 v-model:
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>defineModel 不會改變 v-model 的本質。它只是讓 modelValue 與 update:modelValue 的樣板程式碼變少。
二、 追問題庫
Q1:v-model 是不是等於雙向資料流?
不是。
對表單元素來說,它確實讓輸入值和狀態保持同步;但對元件來說,它本質仍是單向資料流加事件回報。更精準的說法是:v-model 是雙向綁定的使用體驗,不是任意互改資料的雙向資料流。
Q2:Vue 2 和 Vue 3 的元件 v-model 差在哪?
Vue 2 預設使用:
<Child
:value="text"
@input="text = $event"
/>Vue 3 預設使用:
<Child
:model-value="text"
@update:model-value="text = $event"
/>Vue 3 的命名更明確,也支援多個 v-model,比較適合複雜元件封裝。
Q3:.lazy、.number、.trim 是什麼?
這些是 v-model 修飾符:
| 修飾符 | 行為 |
|---|---|
.lazy | 改用 change 時機更新,而不是輸入時立即更新 |
.number | 嘗試把輸入值轉成數字 |
.trim | 自動去掉前後空白 |
<input v-model.trim="username" />
<input v-model.number="age" />
<input v-model.lazy="keyword" />實務上 .number 要小心空字串與無法轉換的內容。它是便利工具,不是完整的資料驗證方案。
Q4:輸入中文時,v-model 會不會每個注音都更新?
Vue 對文字輸入會處理輸入法組字狀態。使用中文、日文、韓文等 IME 時,組字尚未完成前,不應該把每個中間狀態都當成最終輸入值。
如果你自己手寫 @input,在搜尋框、即時驗證這類場景中,就要注意組字事件造成的中間狀態。
Q5:子元件裡可以直接改 modelValue 嗎?
不應該。
modelValue 是父元件傳入的 Prop。子元件直接改它,會違反 Props 單向傳遞,也容易在父元件重新渲染時被覆蓋。
錯誤概念:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
function updateValue() {
props.modelValue = 'new value'
}
</script>正確做法是發出更新事件:
<script setup lang="ts">
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function updateValue() {
emit('update:modelValue', 'new value')
}
</script>Q6:物件型 v-model 有什麼陷阱?
如果 v-model 傳的是物件,子元件雖然不能直接替換 Prop,但仍可能改到物件內部屬性:
<!-- 不建議:子元件直接改物件內部 -->
<script setup lang="ts">
const props = defineProps<{
modelValue: {
name: string
}
}>()
function rename() {
props.modelValue.name = 'Hirimu'
}
</script>這會讓資料流變得不透明。比較穩的方式是建立新物件再 emit:
emit('update:modelValue', {
...props.modelValue,
name: 'Hirimu',
})這樣父元件能清楚知道更新發生了,也比較利於除錯、測試與狀態追蹤。
三、 實作題
題目:手寫一個支援 v-model 的輸入元件
需求:
- 父元件可以用
v-model綁定字串。 - 子元件內部使用原生
input。 - 輸入時把新值回傳給父元件。
- 支援清空按鈕。
父元件使用方式:
<template>
<BaseTextInput v-model="title" />
<p>目前輸入:{{ title }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import BaseTextInput from './BaseTextInput.vue'
const title = ref('')
</script>子元件實作:
<!-- BaseTextInput.vue -->
<template>
<label>
<span>標題</span>
<input
:value="modelValue"
@input="handleInput"
/>
</label>
<button
type="button"
@click="clear"
>
清空
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
function clear() {
emit('update:modelValue', '')
}
</script>如果用 defineModel,可以更短:
<template>
<label>
<span>標題</span>
<input v-model="model" />
</label>
<button
type="button"
@click="model = ''"
>
清空
</button>
</template>
<script setup lang="ts">
const model = defineModel<string>({ default: '' })
</script>這兩種寫法都可以。面試時建議先講傳統展開形式,因為那能證明你真的理解 v-model 的原理;再補充 defineModel 是新寫法,用來減少樣板程式碼。
四、 資深視角
1. v-model 是元件 API 設計,不只是語法糖
當你封裝一個表單元件時,是否提供 v-model 其實是在設計元件的控制方式。
適合用 v-model 的情境:
- 元件有明確的主要值,例如輸入框、日期選擇器、開關。
- 父元件需要掌控該值。
- 子元件負責互動細節,但不應該擁有最終資料來源。
不一定適合用 v-model 的情境:
- 元件內部狀態很多,而且沒有單一主值。
- 狀態只和元件自身 UI 有關,例如 dropdown 是否展開。
- 更新流程需要明確命令,例如提交、重置、批次套用。
2. 不要濫用多個 v-model
多個 v-model 很方便,但太多會讓元件 API 變得零碎。
例如:
<AdvancedSearch
v-model:keyword="keyword"
v-model:category="category"
v-model:sort="sort"
v-model:page="page"
v-model:page-size="pageSize"
/>這種情況可以思考是否改成:
<AdvancedSearch v-model="query" />讓 query 成為一個完整搜尋條件物件。選哪一種沒有絕對答案,重點是資料邊界要一致,更新語意要清楚。
3. 表單元件要區分「暫存值」和「提交值」
有些元件不適合使用者一輸入就更新父層狀態,例如複雜篩選器、草稿編輯器、多步驟表單。這時可以讓子元件內部維護暫存值,等使用者按下套用後再 emit。
這種設計不違反 v-model,而是表示你在區分兩種狀態:
- Draft:使用者正在編輯的暫存狀態。
- Committed Value:父元件認可並使用的正式狀態。
資深面試官常看的不是你會不會寫 v-model,而是你能不能判斷「什麼狀態應該立刻同步,什麼狀態應該延後提交」。
總結
v-model 可以用這張表整理:
| 場景 | 原理 |
|---|---|
| 原生文字輸入 | value 加 input 事件 |
| checkbox / radio / select | 依元素語義使用 checked、value、change 等 |
| 自訂元件 | Prop 加 update:* 事件 |
| 預設元件 v-model | modelValue 加 update:modelValue |
| 具名 v-model | 指定 Prop 名稱與對應 update:name 事件 |
defineModel | 簡化 Props 與 Emit 的宣告 |
一句完整的面試回答可以是:
v-model是 Vue 提供的雙向綁定語法糖。用在原生表單元素時,Vue 會根據元素類型綁定對應的 DOM 屬性和事件;用在自訂元件時,它會展開成一個 Prop 和一個update:*事件。它提供雙向同步的使用體驗,但元件層面仍然遵守 Props 向下、事件向上的單向資料流。