Skip to content

04|表单与 v-model:双向绑定不是数据流失踪案 ​

第一章概览 / 04。目标:理解 v-model 是“读取 value + 监听更新事件”的语法糖,能处理各种原生控件及值类型。

主例可保存为 Vue 3.5.x + Vite TypeScript 项目的 src/App.vue。请同时打开 Vue DevTools:页面外观只能说明控件被选中,DevTools 中的字符串、数字、布尔值或数组才是提交代码真正收到的输入。

本页能力矩阵 ​

控件v-model 对应的核心状态常见坑
text / textarea字符串空格、输入法组合输入、提交时 trim
checkbox布尔值或数组true-value / false-value 不是 HTML 提交值
radio一个选中值每项 value 必须稳定且语义明确
select单值或数组option 的值类型、异步选项、无匹配值
组件prop + update:* 事件不是子组件可以随意改父状态

把语法糖展开看一眼 ​

vue
<!-- 这两段在意图上相同。 -->
<input v-model="name" />

<input
  :value="name"
  @input="name = ($event.target as HTMLInputElement).value"
/>

Vue 按不同原生元素选择合适的 property 与事件:文本框使用 value / input,复选框常用 checked / change,选择器也有自己的更新语义。所以 v-model 很省手,但没有把“父状态 → 控件,用户操作 → 更新父状态”这条单向闭环变成黑洞。

一份类型不打架的表单 ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const displayName = ref('')
const bio = ref('')
const acceptedTerms = ref(false)
const skills = ref<string[]>([])
const plan = ref<'free' | 'pro'>('free')
const favoriteNumber = ref<number | string>('')
const roles = ref<string[]>(['reader'])

function submit() {
  if (!acceptedTerms.value || !displayName.value.trim()) return

  console.info({
    displayName: displayName.value.trim(),
    bio: bio.value,
    skills: skills.value,
    plan: plan.value,
    favoriteNumber: favoriteNumber.value,
    roles: roles.value
  })
}
</script>

<template>
  <form @submit.prevent="submit">
    <label>昵称 <input v-model="displayName" autocomplete="nickname" /></label>

    <label>简介 <textarea v-model="bio" rows="3"></textarea></label>

    <label><input v-model="acceptedTerms" type="checkbox" /> 我同意规则</label>

    <fieldset>
      <legend>技能(数组模型)</legend>
      <label><input v-model="skills" type="checkbox" value="vue" /> Vue</label>
      <label><input v-model="skills" type="checkbox" value="testing" /> 测试</label>
    </fieldset>

    <fieldset>
      <legend>套餐(单值模型)</legend>
      <label><input v-model="plan" type="radio" value="free" /> 免费</label>
      <label><input v-model="plan" type="radio" value="pro" /> Pro</label>
    </fieldset>

    <label>幸运数字 <input v-model.number="favoriteNumber" inputmode="numeric" /></label>

    <label>
      可多选角色
      <select v-model="roles" multiple>
        <option value="reader">读者</option>
        <option value="author">作者</option>
        <option value="reviewer">审稿人</option>
      </select>
    </label>

    <button :disabled="!acceptedTerms">提交</button>
  </form>
</template>

文本类型的幸运数字输入仍允许输入 abc,此时 .number 保留字符串,所以状态必须容纳 string。inputmode只是键盘提示,不会限制输入。用 type="number" 时也要处理清空得到 '',提交时再核对类型和范围。

深水区:输入草稿与提交数据不是同一个对象 ​

表单控件要忠实地承接用户正在输入的内容;提交边界才负责 trim、类型校验、格式转换和组装 API 载荷。若把每一次击键都当成最终数据,光标、输入法组合输入和错误提示会互相绊脚。反过来,若到提交时才随手拼几十个字段,规则又会散落在事件处理器里。

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

const nameDraft = ref('')
const ageDraft = ref<number | ''>('')

const validation = computed(() => ({
  name: nameDraft.value.trim() ? '' : '请输入姓名。',
  age: Number.isInteger(ageDraft.value) && (ageDraft.value as number) >= 16
    ? ''
    : '年龄须为不小于 16 的整数。'
}))

function toPayload() {
  if (validation.value.name || validation.value.age) return null
  return { name: nameDraft.value.trim(), age: ageDraft.value as number }
}
</script>

这里的 validation 是无副作用的派生状态,toPayload() 是明确的提交边界。它们都不直接改 DOM,也不在模板里偷偷写状态。对于密码、验证码、富文本等“用户原样输入就有意义”的字段,尤其不要把 .trim 当作默认礼仪。

校验通过的值必须和异步提交是同一份 ​

