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:* 事件 | 不是子组件可以随意改父状态 |
把语法糖展开看一眼
<!-- 这两段在意图上相同。 -->
<input v-model="name" />
<input
:value="name"
@input="name = ($event.target as HTMLInputElement).value"
/>Vue 按不同原生元素选择合适的 property 与事件:文本框使用 value / input,复选框常用 checked / change,选择器也有自己的更新语义。所以 v-model 很省手,但没有把“父状态 → 控件,用户操作 → 更新父状态”这条单向闭环变成黑洞。
一份类型不打架的表单
<!-- 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 载荷。若把每一次击键都当成最终数据,光标、输入法组合输入和错误提示会互相绊脚。反过来,若到提交时才随手拼几十个字段,规则又会散落在事件处理器里。
<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 更新 | 不需逐字响应的输入 | 不是防抖;用户离开/确认控件才更新 |
复选框的冷门分支:自定义真假值
<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——那是在替父组件涂改作业本。
<!-- 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>排错路线:值看起来变了,为什么提交仍不对
- 先看控件类型。 文本框、checkbox、radio、select 各自有不同的模型形状;先打印状态,不要凭界面猜是布尔、字符串还是数组。
- 再看初始值。 多选 checkbox 需要数组,单选 radio 需要可比较的单值;模型起点错了,语法再正确也会走歪。
- 检查修饰符与数据边界。
.number不能替代范围校验,.trim不能替代业务规范,.lazy也不是防抖。把转换与验证放在可命名的位置。 - 最后追踪提交状态。 表单是否阻止了原生刷新?是否在请求未结束时再次提交?失败信息有没有回到用户能看到、能读到的位置?
把这四层分开后,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 还原成“值从哪来、什么事件把新值送回去”,就已经掌握它的骨架。