第 26 章

表单处理: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'])
})

多修饰符组合展开:编译器将修饰符转化为不同的事件名(.lazychange)和值处理逻辑(.trim.trim().numberparseFloat())。

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

章节小结

  1. v-model 是针对不同表单元素精心设计的语法糖:text/textarea 绑定 value + input,checkbox 绑定 checked + change,radio 和 select 各有对应的原生属性和事件映射。编译器根据元素类型自动选择正确的展开策略,开发者无需记忆每种元素的绑定细节。

  2. 修饰符是编译期的事件和值处理逻辑.lazy 将事件从 input 改为 change.trim 在值更新时自动调用 .trim().number 使用 parseFloat() 转换。多个修饰符可以组合使用,编译器将它们合并到同一个事件处理函数中。

  3. 自定义组件 v-model 的本质是 modelValue prop + update:modelValue emit:Vue 3.4 的 defineModel() 宏将这个模式封装成一行代码,返回一个可读写的 WritableComputedRef,内部自动处理 props 读取和 emit 触发。

  4. VeeValidate 4 使用 provide/inject 构建表单上下文useForm() 创建并提供 FormContextuseField() 注入上下文并注册字段。handleSubmit() 在执行回调前并行校验所有注册字段,任何字段失败都阻止表单提交——这个架构无需全局状态,完全基于 Vue 的组件树依赖注入。

  5. 表单校验的四个设计原则:(1)即时反馈 vs 提交校验的权衡——.lazy + touched 状态控制反馈时机;(2)异步校验必须处理竞态——使用 onCleanup 取消过时请求;(3)校验规则应该是纯函数——易于复用和测试;(4)客户端校验不能替代服务端校验——始终在服务端做安全校验。

本章评分
4.6  / 5  (4 评分)

💬 留言讨论