登入、註冊、搜尋、篩選條件,幾乎每個專案都少不了表單。只要有使用者輸入,就得處理一件事:畫面上輸入框裡的值,要怎麼跟程式裡的資料保持一致?
v-model 大家都會用,但先來試試這三題:
v-model.trim 只會修掉資料的空白,還是連輸入框上顯示的文字也會變?:value 綁物件時,初始值給一個「內容相同,但不是同一個」的物件,會被選中嗎?三題都有把握的話,可以直接找文中的「💡 進階補充」區塊來看。沒把握也沒關係,我們從頭開始。
先用原生 JS 做一件簡單的事:輸入框打字,下方同步顯示。
// <input id="name" />
// <p id="output"></p>
const input = document.querySelector('#name')
const output = document.querySelector('#output')
let name = ''
input.addEventListener('input', (e) => {
name = e.target.value // 畫面 → 資料
output.textContent = name // 資料 → 畫面
})
兩個方向都要自己處理。只要程式裡其他地方改了 name,就得記得回頭更新 input.value,不然畫面和資料就會對不上。
換成 Vue 之後,「資料 → 畫面」交給響應式系統處理,我們只需要負責「畫面 → 資料」:
<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
<template>
<input :value="name" @input="name = $event.target.value" />
<p>{{ name }}</p>
</template>
:value 加上 @input 的組合太常出現,所以 Vue 把它們合成一個指令:
<input v-model="name" />
v-model 不是永遠都展開成 :value 加 @input,而是看你綁在什麼元素上:
| 元素 | 綁定的屬性 | 監聽的事件 |
|---|---|---|
<input type="text">、<textarea> |
value |
input |
<input type="checkbox">、<input type="radio"> |
checked |
change |
<select> |
value |
change |
checkbox 綁的是 checked,因為勾選框真正會變動的狀態是「有沒有被勾起來」;value 只是勾選後代表的值,使用者並不會去改它。
把範例貼到 Vue SFC Playground,切到 JS 分頁,會看到類似這樣的編譯結果:
_withDirectives(_createElementVNode("input", {
"onUpdate:modelValue": $event => (name.value = $event)
}, null, 512), [
[_vModelText, name.value]
])
實際上負責處理的是內建指令 vModelText;換成 checkbox 會變成 vModelCheckbox,select 則是 vModelSelect。
💡 進階補充:動態 type
如果寫成
<input :type="inputType" v-model="x">,編譯時無法確定元素的類型,所以會改用vModelDynamic。它在執行期看tagName和type屬性,再轉給對應的指令處理。原始碼裡的resolveDynamicModel就是一個簡單的switch:function resolveDynamicModel(tagName, type) { switch (tagName) { case 'SELECT': return vModelSelect case 'TEXTAREA': return vModelText default: switch (type) { case 'checkbox': return vModelCheckbox case 'radio': return vModelRadio default: return vModelText } } }
<script setup>
import { ref } from 'vue'
const message = ref('')
const note = ref('')
</script>
<template>
<input v-model="message" />
<textarea v-model="note"></textarea>
</template>
textarea 裡寫 {{ note }} 插值是沒有效果的,一定要用 v-model。
在注音或拼音輸入法組字期間,v-model 不會更新資料。打「ㄋㄧˇ」還沒選字前,message 都不會變;選字完成變成「你」之後才會同步。
如果要做「邊打邊搜尋」這類需要即時反應的功能,就要改回手動綁定:
<input :value="keyword" @input="keyword = $event.target.value" />
💡 進階補充:它是怎麼辦到的
vModelText另外監聽了compositionstart和compositionend兩個事件:function onCompositionStart(e) { e.target.composing = true } function onCompositionEnd(e) { const target = e.target if (target.composing) { target.composing = false target.dispatchEvent(new Event('input')) } }組字開始時,在元素上掛一個
composing旗標,input監聽器看到旗標就直接return。組字結束時,Vue 會自己補發一次input事件,資料才更新。另外,加上
.lazy時這兩個監聽器根本不會被掛上去,因為.lazy只在change時同步,本來就不會被組字過程干擾。
<input type="checkbox" v-model="agree" />
如果後端要的不是 true / false,可以用 true-value 和 false-value 指定:
<input
type="checkbox"
v-model="subscribe"
true-value="yes"
false-value="no"
/>
<script setup>
import { ref } from 'vue'
const hobbies = ref([])
</script>
<template>
<label><input type="checkbox" value="閱讀" v-model="hobbies" /> 閱讀</label>
<label><input type="checkbox" value="運動" v-model="hobbies" /> 運動</label>
<label><input type="checkbox" value="音樂" v-model="hobbies" /> 音樂</label>
</template>
勾選的值會自動加入陣列,取消時自動移除,不用自己寫 push 或 splice。
<label><input type="radio" value="male" v-model="gender" /> 男</label>
<label><input type="radio" value="female" v-model="gender" /> 女</label>
同一組 radio 綁同一個變數,Vue 會自動處理互斥。
💡 進階補充:第 1 題,也可以綁 Set
vModelCheckbox在change時會先判斷資料的型別:if (isArray(modelValue)) { // 陣列:用 looseIndexOf 找位置,再 concat 或 splice } else if (isSet(modelValue)) { const cloned = new Set(modelValue) checked ? cloned.add(elementValue) : cloned.delete(elementValue) assign(cloned) } else { // 單一 checkbox:true / false 或 true-value / false-value }所以
const hobbies = ref(new Set())也能正常運作,而且select multiple同樣支援 Set。不過要注意比對方式不同:陣列用
looseIndexOf做深層比較,Set 用的是has,屬於參考比較。value 綁物件時,用 Set 必須是同一個物件參考才會被判定為勾選。
<script setup>
import { ref } from 'vue'
const city = ref('')
const cityOptions = [
{ id: 1, label: '台北', value: 'taipei' },
{ id: 2, label: '台中', value: 'taichung' },
{ id: 3, label: '台南', value: 'tainan' }
]
</script>
<template>
<select v-model="city">
<option disabled value="">請選擇城市</option>
<option v-for="item in cityOptions" :key="item.id" :value="item.value">
{{ item.label }}
</option>
</select>
</template>
那個 disabled 的空選項不只是提示文字。當 v-model 的初始值對不到任何 option 時,iOS 上會出現選不到第一個選項的問題,官方建議加上它。
多選的話加上 multiple,並綁定陣列即可。
原生 HTML 的 value 只能是字串,但在 Vue 裡用 :value 可以綁任何型別:
<select v-model="selectedCity">
<option v-for="item in cityOptions" :key="item.id" :value="item">
{{ item.label }}
</option>
</select>
選完之後 selectedCity 就是整個物件,不用再拿 id 回頭去陣列裡找。
💡 進階補充:第 3 題,會被選中
單選 select 在比對選項時用的是
looseEqual:} else if (looseEqual(getValue(option), value)) { if (el.selectedIndex !== i) el.selectedIndex = i return }
looseEqual會深層比較物件內容,所以就算初始值是從 API 拿回來的新物件,只要內容相同就會被選中,radio 也是一樣的邏輯。至於
getValue為什麼能拿到物件?因為 Vue 把:value綁定的原始值存在元素的_value屬性上,而不是轉成字串寫進 DOM。
| 修飾符 | 作用 |
|---|---|
.lazy |
改成監聽 change,也就是離開輸入框後才同步 |
.number |
嘗試轉成數字,轉不了就保留原值 |
.trim |
去掉頭尾空白 |
<input v-model.lazy.trim="username" />
<input v-model.number="age" />
另外,type="number" 的 input 會自動套用 .number,不需要另外加。
💡 進階補充:第 2 題,連畫面也會變
if (trim || castToNumber) { addEventListener(el, 'change', () => { el.value = castValue(el.value, trim, castToNumber) }) }打字期間輸入框保留你打的空白,但一離開輸入框觸發
change,Vue 就會把畫面上的值也一起修掉。.number也是一樣,失焦後顯示值會變成轉換後的結果。還有一點:
.number內部用的是looseToNumber,本質上就是parseFloat,轉出NaN時才保留原值。所以輸入'12abc'會得到12,而不是維持字串。如果需要嚴格驗證,還是要自己處理。
1. HTML 上寫的初始值會被忽略
<!-- ❌ value="預設文字" 不會出現 -->
<input v-model="message" value="預設文字" />
v-model 會忽略元素上的 value、checked、selected 屬性,初始值要寫在 ref 裡。
2. 數字欄位清空會得到空字串
就算是 type="number",使用者把欄位清空時拿到的也是 '',送出前記得處理。
3. 不能直接對 props 用 v-model
props 是單向資料流,子元件不能直接修改。自訂元件要雙向綁定,得透過元件的 v-model 機制(Vue 3.4 之後可以用 defineModel)。
有寫過 React 的話,可以這樣對照:React 沒有 v-model,每個輸入框都要自己寫 value 加 onChange,也就是所謂的受控元件。
function Form() {
const [name, setName] = useState('')
return <input value={name} onChange={e => setName(e.target.value)} />
}
有個容易搞混的地方:React 的 onChange 每打一個字就會觸發,行為對應的是原生的 input 事件,而不是原生的 change 事件。
所以:
onChange ≈ Vue 預設的 v-model(監聽 input).lazy(監聽原生 change)在 React 裡沒有直接對應,要改用 onBlur 之類的方式自己處理兩者的核心想法一樣,都是「輸入框的值由狀態決定」。差別在於 Vue 把常見的模式包成指令,還順手處理了中文輸入法組字、型別轉換這些細節;React 則把這些都交給開發者自己寫。
<script setup>
import { ref } from 'vue'
const form = ref({
name: '',
age: null,
hobbies: [],
city: '',
agree: false
})
</script>
<template>
<form @submit.prevent="console.log(form)">
<input v-model.trim="form.name" placeholder="姓名" />
<input type="number" v-model="form.age" placeholder="年齡" />
<label><input type="checkbox" value="閱讀" v-model="form.hobbies" /> 閱讀</label>
<label><input type="checkbox" value="運動" v-model="form.hobbies" /> 運動</label>
<select v-model="form.city">
<option disabled value="">請選擇城市</option>
<option value="taipei">台北</option>
<option value="tainan">台南</option>
</select>
<label><input type="checkbox" v-model="form.agree" /> 我同意服務條款</label>
<button type="submit">送出</button>
</form>
<pre>{{ form }}</pre>
</template>
可以試試看:姓名前後多打空白再離開輸入框、清空年齡欄位,觀察 <pre> 裡的資料怎麼變化。
同一個 selected 陣列,同時綁給一排 checkbox、一個 select multiple,以及一排可以點 × 移除的標籤。
<script setup>
import { ref } from 'vue'
const cityOptions = [
{ id: 1, label: '台北', value: 'taipei' },
{ id: 2, label: '台中', value: 'taichung' },
{ id: 3, label: '台南', value: 'tainan' }
]
const selected = ref([])
const labelOf = (value) => cityOptions.find(c => c.value === value)?.label
function remove(value) {
selected.value = selected.value.filter(v => v !== value)
}
</script>
<template>
<label v-for="item in cityOptions" :key="item.id">
<input type="checkbox" :value="item.value" v-model="selected" />
{{ item.label }}
</label>
<select v-model="selected" multiple>
<option v-for="item in cityOptions" :key="item.id" :value="item.value">
{{ item.label }}
</option>
</select>
<span v-for="value in selected" :key="value">
{{ labelOf(value) }}
<button @click="remove(value)">×</button>
</span>
</template>
動手之前先想想:在 checkbox 勾選「台南」,select 裡的台南會不會被選起來?點標籤的 × 呢?
三個 UI 都只是同一個陣列的投影。不管從哪裡改,改的都是 selected,其他兩個自然就會跟著更新。這也是 v-model 會忽略 HTML 初始值的原因:資料才是唯一的真相來源。