Vue.js: 表单与 v-model
最后更新:2026-10-20
表单是 Web 应用的核心交互——登录、注册、搜索、设置都离不开表单处理。Vue 的 v-model 是处理表单的"语法糖",让双向绑定代码极简(1 行 vs 5 行)。
Vue 3.4+ 的 defineModel 进一步简化 v-model 实现。复杂表单还需要校验、序列化、文件上传等高级功能。本课覆盖 5 大表单场景。
1. 你将学到
- v-model 原理(本质是 props + emit 语法糖)
- 自定义 v-model 组件
- defineModel 简化(Vue 3.4+)
- 表单校验(VeeValidate / 自定义)
- 复杂表单设计(嵌套 / 动态字段)
- 表单序列化与提交
- 文件上传与预览
2. 一个登录表单的"5 处 boilerplate"噩梦
(1) 痛点:5 个表单字段,10 行 v-model 代码
Alice 的登录表单有 5 个字段,每个都有 v-model 绑定:
5 字段 = 10 行样板代码。10 字段就是 20 行。表单越复杂越糟。
(2) Vue v-model 进阶解法:reactive 对象 + defineModel
5 字段 = 1 个对象。reactive 让代码量减少 50%。
(3) 收益
v-model 优化后:
- 代码量:10 行 → 5 行(-50%)
- 新增字段:1 行(reactive 加字段)
- 整体提交:form 一个对象即可
- 校验:集中处理
3. v-model 原理
(1) v-model 本质
VUE
<!-- v-model 是 props + emit 的语法糖 -->
<input v-model="searchQuery">
<!-- 等同于 -->
<input
:value="searchQuery"
@input="searchQuery = $event.target.value"
>
(2) 组件中的 v-model
VUE
<!-- 父组件使用 v-model -->
<CustomInput v-model="searchQuery" />
<!-- 等同于 -->
<CustomInput
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
(3) defineModel(Vue 3.4+ 简化)
4. 5 种自定义 v-model
(1) 基本 input
(2) textarea
(3) select
(4) checkbox(多选)
(5) 自定义修饰符(trim)
VUE
<!-- 父组件:v-model.trim -->
<CustomInput v-model.trim="username" />
<!-- 子组件:处理 trim 修饰符 -->
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
let value = e.target.value
if (props.modelModifiers.trim) {
value = value.trim()
}
emit('update:modelValue', value)
}
</script>
▶ 示例:5 种自定义 v-model + 多 v-model 速查
▶ 试一试输出:
TEXT
自定义输入框显示父组件传入的 search 值;输入时触发 update:modelValue,父组件的 search 同步更新。
使用 v-model:username / v-model:password 时,两个字段各自独立双向绑定,修改一个不会影响另一个。
5. 表单校验
(1) 自定义校验
JS
// utils/validators.js
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
export function isPhone(value) {
return /^1[3-9]\d{9}$/.test(value)
}
export function minLength(value, min) {
return value.length >= min
}
VUE
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.email" @blur="validateField('email')">
<p v-if="errors.email" class="error">{{ errors.email }}</p>
<input v-model="form.password" type="password">
<p v-if="errors.password">{{ errors.password }}</p>
<button :disabled="!isValid">Submit</button>
</form>
</template>
<script setup>
const { reactive, ref } = Vue
import { isEmail, minLength } from '@/utils/validators'
const form = reactive({
email: '',
password: ''
})
const errors = reactive({})
const isValid = ref(false)
function validateField(field) {
if (field === 'email') {
if (!form.email) {
errors.email = 'Email is required'
} else if (!isEmail(form.email)) {
errors.email = 'Invalid email format'
} else {
delete errors.email
}
}
if (field === 'password') {
if (!form.password) {
errors.password = 'Password is required'
} else if (!minLength(form.password, 8)) {
errors.password = 'Password must be 8+ characters'
} else {
delete errors.password
}
}
isValid.value = Object.keys(errors).length === 0
}
function handleSubmit() {
Object.keys(form).forEach(validateField)
if (isValid.value) {
console.log('Submit:', form)
}
}
</script>
(2) VeeValidate 库(推荐)
BASH
npm install vee-validate @vee-validate/rules
VUE
<template>
<Form @submit="handleSubmit" :validation-schema="schema">
<Field name="email" type="email" v-model="form.email" />
<ErrorMessage name="email" />
<Field name="password" type="password" v-model="form.password" />
<ErrorMessage name="password" />
<button>Submit</button>
</Form>
</template>
<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required()
})
const form = reactive({ email: '', password: '' })
function handleSubmit(values) {
console.log('Valid:', values)
}
</script>
6. 复杂表单设计
(1) 嵌套表单
JS
const form = reactive({
user: {
name: '',
email: '',
age: 0
},
address: {
city: '',
country: ''
}
})
(2) 动态字段(v-for)
JS
const form = reactive({
items: [
{ name: '', quantity: 1, price: 0 }
]
})
function addItem() {
form.items.push({ name: '', quantity: 1, price: 0 })
}
function removeItem(index) {
form.items.splice(index, 1)
}
(3) 5 大表单修饰符
VUE
<!-- 1. .lazy:change 事件触发(不是 input) -->
<input v-model.lazy="email">
<!-- 2. .number:自动转数字 -->
<input v-model.number="age" type="number">
<!-- 3. .trim:自动去空格 -->
<input v-model.trim="username">
<!-- 4. 自定义修饰符(CustomInput) -->
<CustomInput v-model.trim="text" />
<!-- 5. .debounce(自定义修饰符):DebouncedInput 组件内部通过 modelModifiers 实现 -->
<DebouncedInput v-model.debounce="searchText" />
▶ 示例:5 大表单修饰符
VUE
<!-- 1. .lazy:change 事件(失焦后)-->
<input v-model.lazy="email">
<!-- 2. .number:自动转数字 -->
<input v-model.number="age" type="number">
<!-- 3. .trim:自动 trim -->
<input v-model.trim="username">
<!-- 4. 多修饰符组合 -->
<input v-model.lazy.trim="email">
<!-- 5. 自定义修饰符(子组件) -->
<CustomInput v-model.trim="text" />
输出:
TEXT
.lazy 输入时不同步,失焦或回车后才更新绑定的 email。
.number 把输入的年龄自动转成数字类型;.trim 自动去掉用户名首尾空格。
组合 .lazy.trim 在失焦时一次性更新并去除空格;子组件上的 v-model.trim 会把修饰符传入。
7. 文件上传
(1) 单文件上传
(2) 多文件 + 拖拽
8. 综合演练:注册表单校验实战
▶ 示例:5 种 v-model 基础 + 校验
▶ 展开运行示例(63 行)
输出:
TEXT
页面显示「注册表单(5 字段 + 校验)」和 5 个输入框,字段下方错误提示为空,「注册」按钮置灰。
用户名留空失焦提示 Required;邮箱不含 @ 提示 Invalid email;年龄小于 18 提示 Age must be 18-120;密码不足 8 位、两次密码不一致也分别给出提示。
全部字段合法后出现绿色「✅ 表单通过校验!可提交」,「注册」按钮变为可点,点击弹出包含表单 JSON 的 alert。
(1) 📊 5 个常见错误速查
| 错误 | 现象 | 解决 |
|---|---|---|
| v-model.number 仍为字符串 | 加法运算异常 | type="number" + .number |
| v-model 不更新 | ref 改为 reactive 后失效 | 改 .value 或 toRefs |
| 文件上传 formData 失败 | 跨域或 method 错 | fetch + FormData |
| 校验时机不对 | 失去焦点才显示 | 用 @blur |
| 大量字段 boilerplate | 代码冗长 | 用 reactive 对象 |
(2) 📊 5 大性能对比
| 模式 | 简洁度 | 性能 | 适用 |
|---|---|---|---|
| 多个 ref | ❌ 重复 | ⭐⭐⭐⭐ | 1-2 字段 |
| reactive 对象 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 5+ 字段 |
| FormData 手动 | ❌ | ⭐⭐⭐ | 文件上传 |
| VeeValidate | ⭐⭐⭐⭐ | ⭐⭐⭐ | 复杂校验 |
| defineModel | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Vue 3.4+ 组件 |
❓ 常见问题
Q v-model 在自定义组件中怎么用?
A 组件内
defineProps(['modelValue']) + defineEmits(['update:modelValue'])。父组件用 v-model。Vue 3.4+ 用 defineModel 1 行替代 10 行。Q v-model.number 不生效?
A 必须配合
type="number"。或者手动 parseFloat()。Q 表单校验用自定义还是 VeeValidate?
A 简单表单(< 5 字段)自定义;复杂表单(> 5 字段 + 嵌套 + 异步校验)用 VeeValidate。
Q 怎么上传文件并显示预览?
A 用 FileReader.readAsDataURL() 读为 base64 字符串,赋值给
<img src="...">。上传时用 FormData。Q v-model 修饰符 .lazy 和 .debounce 区别?
A .lazy 在 change 事件触发(失焦后),节省实时校验开销。.debounce 是自定义修饰符,需要在子组件实现,300ms 内最后一次触发。
Q 表单数据怎么序列化为 JSON 提交?
A 直接用
JSON.stringify(form)(无需 FormData,除非有文件)。后端接收 JSON 即可。Q defineModel 替代 v-model 完整写法吗?
A 是的。
defineModel('modelValue') 等同于 props.modelValue + emit('update:modelValue') 的 10 行代码。完全向后兼容。📖 小节
- v-model 是 props + emit 语法糖,Vue 3.4+ 用 defineModel 1 行替代
- 5 种自定义 v-model:input / textarea / select / checkbox / 修饰符
- 5 大修饰符:.lazy / .number / .trim / 自定义 / 多修饰符
- 表单校验:自定义(简单)/ VeeValidate(复杂)
- 5 大复杂表单:嵌套 / 动态 / 序列 / 校验 / 文件
- reactive 对象 + v-model 减少 50% boilerplate
- 文件上传:FileReader + FormData
📝 作业
-
基础题(难度⭐) 实现一个简单的登录表单:
- 5 个字段(username / password / email / phone / captcha)
- v-model + reactive
- 提交时打印整个 form 对象
-
进阶题(难度⭐⭐) 实现表单校验:
- 5 个字段 + 自定义校验(email 格式、phone 格式、password 长度)
- @blur 触发校验
- 错误信息显示
- 提交按钮根据 isValid 禁用/启用
-
挑战题(难度⭐⭐⭐) 实现完整的"用户注册"表单系统:
- 10 个字段(含嵌套 address 对象 + 动态 tags 数组)
- VeeValidate + Yup schema
- 文件上传(头像)
- 5 种修饰符全覆盖
- defineModel 用于子组件
- 5 种错误状态
- TypeScript 强类型