第4章 4.4 表单与校验
📌 前置知识:学完「第4章 4.3 网络请求:axios 封装」再来看这章,效果翻倍
上一章我们搞定了 axios 封装,手里有了数据请求的利器。但数据拿来之后,总得让用户输入点什么吧?注册账号要填表单,搜索要输入关键词,订单确认要勾选协议……没有表单的 App,就像没有入口的高速公路——数据进不来也出不去。
这章我们要解决一个很现实的问题:如何优雅地处理用户输入 + 校验错误。学完你能:
- 用 v-model 把用户输入和程序状态绑定起来
- 用校验规则挡住垃圾数据(空表单、格式错误、恶意输入)
- 用 Element Plus 组件快速搭出一个专业的表单页面
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这种崩溃场景?
- 填了一个长表单,点提交,弹出一堆红色报错,但根本看不懂哪错了
- 注册时密码输入
123456,系统说「密码太简单」,但没告诉你啥才算复杂 - 表单填了一半刷新了,之前的数据全没了
这都是表单没做好的典型症状。 表单是用户和程\n\n
\n\n
\n\n序对话的「窗口」,窗口设计得好不好,直接影响用户体验。
举个例子:表单就像餐厅的点菜环节。
- 没校验的点菜:顾客说「要一份随便」,服务员直接传给后厨,后厨一脸懵
- 有校验的点菜:顾客说「要一份随便」,服务员微笑说「抱歉,您得告诉我牛排几分熟、要不要配菜哦」
这章我们就来写一个「会说话」的点菜系统——哦不,是表单系统。
🧱 基础 25 分钟:核心概念
4.4.1 v-model:让表单和数据「心有灵犀」
是什么:v-model 是 Vue3 里的「双向绑定」指令,简单说就是:用户改了输入框的值,程序里的变量自动更新;程序里的变量变了,输入框也自动显示新值。
生活类比:想象你和朋友用同一个笔记本记账。你写一笔,朋友那边立刻显示;朋友改一笔,你这边也自动更新。v-model 就是这个「共享笔记本」。
怎么用:
<template>
<div>
<input v-model="username" placeholder="请输入用户名" />
<p>你输入的是:{{ username }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 定义一个响应式变量
const username = ref('')
</script>
这 6 行代码,完成了输入框和变量的「绑定」——你在输入框里打字,下面立刻显示你打的内容。
4.4.2 v-model 修饰符:给输入「装过滤器」
有时候用户输入的数据不太干净,比如多了空格、或者输入了数字但我们想要字符串。这时候可以用修饰符「过滤」一下:
| 修饰符 | 作用 | 例子 |
|---|---|---|
.trim |
自动去除首尾空格 | " 小明 " → "小明" |
.number |
自动转成数字 | "123" → 123 |
.lazy |
失去焦点时才更新(不是每次按键) | 适合需要完整输入再处理的场景 |
怎么用:
<template>
<div>
<!-- 自动去空格 -->
<input v-model.trim="name" placeholder="姓名(自动去空格)" />
<!-- 自动转数字 -->
<input v-model.number="age" placeholder="年龄(自动转数字)" type="number" />
<p>姓名:{{ name }},年龄:{{ age }}(类型:{{ typeof age }})</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const name = ref('')
const age = ref(0)
</script>
输入 " 小明 " 会自动变成 "小明";输入 "18" 会自动变成数字 18,而不是字符串。
4.4.3 表单校验三件套:rules / validate / clearValidate
光让用户输入还不够,我们得校验输入是否符合要求。Vue3 + Element Plus 的表单校验有三个核心概念:
| 概念 | 比喻 | 作用 |
|---|---|---|
rules |
餐厅的「点菜规则卡」 | 定义哪些情况算「不符合要求」 |
validate() |
服务员的「验菜环节」 | 手动触发校验,返回是否通过 |
clearValidate() |
服务员「收回报错」 | 清除错误提示 |
为什么要用:想象你开了一家外卖店,不做校验 = 任何奇怪的订单都接,结果发现地址写的是「火星」、电话是「ABC」,配送都不知道往哪送。校验就是你的「第一道关卡」。
怎么用(先感受一下完整结构,后面项目里会详细拆解):
<template>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
<el-button @click="submitForm">提交</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref(null)
// 表单数据
const form = reactive({
username: '',
email: ''
})
// 校验规则(重点!)
const rules = {
username: [
{ required: true, message: '用户名不能为空', trigger: 'blur' },
{ min: 3, max: 10, message: '用户名3-10个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '邮箱不能为空', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
]
}
// 提交时校验
const submitForm = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (valid) {
console.log('校验通过,提交数据', form)
} else {
console.log('校验失败,请检查输入')
}
}
// 重置表单和校验状态
const resetForm = () => {
formRef.value.clearValidate()
Object.assign(form, { username: '', email: '' })
}
</script>
4.4.4 自定义校验:规则由你定
有时候内置规则不够用,比如「密码必须包含数字和字母」「用户名不能是 admin」。这时候可以用 validator 写自己的校验逻辑。
怎么用:
<script setup>
// ... 其他代码 ...
// 自定义校验函数
const validatePassword = (rule, value, callback) => {
if (!value) {
callback(new Error('密码不能为空'))
} else if (!/\d/.test(value) || !/[a-zA-Z]/.test(value)) {
callback(new Error('密码必须包含数字和字母'))
} else if (value.length < 6) {
callback(new Error('密码至少6位'))
} else {
callback() // 校验通过
}
}
const rules = {
password: [
{ required: true, validator: validatePassword, trigger: 'blur' }
]
}
</script>
validator 函数的签名是 (rule, value, callback):
- 校验通过:调用 callback() 或 callback(undefined)
- 校验失败:调用 callback(new Error('错误信息'))
🔥 实战 35 分钟:3 个递进的小项目
项目 1:5 分钟完成「用户注册表单」
场景:做一个最简单的注册表单,包含用户名和密码两个字段。
完整代码:
<template>
<div class="register-form">
<h2>用户注册</h2>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="3-10位字母或数字" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="至少6位,需包含数字和字母" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleRegister">注册</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref(null)
const form = reactive({
username: '',
password: ''
})
// 自定义校验:密码必须包含数字和字母
const validatePassword = (rule, value, callback) => {
if (!value) {
callback(new Error('密码不能为空'))
} else if (!/\d/.test(value) || !/[a-zA-Z]/.test(value)) {
callback(new Error('密码必须包含数字和字母'))
} else {
callback()
}
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 10, message: '用户名3-10个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9]+$/, message: '只能输入字母或数字', trigger: 'blur' }
],
password: [
{ required: true, validator: validatePassword, trigger: 'blur' }
]
}
const handleRegister = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (valid) {
console.log('注册成功!', { ...form })
ElMessage.success('注册成功!')
} else {
ElMessage.error('请检查表单输入')
}
}
const handleReset = () => {
formRef.value.clearValidate()
Object.assign(form, { username: '', password: '' })
}
</script>
预期输出:
- 用户名输入 ab → 显示「用户名3-10个字符」
- 用户名输入 admin → 通过
- 密码输入 123456 → 显示「密码必须包含数字和字母」
- 密码输入 abc123 → 通过
- 点击「注册」→ 浏览器控制台打印用户数据
一句话解释:这演示了 v-model 绑定 + rules 校验 + validate 触发的完整流程。
项目 2:15 分钟完成「电商收货地址表单」
场景:电商下单需要填写收货地址,包含姓名、电话、省市区、详细地址、是否默认等字段,数据来自 CSV。
需求分析:
| 字段 | 校验规则 |
|---|---|
| 姓名 | 必填,2-20个字符 |
| 电话 | 必填,手机号格式(11位,以13/14/15/18/19开头) |
| 省份 | 必填,选择一个省份 |
| 详细地址 | 必填,至少5个字符 |
| 默认地址 | 可选,boolean |
完整代码:
<template>
<div class="address-form">
<h2>收货地址</h2>
<el-form :model="form" :rules="rules" ref="formRef" label-width="90px">
<el-form-item label="收货人" prop="name">
<el-input v-model="form.name" placeholder="请输入收货人姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入11位手机号" maxlength="11" />
</el-form-item>
<el-form-item label="省份" prop="province">
<el-select v-model="form.province" placeholder="请选择省份" style="width: 100%">
<el-option label="北京市" value="北京市" />
<el-option label="上海市" value="上海市" />
<el-option label="广东省" value="广东省" />
<el-option label="浙江省" value="浙江省" />
<el-option label="四川省" value="四川省" />
</el-select>
</el-form-item>
<el-form-item label="详细地址" prop="detail">
<el-input v-model="form.detail" type="textarea" placeholder="请输入详细地址(至少5个字)" />
</el-form-item>
<el-form-item prop="isDefault">
<el-checkbox v-model="form.isDefault">设为默认地址</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">保存地址</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
<!-- 地址列表 -->
<div class="address-list" v-if="addressList.length">
<h3>已保存的地址</h3>
<el-card v-for="(addr, index) in addressList" :key="index" class="address-card">
<div><strong>{{ addr.name }}</strong> {{ addr.phone }}</div>
<div>{{ addr.province }} {{ addr.detail }}</div>
<div v-if="addr.isDefault" class="default-tag">默认</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const formRef = ref(null)
const addressList = ref([])
const form = reactive({
name: '',
phone: '',
province: '',
detail: '',
isDefault: false
})
// 手机号格式校验
const validatePhone = (rule, value, callback) => {
if (!value) {
callback(new Error('手机号不能为空'))
} else if (!/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
}
const rules = {
name: [
{ required: true, message: '请输入收货人姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名2-20个字符', trigger: 'blur' }
],
phone: [
{ required: true, validator: validatePhone, trigger: 'blur' }
],
province: [
{ required: true, message: '请选择省份', trigger: 'change' }
],
detail: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ min: 5, message: '详细地址至少5个字符', trigger: 'blur' }
]
}
const submitForm = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (valid) {
// 如果设为默认,先取消其他默认
if (form.isDefault) {
addressList.value.forEach(addr => addr.isDefault = false)
}
// 保存到列表
addressList.value.push({ ...form })
console.log('保存地址:', form)
ElMessage.success('地址保存成功!')
// 重置表单
resetForm()
}
}
const resetForm = () => {
formRef.value.clearValidate()
Object.assign(form, { name: '', phone: '', province: '', detail: '', isDefault: false })
}
</script>
<style scoped>
.address-card {
margin-bottom: 10px;
position: relative;
}
.default-tag {
position: absolute;
top: 10px;
right: 10px;
background: #409eff;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
</style>
预期输出:
- 填写正确信息后点击「保存地址」→ 地址出现在下方列表
- 手机号输入 123 → 显示「请输入正确的手机号」
- 省份不选直接提交 → 显示「请选择省份」
- 勾选「设为默认」→ 新地址显示默认标签,其他地址默认标签消失
一句话解释:这演示了多字段表单 + select/textarea/checkbox 组件 + 列表展示的综合用法。
项目 3:15 分钟完成「待办清单工具」
场景:组合前面学的 axios 请求 + 表单校验,做一个可以增删改查的待办清单,数据从 localStorage 持久化。
需求分析:
| 功能 | 实现方式 |
|---|---|
| 添加待办 | 表单输入 + 校验(内容不能为空) |
| 标记完成 | 点击切换 isDone 状态 |
| 删除待办 | 点击删除,带确认 |
| 数据持久化 | 保存到 localStorage,页面刷新不丢失 |
完整代码:
<template>
<div class="todo-app">
<h2>我的待办清单</h2>
<!-- 输入区域 -->
<el-form :model="form" :rules="rules" ref="formRef" inline @submit.prevent="handleAdd">
<el-form-item prop="content">
<el-input
v-model="form.content"
placeholder="输入待办事项,按回车添加"
style="width: 300px"
@keyup.enter="handleAdd"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleAdd">添加</el-button>
</el-form-item>
</el-form>
<!-- 待办列表 -->
<div class="todo-list">
<el-card
v-for="todo in todoList"
:key="todo.id"
:class="{ 'is-done': todo.isDone }"
class="todo-item"
>
<div class="todo-content">
<el-checkbox v-model="todo.isDone" @change="saveToStorage">
<span :class="{ completed: todo.isDone }">{{ todo.content }}</span>
</el-checkbox>
<span class="todo-time">{{ todo.createdAt }}</span>
</div>
<el-button type="danger" size="small" @click="handleDelete(todo.id)">删除</el-button>
</el-card>
<el-empty v-if="!todoList.length" description="暂无待办事项,添加一个吧~" />
</div>
<!-- 统计 -->
<div class="todo-stats">
共 {{ todoList.length }} 项,已完成 {{ doneCount }} 项
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
const formRef = ref(null)
const todoList = ref([])
const form = reactive({
content: ''
})
const rules = {
content: [
{ required: true, message: '待办内容不能为空', trigger: 'blur' },
{ min: 2, max: 100, message: '内容2-100个字符', trigger: 'blur' }
]
}
// 计算已完成数量
const doneCount = computed(() => todoList.value.filter(t => t.isDone).length)
// 页面加载时从 localStorage 读取数据
onMounted(() => {
const saved = localStorage.getItem('todo-list')
if (saved) {
todoList.value = JSON.parse(saved)
}
})
// 保存到 localStorage
const saveToStorage = () => {
localStorage.setItem('todo-list', JSON.stringify(todoList.value))
}
// 添加待办
const handleAdd = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const newTodo = {
id: Date.now(),
content: form.content.trim(),
isDone: false,
createdAt: new Date().toLocaleString()
}
todoList.value.unshift(newTodo) // 添加到列表开头
form.content = '' // 清空输入框
formRef.value.clearValidate() // 清除校验状态
saveToStorage()
ElMessage.success('添加成功!')
}
// 删除待办
const handleDelete = (id) => {
ElMessageBox.confirm('确定要删除这条待办吗?', '确认删除', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
todoList.value = todoList.value.filter(t => t.id !== id)
saveToStorage()
ElMessage.success('删除成功')
}).catch(() => {})
}
</script>
<style scoped>
.todo-app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.todo-item {
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.todo-item.is-done {
background: #f5f7fa;
opacity: 0.7;
}
.todo-content {
display: flex;
align-items: center;
gap: 10px;
}
.todo-time {
font-size: 12px;
color: #999;
}
.completed {
text-decoration: line-through;
color: #999;
}
.todo-stats {
margin-top: 20px;
text-align: center;
color: #666;
}
</style>
预期输出:
- 输入「买菜」按回车 → 待办列表顶部出现「买菜」(当前时间)
- 点击「买菜」前的勾选框 → 内容变灰色带删除线
- 点击「删除」→ 弹出确认框,确认后删除
- 刷新页面 → 数据仍然存在
一句话解释:这演示了表单校验 + 列表渲染 + localStorage 持久化的完整闭环。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:v-model 和 value 的「爱恨情仇」
❌ 错误写法:
<el-input :value="form.name" @input="form.name = $event" />
✅ 正确写法:
<el-input v-model="form.name" />
原因:v-model 本身就是 :value + @input 的语法糖,没必要自己写两遍。
坑 2:resetFields 只重置值,不清校验
❌ 错误认知:
// 以为这样就能重置整个表单
formRef.value.resetFields()
✅ 正确做法:
// 清值 + 清校验
formRef.value.clearValidate()
Object.assign(form, { name: '', phone: '' })
// 或者一起用
formRef.value.resetFields() // 会重置到初始值,但需要 initial value
原因:resetFields() 会把表单重置到「初始值」,但如果你的 form 是用 reactive({}) 初始化的空对象,这个方法可能不生效。
坑 3:校验触发时机不对
❌ 问题:用户在输入过程中就被校验报错了,体验很差。
<!-- trigger: 'blur' 才是正解 -->
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
✅ 正确做法:
// rules 里指定 trigger: 'blur'(失去焦点时校验)
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
]
}
原因:输入时实时校验会打扰用户,只有「离开输入框」时才校验是更好的体验。
坑 4:async/await 在 validate 里的正确姿势
❌ 错误写法:
const handleSubmit = () => {
const valid = formRef.value.validate() // 没有 await
if (valid) { // 这里 valid 是 Promise,不是 boolean
console.log('提交')
}
}
✅ 正确写法:
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
// catch 返回 false,这样校验失败时也不会抛出异常
if (valid) {
console.log('提交')
}
}
原因:validate() 返回 Promise,不用 await 的话 valid 是 Promise 对象,永远是真值。
坑 5:动态校验规则里用了错误的作用域
❌ 错误写法:
// 只想校验 password 字段,但触发了所有字段校验
formRef.value.validateField('password')
✅ 正确写法:
// 只校验 password 字段
formRef.value.validateField('password')
// 或者校验多个字段
formRef.value.validateField(['username', 'password'])
原因:validateField 可以只校验指定字段,而不是整个表单。
调试技巧:用 validate 的返回值调试
const handleSubmit = async () => {
try {
await formRef.value.validate()
console.log('校验通过,提交数据')
} catch (error) {
console.log('校验失败:', error)
// error 是一个对象,包含了所有字段的错误信息
console.log('失败字段:', Object.keys(error))
}
}
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改个校验规则
- 输入:在项目 1 的基础上,把用户名最小长度从 3 改成 5
- 预期输出:输入 ab 时报错「用户名5-10个字符」
- 提示:只改 rules 里的一个数字
练习 2(2 分钟):加一个字段
- 输入:在项目 1 加上「确认密码」字段,要求和密码一致
- 预期输出:两次密码不一致时报错「两次输入密码不一致」
- 提示:用 validator 函数,参数里可以访问到 form.password
练习 3(2 分钟):换个校验触发方式
- 输入:把项目 2 的手机号校验从 blur 改成 change
- 预期输出:选择省份时(blur 时)不触发手机号校验
- 提示:只改 rules 里一个字段的 trigger
练习 4(2 分钟):统计功能
- 输入:在项目 2 的地址列表里加一个统计「共 X 个地址,默认 X 个」
- 预期输出:列表下方显示「共 2 个地址,默认 1 个」
- 提示:用 computed 计算 addressList.value.length
练习 5(2 分钟):分析报错
- 输入:运行下面代码,点击提交,分析为什么报错
const handleSubmit = async () => {
const valid = formRef.value.validate()
if (valid) {
console.log('提交')
}
}
- 预期输出:浏览器控制台报错
valid不是 boolean - 提示:检查
validate()的返回值类型
作业题:做一个「第 4 章 4.4 表单与校验实战工具」
需求描述:做一个「联系人管理工具」,可以添加、查看、删除联系人,每个联系人有姓名、手机号、邮箱、分类(朋友/同事/家人)四个字段。
功能点:
1. 表单录入:四个字段都有校验,格式不对不能提交
2. 列表展示:卡片形式展示所有联系人,显示分类标签
3. 删除确认:删除前弹出确认框,确认后删除
加分项:
1. 数据保存到 localStorage(刷新不丢失)
2. 按分类筛选联系人
验收标准:
- 能跑起来,不报错
- 输入空表单点提交,能看到校验错误提示
- 手机号输入 abc,能看到格式错误提示
- 添加联系人后,列表里显示新联系人
- 删除联系人前有确认提示
📚 总结 + 资源
一句话总结
这章学了 3 个核心点:v-model 双向绑定数据、rules 定义校验规则、validate 触发校验——三剑客组合起来,就有了「输入 → 校验 → 反馈」的完整表单体验。
延伸学习资源
- Vue3 官方文档 - 表单输入绑定 — 最权威的参考
- Element Plus - Form 表单组件 — 组件用法的最佳实践
- 《Vue3 设计与实现》 — 进阶理解响应式原理的书
你在做表单时遇到过最奇葩的校验场景是什么? 比如要求密码必须是「彩虹屁」格式、邮箱必须以 .com 结尾……评论区聊聊,老粉优先回复!
📌 下章剧透:学了表单和数据请求,下一章我们要用「电商首页 + 详情页」把它们串起来,做一个有点真实模样的购物 App……

评论(0)