若在请求前校验 form.age,却在 await 后重新读取可编辑的 form.age,用户可能已经把16改成15。此时“校验通过”与“实际提交”说的是两个不同输入。正确顺序是:读取草稿→校验→组装载荷快照→启动请求。数组字段用 [...form.interests] 复制,避免后续勾选改变在途载荷。新输入可以继续作为下一次草稿,但不能替换已经发起的那次提交。

修饰符不是装饰品 ​

修饰符行为适合注意
.trim更新时去掉两端空格名称、邀请码不适合密码、Markdown、可能保留空格的内容
.number尝试转成 number数量、价格、页码无法转换时保留原字符串;仍要做范围与 NaN 校验
.lazy从 input 改为 change 更新不需逐字响应的输入不是防抖;用户离开/确认控件才更新

复选框的冷门分支:自定义真假值 ​

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

const subscription = ref<'subscribed' | 'unsubscribed'>('unsubscribed')
</script>

<template>
  <label>
    <input
      v-model="subscription"
      type="checkbox"
      true-value="subscribed"
      false-value="unsubscribed"
    />
    订阅更新
  </label>
</template>

这里 true-value / false-value 影响 Vue 的模型值;传统 HTML 表单提交并不会自动带上这两个值。若要原生提交或和后端协议对齐,仍需明确处理数据。

组件上的 v-model:一份明确的更新合同 ​

本章只建立概念;第 11 页会用 defineModel 写得更完整。传统契约是子组件接收 modelValue,并在用户操作后发送 update:modelValue。子组件不能直接改 prop——那是在替父组件涂改作业本。

vue
<!-- Parent.vue -->
<UserNameInput v-model="displayName" />

<!-- UserNameInput.vue -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>

排错路线:值看起来变了,为什么提交仍不对 ​

  1. 先看控件类型。 文本框、checkbox、radio、select 各自有不同的模型形状;先打印状态,不要凭界面猜是布尔、字符串还是数组。
  2. 再看初始值。 多选 checkbox 需要数组,单选 radio 需要可比较的单值;模型起点错了,语法再正确也会走歪。
  3. 检查修饰符与数据边界。 .number 不能替代范围校验,.trim 不能替代业务规范,.lazy 也不是防抖。把转换与验证放在可命名的位置。
  4. 最后追踪提交状态。 表单是否阻止了原生刷新?是否在请求未结束时再次提交?失败信息有没有回到用户能看到、能读到的位置?

把这四层分开后,v-model 就不再神秘:它只是控件与状态之间的一段双向接线,不负责产品规则、网络协议或服务端校验。

无障碍与提交状态:表单不是只有数据 ​

  • 用真正的 <label> 关联输入;仅有 placeholder 不是标签,提示文字会消失,读屏器也会皱眉。
  • 错误信息用 aria-describedby 关联;错误出现后决定焦点去哪里,别让键盘用户在迷宫里转圈。
  • @submit.prevent 只阻止浏览器跳页,不等于校验、取消请求、防重复提交或服务端信任。
  • 禁用提交按钮有助于防重,但服务端仍必须幂等/校验;前端不是保安室,是温馨提示牌。

翻车博物馆 ​

症状原因修复
数字输入结果是字符串HTML 输入值天然是字符串用 .number 并在提交边界校验
一组 checkbox 只有 true/false模型初始值不是数组多选时初始化为 ref<string[]>([])
v-model.trim 把密码改了不该篡改用户原始输入密码等敏感/原样字段不要 .trim
组件里改 prop 报警告子组件破坏单向数据流emit 更新事件或使用规范的组件 v-model

练习:报名表的三种状态 ​

在 src/App.vue 实现“活动报名”表单。输入合同为:姓名保留用户正在输入的原字符串;年龄状态是 number | '',只接受 16~120 的整数;兴趣是 string[];出席方式只能是 'online' | 'onsite'。测试时至少输入一次前后带空格的姓名、清空年龄、输入 15、输入 16,并勾选两个兴趣。

提交函数接收一个可控的“本次请求是否失败”开关。校验失败不得进入提交态;请求中设置 aria-busy="true" 并拒绝第二次提交;成功显示规范化后的载荷,失败显示表单级错误并恢复按钮。字段错误分别由 aria-describedby 关联,表单级失败使用 role="alert"。

参考答案:查看第一章练习答案中的第 04 题。

验收清单:如何判断练习完成 ​

  • 输入 ' 小白 ' 时输入框仍保留两端空格,成功载荷中的姓名才变为 '小白';空年龄保持 '',不会被误当成 0。
  • 年龄 15 被拒绝,16 被接受;checkbox 得到数组,radio 得到单个联合类型字符串。
  • 提交中状态既能被看见也能被辅助技术读到,重复触发不会启动第二次动作;模拟失败后错误可见且按钮恢复。

通关自检:你能把任一 v-model 还原成“值从哪来、什么事件把新值送回去”,就已经掌握它的骨架。

用心记录每一步成长