跳至主要內容
Skip to content

v-model 雙向綁定的原理

v-model 是 Vue 面試裡非常常見的題目,因為它看起來簡單,實際上可以一路追問到資料流、元件設計、表單封裝與編譯轉換。

一句話回答:v-model 是一個語法糖,它把「資料傳入」和「事件更新」包在一起,讓表單狀態可以用更直覺的方式同步。


一、 必考觀念

1. v-model 解決什麼問題?

在沒有 v-model 時,表單狀態通常要自己處理兩件事:

  1. 把狀態傳給輸入框。
  2. 監聽輸入事件,手動把新值寫回狀態。
vue
<template>
  <input
    :value="username"
    @input="username = $event.target.value"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'

const username = ref('')
</script>

使用 v-model 後,可以寫成:

vue
<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"]textareavalueinput
input[type="checkbox"]checked 或陣列值change
input[type="radio"]checkedvalue 比對change
select選中的 valuechange

所以 v-model 不是永遠等於 :value + @input。在不同表單控制項上,Vue 會使用符合該元素語義的屬性與事件。

例如文字輸入:

vue
<input v-model="message" />

可以粗略理解成:

vue
<input
  :value="message"
  @input="message = $event.target.value"
/>

但 checkbox 更接近:

vue
<input
  type="checkbox"
  :checked="enabled"
  @change="enabled = $event.target.checked"
/>

這也是面試常見陷阱:不要把所有 v-model 都背成 valueinput

3. 元件上的 v-model

自訂元件上的 v-model 本質是 Props 加 Emit。

vue
<BaseInput v-model="title" />

在 Vue 3 中,會展開成:

vue
<BaseInput
  :model-value="title"
  @update:model-value="title = $event"
/>

因此子元件應該接收 modelValue,並在需要更新時發出 update:modelValue

vue
<!-- 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

vue
<UserNameInput
  v-model:first-name="firstName"
  v-model:last-name="lastName"
/>

它會展開成:

vue
<UserNameInput
  :first-name="firstName"
  @update:first-name="firstName = $event"
  :last-name="lastName"
  @update:last-name="lastName = $event"
/>

子元件可以這樣寫:

vue
<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 的寫法。

傳統寫法:

vue
<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

可以改成:

vue
<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

如果是具名 v-model

vue
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>

defineModel 不會改變 v-model 的本質。它只是讓 modelValueupdate:modelValue 的樣板程式碼變少。


二、 追問題庫

Q1:v-model 是不是等於雙向資料流?

不是。

對表單元素來說,它確實讓輸入值和狀態保持同步;但對元件來說,它本質仍是單向資料流加事件回報。更精準的說法是:v-model 是雙向綁定的使用體驗,不是任意互改資料的雙向資料流。

Q2:Vue 2 和 Vue 3 的元件 v-model 差在哪?

Vue 2 預設使用:

vue
<Child
  :value="text"
  @input="text = $event"
/>

Vue 3 預設使用:

vue
<Child
  :model-value="text"
  @update:model-value="text = $event"
/>

Vue 3 的命名更明確,也支援多個 v-model,比較適合複雜元件封裝。

Q3:.lazy.number.trim 是什麼?

這些是 v-model 修飾符:

修飾符行為
.lazy改用 change 時機更新,而不是輸入時立即更新
.number嘗試把輸入值轉成數字
.trim自動去掉前後空白
vue
<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 單向傳遞,也容易在父元件重新渲染時被覆蓋。

錯誤概念:

vue
<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

function updateValue() {
  props.modelValue = 'new value'
}
</script>

正確做法是發出更新事件:

vue
<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,但仍可能改到物件內部屬性:

vue
<!-- 不建議:子元件直接改物件內部 -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: {
    name: string
  }
}>()

function rename() {
  props.modelValue.name = 'Hirimu'
}
</script>

這會讓資料流變得不透明。比較穩的方式是建立新物件再 emit:

typescript
emit('update:modelValue', {
  ...props.modelValue,
  name: 'Hirimu',
})

這樣父元件能清楚知道更新發生了,也比較利於除錯、測試與狀態追蹤。


三、 實作題

題目:手寫一個支援 v-model 的輸入元件

需求:

  • 父元件可以用 v-model 綁定字串。
  • 子元件內部使用原生 input
  • 輸入時把新值回傳給父元件。
  • 支援清空按鈕。

父元件使用方式:

vue
<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>

子元件實作:

vue
<!-- 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,可以更短:

vue
<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 變得零碎。

例如:

vue
<AdvancedSearch
  v-model:keyword="keyword"
  v-model:category="category"
  v-model:sort="sort"
  v-model:page="page"
  v-model:page-size="pageSize"
/>

這種情況可以思考是否改成:

vue
<AdvancedSearch v-model="query" />

query 成為一個完整搜尋條件物件。選哪一種沒有絕對答案,重點是資料邊界要一致,更新語意要清楚。

3. 表單元件要區分「暫存值」和「提交值」

有些元件不適合使用者一輸入就更新父層狀態,例如複雜篩選器、草稿編輯器、多步驟表單。這時可以讓子元件內部維護暫存值,等使用者按下套用後再 emit。

這種設計不違反 v-model,而是表示你在區分兩種狀態:

  • Draft:使用者正在編輯的暫存狀態。
  • Committed Value:父元件認可並使用的正式狀態。

資深面試官常看的不是你會不會寫 v-model,而是你能不能判斷「什麼狀態應該立刻同步,什麼狀態應該延後提交」。


總結

v-model 可以用這張表整理:

場景原理
原生文字輸入valueinput 事件
checkbox / radio / select依元素語義使用 checkedvaluechange
自訂元件Prop 加 update:* 事件
預設元件 v-modelmodelValueupdate:modelValue
具名 v-model指定 Prop 名稱與對應 update:name 事件
defineModel簡化 Props 與 Emit 的宣告

一句完整的面試回答可以是:

v-model 是 Vue 提供的雙向綁定語法糖。用在原生表單元素時,Vue 會根據元素類型綁定對應的 DOM 屬性和事件;用在自訂元件時,它會展開成一個 Prop 和一個 update:* 事件。它提供雙向同步的使用體驗,但元件層面仍然遵守 Props 向下、事件向上的單向資料流。

延伸閱讀