iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Modern Web

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

Vue 走過路過不要錯過 Day13 -v-model 表單綁定:input、checkbox、select 與修飾符

  • 分享至 

  • xImage
  •  

登入、註冊、搜尋、篩選條件,幾乎每個專案都少不了表單。只要有使用者輸入,就得處理一件事:畫面上輸入框裡的值,要怎麼跟程式裡的資料保持一致?

v-model 大家都會用,但先來試試這三題:

  1. 多個 checkbox 綁同一個變數,這個變數除了陣列,還能用什麼型別?
  2. v-model.trim 只會修掉資料的空白,還是連輸入框上顯示的文字也會變?
  3. select 用 :value 綁物件時,初始值給一個「內容相同,但不是同一個」的物件,會被選中嗎?

三題都有把握的話,可以直接找文中的「💡 進階補充」區塊來看。沒把握也沒關係,我們從頭開始。


一、沒有 v-model 的時候

先用原生 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 會依元素類型換一套做法

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

三、input 與 textarea

<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 時同步,本來就不會被組字過程干擾。


四、checkbox 與 radio

單一 checkbox:綁 boolean

<input type="checkbox" v-model="agree" />

如果後端要的不是 true / false,可以用 true-value 和 false-value 指定:

<input
  type="checkbox"
  v-model="subscribe"
  true-value="yes"
  false-value="no"
/>

多個 checkbox:綁同一個陣列

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

radio:綁單一值

<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 必須是同一個物件參考才會被判定為勾選。


五、select

<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,並綁定陣列即可。

value 也可以綁物件

原生 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 的話,可以這樣對照: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 事件。

所以:

  • React 的 onChange ≈ Vue 預設的 v-model(監聽 input)
  • Vue 的 .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> 裡的資料怎麼變化。

挑戰:三種 UI 共用同一份資料

同一個 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 初始值的原因:資料才是唯一的真相來源。



上一篇
Vue 走過路過不要錯過 Day12 -v-on 與事件修飾符:.prevent、.stop 幫你省下的那幾行
下一篇
Vue 走過路過不要錯過 Day14 -想直接操作 DOM 的時候:模板 ref 與 useTemplateRef
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言