Vue.js: 表单与 v-model
最后更新:2026-08-26
表单是 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 绑定:
VUE
<!-- ❌ 翻车版:5 个字段 + 5 个 ref + 5 个 v-model -->
<template>
<form @submit.prevent="handleLogin">
<input v-model="username">
<input v-model="password" type="password">
<input v-model="email" type="email">
<input v-model="phone">
<input v-model="captcha">
<button>Login</button>
</form>
</template>
<script setup>
const { ref } = Vue
const username = ref('')
const password = ref('')
const email = ref('')
const phone = ref('')
const captcha = ref('')
</script>
5 字段 = 10 行样板代码。10 字段就是 20 行。表单越复杂越糟。
(2) Vue v-model 进阶解法:reactive 对象 + defineModel
VUE
<!-- ✅ 正确版:1 个 reactive 对象 -->
<template>
<form @submit.prevent="handleLogin">
<input v-model="form.username">
<input v-model="form.password" type="password">
<input v-model="form.email" type="email">
<input v-model="form.phone">
<input v-model="form.captcha">
<button>Login</button>
</form>
</template>
<script setup>
const { reactive } = Vue
const form = reactive({
username: '',
password: '',
email: '',
phone: '',
captcha: ''
})
</script>
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
<!-- 子组件:CustomInput.vue -->
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
VUE
<!-- 父组件使用 v-model -->
<CustomInput v-model="searchQuery" />
<!-- 等同于 -->
<CustomInput
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
(3) defineModel(Vue 3.4+ 简化)
VUE
<!-- Vue 3.4+:1 行替代上面 10 行 -->
<template>
<input v-model="value">
</template>
<script setup>
const value = defineModel('modelValue')
// 现在 value 是 ref,可以:
// - 读:value.value
// - 写:value.value = 'new'(自动 emit)
// - watch:watch(value, ...)
// - 父组件 v-model 双向绑定照常
</script>
4. 5 种自定义 v-model
(1) 基本 input
VUE
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
(2) textarea
VUE
<template>
<textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
(3) select
VUE
<template>
<select
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option value="vue">Vue</option>
<option value="react">React</option>
</select>
</template>
(4) checkbox(多选)
VUE
<template>
<div>
<label v-for="option in options" :key="option">
<input
type="checkbox"
:value="option"
:checked="modelValue.includes(option)"
@change="onChange($event, option)"
>
{{ option }}
</label>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: Array,
options: Array
})
const emit = defineEmits(['update:modelValue'])
function onChange(e, option) {
const newValue = e.target.checked
? [...props.modelValue, option]
: props.modelValue.filter(v => v !== option)
emit('update:modelValue', newValue)
}
</script>
(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. 表单校验
(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: ''
}
})
VUE
<template>
<input v-model="form.user.name" placeholder="Name">
<input v-model="form.user.email" placeholder="Email">
<input v-model="form.address.city" placeholder="City">
</template>
(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)
}
VUE
<template>
<div v-for="(item, index) in form.items" :key="index">
<input v-model="item.name" placeholder="Item name">
<input v-model.number="item.quantity" type="number">
<input v-model.number="item.price" type="number">
<button @click="removeItem(index)">Remove</button>
</div>
<button @click="addItem">Add Item</button>
</template>
(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" />
7. 文件上传
(1) 单文件上传
VUE
<template>
<input type="file" @change="handleFile" accept="image/*">
<img v-if="preview" :src="preview" alt="Preview">
</template>
<script setup>
const { ref } = Vue
const preview = ref(null)
function handleFile(event) {
const file = event.target.files[0]
if (file) {
// 本地预览
const reader = new FileReader()
reader.onload = (e) => {
preview.value = e.target.result
}
reader.readAsDataURL(file)
// 上传到服务器
const formData = new FormData()
formData.append('file', file)
fetch('/api/upload', { method: 'POST', body: formData })
}
}
</script>
(2) 多文件 + 拖拽
VUE
<template>
<div
@dragover.prevent
@drop.prevent="handleDrop"
:class="{ dragging: isDragging }"
>
<input type="file" multiple @change="handleFiles" accept="image/*">
<p>Drag files here or click to upload</p>
<div v-for="file in files" :key="file.name">
<img :src="file.preview" :alt="file.name">
<p>{{ file.name }} ({{ file.size }} bytes)</p>
</div>
</div>
</template>
<script setup>
const { ref, reactive } = Vue
const files = reactive([])
const isDragging = ref(false)
function addFile(file) {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = (e) => {
files.push({
name: file.name,
size: file.size,
preview: e.target.result
})
}
reader.readAsDataURL(file)
}
function handleFiles(e) {
Array.from(e.target.files).forEach(addFile)
}
function handleDrop(e) {
isDragging.value = false
Array.from(e.dataTransfer.files).forEach(addFile)
}
</script>
8. 完整示例:5 大表单场景
▶ 示例:5 种 v-model 基础 + 校验
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; display: block; }
.error { color: #ef4444; font-size: 0.85rem; margin: 2px 0; }
.btn { padding: 6px 12px; background: #42b883; color: white; border: none; border-radius: 4px; cursor: pointer; }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.ok { color: #10b981; padding: 4px; }
</style>
<div id="app">
<h4>注册表单(5 字段 + 校验)</h4>
<input v-model.trim="form.username" placeholder="Username" @blur="validate('username')">
<p class="error">{{ errors.username || '' }}</p>
<input v-model.trim="form.email" placeholder="Email" @blur="validate('email')">
<p class="error">{{ errors.email || '' }}</p>
<input v-model.number="form.age" type="number" placeholder="Age" @blur="validate('age')">
<p class="error">{{ errors.age || '' }}</p>
<input v-model="form.password" type="password" placeholder="Password (≥8)" @blur="validate('password')">
<p class="error">{{ errors.password || '' }}</p>
<input v-model="form.confirm" type="password" placeholder="Confirm Password" @blur="validate('confirm')">
<p class="error">{{ errors.confirm || '' }}</p>
<p v-if="success" class="ok">✅ 表单通过校验!可提交</p>
<button class="btn" :disabled="!isValid" @click="submit">注册</button>
</div>
<script>
const { createApp, reactive, ref, computed } = Vue
const App = {
setup() {
const form = reactive({
username: '', email: '', age: 0, password: '', confirm: ''
})
const errors = reactive({})
const success = ref(false)
const isValid = computed(() => {
return form.username && form.email && form.age >= 18
&& form.password.length >= 8 && form.password === form.confirm
&& Object.keys(errors).length === 0
})
function validate(field) {
// 1. 必填
if (field === 'username' && !form.username) {
errors.username = 'Required'
} else if (field === 'email' && (!form.email || !form.email.includes('@'))) {
errors.email = 'Invalid email'
} else if (field === 'age' && (form.age < 18 || form.age > 120)) {
errors.age = 'Age must be 18-120'
} else if (field === 'password' && form.password.length < 8) {
errors.password = 'Password must be ≥8 characters'
} else if (field === 'confirm' && form.password !== form.confirm) {
errors.confirm = 'Passwords do not match'
} else {
delete errors[field]
}
success.value = isValid.value && Object.keys(errors).length === 0
}
function submit() {
if (isValid.value) {
alert('Submitted: ' + JSON.stringify(form))
}
}
return { form, errors, success, isValid, validate, submit }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:5 种自定义 v-model + 多 v-model 速查
VUE
<!-- 子组件:CustomInput.vue -->
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>
<!-- 父组件 -->
<CustomInput v-model="search" />
<!-- 等同于 -->
<CustomInput :modelValue="search" @update:modelValue="search = $event" />
<!-- 多个 v-model(Vue 3.4+)-->
<CustomInput v-model:username="user" v-model:password="pass" />
▶ 示例: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" />
▶ 示例:5 个常见错误速查
| 错误 | 现象 | 解决 |
|---|---|---|
| v-model.number 仍为字符串 | 加法运算异常 | type="number" + .number |
| v-model 不更新 | ref 改为 reactive 后失效 | 改 .value 或 toRefs |
| 文件上传 formData 失败 | 跨域或 method 错 | fetch + FormData |
| 校验时机不对 | 失去焦点才显示 | 用 @blur |
| 大量字段 boilerplate | 代码冗长 | 用 reactive 对象 |
▶ 示例: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 强类型