第4章 4.4 表单与校验

📌 前置知识:学完「第4章 4.3 网络请求:axios 封装」再来看这章,效果翻倍

上一章我们搞定了 axios 封装,手里有了数据请求的利器。但数据拿来之后,总得让用户输入点什么吧?注册账号要填表单,搜索要输入关键词,订单确认要勾选协议……没有表单的 App,就像没有入口的高速公路——数据进不来也出不去

这章我们要解决一个很现实的问题:如何优雅地处理用户输入 + 校验错误。学完你能:

  • 用 v-model 把用户输入和程序状态绑定起来
  • 用校验规则挡住垃圾数据(空表单、格式错误、恶意输入)
  • 用 Element Plus 组件快速搭出一个专业的表单页面

🎯 开场 3 分钟:为什么要学这个?

你有没有遇到过这种崩溃场景?

  • 填了一个长表单,点提交,弹出一堆红色报错,但根本看不懂哪错了
  • 注册时密码输入 123456,系统说「密码太简单」,但没告诉你啥才算复杂
  • 表单填了一半刷新了,之前的数据全没了

这都是表单没做好的典型症状。 表单是用户和程\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 触发校验——三剑客组合起来,就有了「输入 → 校验 → 反馈」的完整表单体验。

延伸学习资源

  1. Vue3 官方文档 - 表单输入绑定 — 最权威的参考
  2. Element Plus - Form 表单组件 — 组件用法的最佳实践
  3. 《Vue3 设计与实现》 — 进阶理解响应式原理的书

你在做表单时遇到过最奇葩的校验场景是什么? 比如要求密码必须是「彩虹屁」格式、邮箱必须以 .com 结尾……评论区聊聊,老粉优先回复!

📌 下章剧透:学了表单和数据请求,下一章我们要用「电商首页 + 详情页」把它们串起来,做一个有点真实模样的购物 App……

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。