表单处理:v-model 编译展开、校验设计模式与 VeeValidate 原理
v-model 在不同表单元素上的行为并不一致——<input type="checkbox"> 绑定的是 checked 属性和 change 事件,而 <input type="text"> 绑定的是 value 属性和 input 事件,<select> 又是另一套逻辑。这些差异不是意外,而是浏览器原生行为的精确映射。理解 v-model 的编译展开,就是理解 Vue 如何在"声明式双向绑定"的表象下精细地操控 DOM 事件。
Level 1 · 你需要知道的(1-3年经验)
原生表单元素的 v-model 展开
text/textarea:绑定 value + input 事件
<input v-model="text" />
<!-- 等价于 -->
<input :value="text" @input="text = $event.target.value" />
checkbox(单个):绑定 checked + change 事件
<input type="checkbox" v-model="checked" />
<!-- 等价于 -->
<input type="checkbox"
:checked="checked"
@change="checked = $event.target.checked" />
checkbox(多选,绑定数组):
<input type="checkbox" value="apple" v-model="selectedFruits" />
<input type="checkbox" value="banana" v-model="selectedFruits" />
<!-- selectedFruits 是字符串数组,选中时添加 value,取消时移除 -->
radio:绑定 value + change 事件
<input type="radio" value="male" v-model="gender" />
<input type="radio" value="female" v-model="gender" />
<!-- 等价于 -->
<input type="radio" value="male"
:checked="gender === 'male'"
@change="gender = 'male'" />
select(单选):绑定 value + change 事件
<select v-model="city">
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
<!-- 等价于 -->
<select :value="city" @change="city = $event.target.value">
...
</select>
select(多选):绑定数组
<select v-model="selectedCities" multiple>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
</select>
<!-- selectedCities 是数组 -->
v-model 修饰符
.lazy:绑定 change 而非 input 事件(用户离开输入框时才同步)
<input v-model.lazy="text" />
<!-- 等价于 -->
<input :value="text" @change="text = $event.target.value" />
适合减少不必要的响应式更新,或在提交时才做校验的场景。
.number:自动转换为数字(用 parseFloat)
<input type="number" v-model.number="price" />
<!-- price 是 number 类型,不是字符串 -->
.trim:自动去除首尾空格
<input v-model.trim="username" />
<!-- username 总是去除了首尾空格 -->
自定义组件的 v-model
在自定义组件上使用 v-model:
<!-- 父组件 -->
<CustomInput v-model="text" />
<!-- 等价于 -->
<CustomInput :modelValue="text" @update:modelValue="text = $event" />
子组件需要接收 modelValue prop 并 emit update:modelValue:
<!-- CustomInput.vue(旧方式) -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input :value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)" />
</template>
Vue 3.4+ 推荐方式:defineModel()
<!-- CustomInput.vue(新方式) -->
<script setup>
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>
多个 v-model(具名绑定)
<!-- 父组件 -->
<UserEditor
v-model:name="user.name"
v-model:email="user.email"
/>
<!-- 等价于 -->
<UserEditor
:name="user.name" @update:name="user.name = $event"
:email="user.email" @update:email="user.email = $event"
/>
<!-- UserEditor.vue -->
<script setup>
const name = defineModel<string>('name')
const email = defineModel<string>('email')
</script>
<template>
<input v-model="name" />
<input v-model="email" type="email" />
</template>
Level 2 · 它是怎么运行的(3-5年经验)
v-model 编译器的处理逻辑
Vue 编译器在处理 v-model 指令时,会根据元素类型选择不同的展开策略:
// packages/compiler-dom/src/transforms/vModel.ts(简化)
export const transformModel: DirectiveTransform = (dir, node, context) => {
const { tag } = node
if (tag === 'input') {
const type = findInputType(node)
if (type === 'checkbox') {
return genCheckboxModel(node, dir)
} else if (type === 'radio') {
return genRadioModel(node, dir)
} else if (type === 'file') {
// file input 只读,v-model 不起作用
warn('v-model cannot be used on file inputs...')
} else {
return genTextModel(node, dir)
}
} else if (tag === 'select') {
return genSelectModel(node, dir)
} else if (tag === 'textarea') {
return genTextModel(node, dir)
} else {
// 自定义组件:生成 modelValue + update:modelValue
return genComponentModel(node, dir, context)
}
}
编译前后的完整对比
输入(模板):
<input v-model.trim.lazy="username" type="text" />
编译输出(简化):
createVNode('input', {
type: 'text',
value: username, // :value 绑定
onChange: withModifiers($event => { // .lazy → change 而非 input
username = $event.target.value.trim() // .trim → 自动 trim
}, ['trim'])
})
多修饰符组合展开:编译器将修饰符转化为不同的事件名(.lazy → change)和值处理逻辑(.trim → .trim(),.number → parseFloat())。
v-model 在 checkbox 上的特殊处理
checkbox 的 v-model 有两种模式:
模式一:绑定布尔值(单个 checkbox,无 value 属性)
<input type="checkbox" v-model="isChecked" />
<!-- isChecked 是 true/false -->
编译展开:
{
checked: isChecked,
onChange: $event => { isChecked = $event.target.checked }
}
模式二:绑定数组(多个 checkbox,有 value 属性)
<input type="checkbox" value="apple" v-model="fruits" />
编译展开:
{
checked: looseIndexOf(fruits, 'apple') > -1, // 检查 'apple' 是否在数组中
onChange: $event => {
const $$selectedVal = Array.from($event.target.options)
.filter(o => o.selected).map(o => o.value)
// 或者:
if ($event.target.checked) {
looseIndexOf(fruits, 'apple') < 0 && fruits.push('apple')
} else {
const index = looseIndexOf(fruits, 'apple')
index > -1 && fruits.splice(index, 1)
}
}
}
looseIndexOf 是 Vue 内部的宽松比较函数,支持对象引用和原始值的比较。
defineModel 宏的展开机制(Vue 3.4+)
// 编译器将 defineModel() 展开成 useModel() 的调用
// packages/runtime-core/src/helpers/useModel.ts
export function useModel<M extends PropertyKey, T>(
props: Record<M, T>,
name: M,
options?: UseModelOptions<T>,
): WritableComputedRef<T> {
const i = getCurrentInstance()!
// 返回一个可读写的 computed
return computed({
get() {
return props[name]
},
set(value) {
// 触发 update:modelValue 事件
i.emit(`update:${String(name)}`, value)
}
})
}
defineModel() 返回的是一个 WritableComputedRef<T>——读取时从 props 获取,写入时触发 emit。这使得子组件可以直接使用 v-model 绑定这个 ref,实现透明的双向绑定。
手写校验 Composable:useField 实现
// useField.ts — 字段级校验 Composable
import { ref, computed } from 'vue'
type Validator<T> = (value: T) => string | true
interface FieldOptions<T> {
initialValue: T
validators?: Validator<T>[]
}
export function useField<T>(options: FieldOptions<T>) {
const { initialValue, validators = [] } = options
const value = ref<T>(initialValue) as Ref<T>
const error = ref<string>('')
const touched = ref(false) // 是否被用户交互过
const dirty = ref(false) // 是否被修改过
function validate(): boolean {
for (const validator of validators) {
const result = validator(value.value)
if (result !== true) {
error.value = result
return false
}
}
error.value = ''
return true
}
function handleChange(newValue: T) {
value.value = newValue
dirty.value = true
if (touched.value) validate() // 触碰后立即校验
}
function handleBlur() {
touched.value = true
validate()
}
const isValid = computed(() => error.value === '')
return {
value,
error,
touched,
dirty,
isValid,
validate,
handleChange,
handleBlur,
}
}
使用示例:
// 自定义验证规则(纯函数,可复用)
const required = (msg = '此项必填') => (v: unknown) =>
(v !== null && v !== undefined && v !== '') || msg
const minLength = (min: number, msg?: string) => (v: string) =>
v.length >= min || msg ?? `最少 ${min} 个字符`
const email = (v: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '请输入有效的邮箱地址'
// 在组件中使用
const { value: username, error: usernameError, handleBlur } = useField({
initialValue: '',
validators: [required(), minLength(3)]
})
const { value: userEmail, error: emailError } = useField({
initialValue: '',
validators: [required('邮箱不能为空'), email]
})
VeeValidate 4 的工作原理
VeeValidate 4 使用 provide/inject 构建表单上下文:
useForm() 调用
└─ 创建表单上下文(FormContext)
├─ fields: Map<string, FieldContext>
├─ errors: Ref<Record<string, string>>
├─ values: Ref<Record<string, unknown>>
└─ provide(FormContextKey, formContext)
│
▼
useField('username') 调用(在表单的子孙组件中)
└─ inject(FormContextKey) → 获取表单上下文
└─ 向表单注册自己:formContext.register(field)
└─ 返回 { value, error, handleChange, ... }
│
▼
handleSubmit(onSubmit) 调用
└─ 遍历所有注册的 field,调用 field.validate()
└─ 全部通过 → 调用 onSubmit(values)
└─ 有错误 → 设置 errors,不调用 onSubmit
<!-- 完整 VeeValidate 4 表单示例 -->
<script setup>
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const { handleSubmit, errors } = useForm({
validationSchema: yup.object({
username: yup.string().required().min(3),
email: yup.string().required().email(),
password: yup.string().required().min(8),
})
})
const { value: username } = useField('username')
const { value: email } = useField('email')
const { value: password } = useField('password')
const onSubmit = handleSubmit(async (values) => {
await api.register(values)
// handleSubmit 确保只有所有字段通过校验才调用这里
})
</script>
<template>
<form @submit.prevent="onSubmit">
<div>
<input v-model="username" />
<span>{{ errors.username }}</span>
</div>
<div>
<input v-model="email" type="email" />
<span>{{ errors.email }}</span>
</div>
<button type="submit">注册</button>
</form>
</template>
Level 3 · 设计文档与源码(资深开发者)
vModelText 指令的运行时实现
// packages/runtime-dom/src/directives/vModelText.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
// .lazy → 监听 change,否则监听 input
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return // 处理 IME 输入
let domValue: string | number = el.value
if (trim) {
domValue = domValue.trim()
}
if (number || el.type === 'number') {
domValue = looseToNumber(domValue) // parseFloat + NaN 处理
}
el._assign(domValue)
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
},
// 当绑定值从外部变化时,同步到 DOM
beforeMount(el, { value }) {
el.value = value == null ? '' : value
},
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }) {
el._assign = getModelAssigner(vnode)
// 如果 el 正在聚焦(用户正在输入),不要覆盖用户输入
if (document.activeElement === el) {
if (lazy) return
if (trim && el.value.trim() === value) return
}
const newValue = value == null ? '' : value
if (el.value !== newValue) {
el.value = newValue
}
},
}
关键细节:composing 检查处理 IME(输入法)组合输入——中文、日文输入法在确认前的候选字符不应该触发 v-model 更新。
looseIndexOf 和 looseEqual:数组 v-model 的核心工具
// packages/shared/src/looseEqual.ts
// 宽松相等:支持对象深比较
export function looseEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
const isObjectA = isObject(a)
const isObjectB = isObject(b)
if (isObjectA && isObjectB) {
const keysA = Object.keys(a as object)
const keysB = Object.keys(b as object)
return (
keysA.length === keysB.length &&
keysA.every(key => looseEqual((a as any)[key], (b as any)[key]))
)
} else if (!isObjectA && !isObjectB) {
return String(a) === String(b) // 处理原始值的字符串比较
}
return false
}
export function looseIndexOf(arr: unknown[], val: unknown): number {
return arr.findIndex(item => looseEqual(item, val))
}
这解释了为什么 checkbox 的数组 v-model 能正确处理对象选项——即使是引用不同但内容相同的对象,也能正确找到和移除。
VeeValidate 的 FormContext 类型系统
// vee-validate 内部的核心类型
interface FormContext {
// 注册字段
register: (field: PrivateFieldComposite) => void
unregister: (field: PrivateFieldComposite) => void
// 表单级别的值和错误
values: Readonly<GenericObject>
errors: Readonly<Ref<Record<string, string | undefined>>>
// 批量校验
validate: () => Promise<{ valid: boolean; results: Record<string, ValidationResult> }>
validateField: (field: string) => Promise<ValidationResult>
// 提交
handleSubmit: <TReturn = unknown>(
cb: SubmissionHandler<GenericObject, GenericObject, TReturn>
) => (e?: Event) => Promise<TReturn | undefined>
// 状态
isSubmitting: Ref<boolean>
submitCount: Ref<number>
meta: ComputedRef<FormMeta<GenericObject>>
}
yup/zod 集成:Schema 校验
VeeValidate 4 支持将 yup 或 zod schema 作为校验规则:
// yup schema 转换为 VeeValidate 校验规则
import * as yup from 'yup'
import { toTypedSchema } from '@vee-validate/yup'
const schema = toTypedSchema(
yup.object({
username: yup.string().required('用户名必填').min(3, '至少3位'),
email: yup.string().required().email('请输入有效邮箱'),
age: yup.number().required().min(18, '需满18岁'),
})
)
const { handleSubmit, defineComponentBinds } = useForm({
validationSchema: schema
})
// zod 替代方案
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
const schema = toTypedSchema(
z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().min(18),
})
)
Level 4 · 边界与陷阱(全体适用)
陷阱 1:自定义组件未正确 emit update:modelValue
<!-- ❌ 错误:修改了 props,没有 emit -->
<script setup>
const props = defineProps<{ modelValue: string }>()
function handleInput(e: InputEvent) {
// ❌ 直接修改 props 违反单向数据流
props.modelValue = (e.target as HTMLInputElement).value
}
</script>
<!-- ✅ 正确:emit update:modelValue -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function handleInput(e: InputEvent) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
<!-- 或者使用 defineModel(Vue 3.4+,最简洁) -->
<script setup>
const model = defineModel<string>()
</script>
症状:表单看起来能输入,但父组件的值不更新(单向绑定表现)。
陷阱 2:.number 修饰符与 NaN
// ❌ 当用户清空输入框时,.number 修饰符的行为
<input type="number" v-model.number="price" />
// 用户输入 "123" → price = 123 ✓
// 用户清空输入框 → price = NaN ✗
// NaN 是响应式的,但通常不是你想要的默认值
// ✅ 解决方案:使用 computed 或手动处理
const rawPrice = ref('')
const price = computed({
get: () => rawPrice.value,
set: (v) => {
rawPrice.value = v
}
})
const numericPrice = computed(() => {
const n = parseFloat(rawPrice.value)
return isNaN(n) ? 0 : n
})
陷阱 3:v-model 与响应式对象属性的配合
// ❌ v-model 绑定解构后的属性,失去响应性
const { username } = reactive(formState)
// <input v-model="username" /> — 修改不会反映到 formState
// ✅ 直接绑定对象属性
// <input v-model="formState.username" />
// ✅ 或使用 ref
const username = ref(formState.username)
watch(username, (v) => formState.username = v)
陷阱 4:异步校验的竞态条件
// ❌ 竞态问题:用户快速输入时,多个异步校验并发,结果互相覆盖
watch(email, async (v) => {
const isUnique = await checkEmailUnique(v)
emailError.value = isUnique ? '' : '邮箱已被注册'
// 如果在等待期间用户又输入了新值,这个结果可能是过时的!
})
// ✅ 使用 watchEffect 的 onCleanup 处理竞态
watch(email, async (v, _, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
const isUnique = await checkEmailUnique(v)
if (!cancelled) {
emailError.value = isUnique ? '' : '邮箱已被注册'
}
})
陷阱 5:select 的 v-model 初始化问题
<!-- ❌ 问题:初始值与选项不匹配时,select 显示空白 -->
<select v-model="selected">
<option value="a">选项A</option>
<option value="b">选项B</option>
</select>
// selected 初始值是 '' 或 null,但选项中没有对应值
const selected = ref('') // ← 导致 select 显示空白
<!-- ✅ 解决:添加占位 option,或确保初始值匹配某个 option -->
<select v-model="selected">
<option value="" disabled>请选择...</option>
<option value="a">选项A</option>
<option value="b">选项B</option>
</select>
陷阱 6:v-model 在 Composition API 组件中的修饰符支持
当父组件使用 v-model.trim 传给自定义组件时,修饰符信息通过 modelModifiers prop 传递:
<!-- 父组件 -->
<CustomInput v-model.trim.uppercase="text" />
<!-- 等价于 -->
<CustomInput
:modelValue="text"
:modelModifiers="{ trim: true, uppercase: true }"
@update:modelValue="text = $event"
/>
// 子组件处理修饰符
const props = defineProps<{
modelValue: string
modelModifiers?: { trim?: boolean; uppercase?: boolean }
}>()
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
function handleInput(e: InputEvent) {
let value = (e.target as HTMLInputElement).value
if (props.modelModifiers?.trim) value = value.trim()
if (props.modelModifiers?.uppercase) value = value.toUpperCase()
emit('update:modelValue', value)
}
章节小结
-
v-model 是针对不同表单元素精心设计的语法糖:text/textarea 绑定
value + input,checkbox 绑定checked + change,radio 和 select 各有对应的原生属性和事件映射。编译器根据元素类型自动选择正确的展开策略,开发者无需记忆每种元素的绑定细节。 -
修饰符是编译期的事件和值处理逻辑:
.lazy将事件从input改为change,.trim在值更新时自动调用.trim(),.number使用parseFloat()转换。多个修饰符可以组合使用,编译器将它们合并到同一个事件处理函数中。 -
自定义组件 v-model 的本质是 modelValue prop + update:modelValue emit:Vue 3.4 的
defineModel()宏将这个模式封装成一行代码,返回一个可读写的WritableComputedRef,内部自动处理 props 读取和 emit 触发。 -
VeeValidate 4 使用 provide/inject 构建表单上下文:
useForm()创建并提供FormContext,useField()注入上下文并注册字段。handleSubmit()在执行回调前并行校验所有注册字段,任何字段失败都阻止表单提交——这个架构无需全局状态,完全基于 Vue 的组件树依赖注入。 -
表单校验的四个设计原则:(1)即时反馈 vs 提交校验的权衡——
.lazy+touched状态控制反馈时机;(2)异步校验必须处理竞态——使用onCleanup取消过时请求;(3)校验规则应该是纯函数——易于复用和测试;(4)客户端校验不能替代服务端校验——始终在服务端做安全校验。