表单校验:Element Plus 规则与异步验证
1. 前端校验的定位
Java 后端用 @Valid + JSR-303:
@PostMapping("/user")
public User create(@Valid @RequestBody UserDTO dto) { ... }
public class UserDTO {
@NotBlank(message = "用户名必填")
@Size(min = 3, max = 20)
private String username;
}
前端的表单校验作用不是安全防线,而是:
- 提升体验:用户输完立刻知道错在哪,不用等提交后跳一堆红框
- 减少无效请求:拦掉明显错误,省后端一次处理
- 引导填写:格式要求在字段旁实时提示
核心原则:前端校验过的,后端必须再校验一次。用户用 DevTools 绕过前端易如反掌。
2. Element Plus 校验的三件套
<el-form
ref="formRef" <!-- 拿实例调 validate() -->
:model="form" <!-- 数据对象 -->
:rules="rules" <!-- 校验规则对象 -->
>
<el-form-item
prop="username" <!-- 绑定 rules 里哪个字段 -->
label="用户名"
>
<el-input v-model="form.username" />
</el-form-item>
</el-form>
三个要件缺一不可:
ref—— 调方法:model—— 数据prop—— 告诉el-form-item看哪个字段(写错 prop 是 80% 的"校验不生效"的原因)
Element Plus 的规则系统基于 async-validator,规则对象是标准格式,不是 Element Plus 专属。
3. 同步规则:最常用的七八种
import type { FormRules } from 'element-plus'
const rules: FormRules = {
username: [
{ required: true, message: '用户名必填', trigger: 'blur' },
{ min: 3, max: 20, message: '长度 3-20 位', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字、下划线', trigger: 'blur' },
],
email: [
{ required: true, message: '邮箱必填', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
age: [{ type: 'number', min: 0, max: 120, message: '年龄 0-120', trigger: 'blur' }],
website: [{ type: 'url', message: 'URL 格式不正确', trigger: 'blur' }],
tags: [{ type: 'array', min: 1, message: '至少选一个标签', trigger: 'change' }],
agree: [
{ type: 'boolean', required: true, message: '请同意条款', trigger: 'change' },
// 进阶:只有 true 才算通过
{
validator: (_, value, cb) => (value === true ? cb() : cb(new Error('请同意条款'))),
trigger: 'change',
},
],
}
规则字段速查:
| 字段 | 含义 |
|---|---|
required |
必填 |
type |
string / number / boolean / array / email / url / date |
min/max |
长度范围(字符串)或数值范围(数字) |
pattern |
正则 |
message |
报错信息 |
trigger |
blur(失焦)/ change(变化)/ ['blur', 'change'] |
validator |
自定义函数(见第 5 节) |
trigger 怎么选
blur—— 离开输入框才校验,适合文本输入(防止边打字边报错烦人)change—— 值变就校验,适合下拉选择、单选、复选['blur', 'change']—— 失焦和改变都校验,最严格
4. 类型陷阱:type: 'number' 和 v-model.number
<el-input v-model.number="form.age" />
不加 .number 修饰符时,v-model 绑定的是字符串 "18",不是数字 18。规则里写 type: 'number' 会报"类型不匹配"。
两种解法:
v-model.number—— 自动转成数字- 用
<el-input-number>—— Element Plus 的数字专用输入框,自带加减按钮
第二种更稳,数字输入场景优先用 el-input-number。
5. 自定义 validator:灵活处理
标准规则不够用时,validator 函数里想怎么判就怎么判:
const rules: FormRules = {
phone: [
{
validator: (_, value: string, callback) => {
if (!value) return callback(new Error('手机号必填'))
if (!/^1[3-9]\d{9}$/.test(value)) return callback(new Error('手机号格式不正确'))
callback() // 不传参 = 通过
},
trigger: 'blur',
},
],
}
签名:(rule, value, callback) => void
- 有问题:
callback(new Error('...')) - 通过:
callback()
坑点:不管通过还是不通过,都必须调 callback。忘了调,表单会一直卡在"校验中"状态。
6. 异步校验:远程查重
"用户名是否已被注册" —— 必须问后端。validator 支持异步:
import { refDebounced } from '@vueuse/core'
import { checkUsernameExists } from '@/api/user'
const rules: FormRules = {
username: [
{ required: true, message: '必填', trigger: 'blur' },
{
validator: async (_, value: string) => {
if (!value) return
const exists = await checkUsernameExists(value)
if (exists) throw new Error('该用户名已被占用')
},
trigger: 'blur',
},
],
}
两种写法等价:
callback(new Error('...'))—— 传统写法throw new Error('...')—— async 函数里更干净
6.1 防刷:加个小 cache
每次 blur 都请求浪费带宽。缓存一下已检查过的值:
const usernameCache = new Map<string, boolean>()
const rules: FormRules = {
username: [
{
validator: async (_, value: string) => {
if (!value) return
if (usernameCache.has(value)) {
if (usernameCache.get(value)) throw new Error('该用户名已被占用')
return
}
const exists = await checkUsernameExists(value)
usernameCache.set(value, exists)
if (exists) throw new Error('该用户名已被占用')
},
trigger: 'blur',
},
],
}
Java 对照:相当于 @Cacheable 给接口调用挂本地缓存。
7. 跨字段校验:确认密码
"确认密码"要等于"密码"字段。validator 里访问 form:
import { reactive } from 'vue'
const form = reactive({ password: '', confirm: '' })
const rules: FormRules = {
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '至少 8 位', trigger: 'blur' },
],
confirm: [
{
validator: (_, value: string) => {
if (value !== form.password) throw new Error('两次密码不一致')
},
trigger: 'blur',
},
],
}
坑点:密码改了,"确认密码"字段的校验不会自动重跑。要手动触发:
import { watch } from 'vue'
watch(
() => form.password,
() => {
if (form.confirm) formRef.value?.validateField('confirm')
},
)
validateField('字段名') 单独校验某一个字段。
8. 动态规则:规则随别的字段变
"证件类型是身份证时校验 18 位数字,是护照时校验字母数字组合":
import { computed } from 'vue'
const form = reactive({ idType: 'idcard', idNumber: '' })
const rules = computed<FormRules>(() => ({
idNumber: [
{ required: true, message: '必填', trigger: 'blur' },
form.idType === 'idcard'
? { pattern: /^\d{17}[\dX]$/, message: '身份证格式不正确', trigger: 'blur' }
: { pattern: /^[A-Z0-9]{7,9}$/, message: '护照格式不正确', trigger: 'blur' },
],
}))
规则写在 computed 里,依赖变了自动重算。
别忘了重新校验:证件类型切换后,输入框里已有的值可能不符合新规则——手动 validateField:
watch(() => form.idType, () => formRef.value?.validateField('idNumber'))
9. 表单方法:validate / resetFields / clearValidate
const formRef = ref<FormInstance>()
// 全量校验,返回 Promise<boolean>
const onSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
ElMessage.warning('请检查填写内容')
return
}
// 发起请求...
}
// 重置为初始值 + 清校验状态(要配合 :model 的 prop 使用)
const onReset = () => formRef.value?.resetFields()
// 清校验状态但不重置数据
const onClearErrors = () => formRef.value?.clearValidate()
// 单字段校验
const onBlurUsername = () => formRef.value?.validateField('username')
resetFields 不生效? 需要初始值:
// ❌ 直接赋值再 reset,reset 不知道初始值是什么
const form = reactive({ username: '' })
form.username = 'abc'
formRef.value?.resetFields() // 重置后还是 'abc'
// ✅ Element Plus 把"初始值"锁定在 el-form 首次渲染时
// 用 initial state 搭配 cloneDeep 自己重置更可靠:
import { cloneDeep } from 'lodash-es'
const initial = { username: '' }
const form = reactive(cloneDeep(initial))
const reset = () => Object.assign(form, cloneDeep(initial))
10. 校验 UI:错误样式与提示位置
10.1 默认样式
Element Plus 默认把错误信息显示在输入框下方。如果 label 在左侧,错误信息也会靠左对齐。
10.2 改成浮动提示
空间紧张时,让错误信息浮在右侧:
<el-form inline-message>
<!-- 错误信息单行显示,不占额外高度 -->
</el-form>
10.3 完全自定义
用 <template #error> 插槽:
<el-form-item prop="username">
<el-input v-model="form.username" />
<template #error="{ error }">
<span class="text-red-500 flex items-center gap-1">
<el-icon><WarningFilled /></el-icon>
{{ error }}
</span>
</template>
</el-form-item>
11. 常见业务规则复用
每个项目都要写的规则,抽成工具函数复用:
// src/utils/validators.ts
import type { FormItemRule } from 'element-plus'
export const required = (msg = '此项必填'): FormItemRule => ({
required: true,
message: msg,
trigger: 'blur',
})
export const maxLength = (max: number): FormItemRule => ({
max,
message: `不超过 ${max} 个字符`,
trigger: 'blur',
})
export const phone: FormItemRule = {
pattern: /^1[3-9]\d{9}$/,
message: '手机号格式不正确',
trigger: 'blur',
}
export const password: FormItemRule[] = [
required('请输入密码'),
{ min: 8, message: '密码至少 8 位', trigger: 'blur' },
{
validator: (_, value: string) => {
if (!/[a-z]/.test(value)) throw new Error('需包含小写字母')
if (!/[A-Z]/.test(value)) throw new Error('需包含大写字母')
if (!/\d/.test(value)) throw new Error('需包含数字')
},
trigger: 'blur',
},
]
export const ipv4: FormItemRule = {
pattern:
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/,
message: 'IP 地址格式不正确',
trigger: 'blur',
}
使用:
import { required, maxLength, phone } from '@/utils/validators'
const rules: FormRules = {
name: [required('姓名必填'), maxLength(20)],
mobile: [required(), phone],
}
Java 对照:自定义 @Constraint 注解 + ConstraintValidator 的复用思路——写一次,全项目用。
12. 表单提交的完整流程
把前面几节串起来的"标准提交流程":
const submitting = ref(false)
const onSubmit = async () => {
// 1. 前端校验
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
ElMessage.warning('请完善表单')
return
}
// 2. 防重复提交
if (submitting.value) return
submitting.value = true
try {
// 3. 提交
await createUser(form)
ElMessage.success('创建成功')
// 4. 清理
formRef.value?.resetFields()
// 或跳转页面
router.push('/users')
} catch (err: any) {
// 5. 后端的字段级错误回显到表单上
if (err.response?.data?.errors) {
handleServerErrors(err.response.data.errors)
} else {
ElMessage.error(err.message || '提交失败')
}
} finally {
submitting.value = false
}
}
// 后端返回 { errors: { username: '已被注册' } }
const handleServerErrors = (errors: Record<string, string>) => {
Object.entries(errors).forEach(([field, msg]) => {
// 用 Element Plus 的内部 API 在字段上挂错误
const formItem = formRef.value?.fields?.find((f: any) => f.prop === field)
;(formItem as any)?.validateState = 'error'
;(formItem as any).validateMessage = msg
})
}
关键点:
- 前端校验 + 防重复 + 后端校验错误回显 —— 三层兜底
submitting绑到按钮:loading,防止用户连点- 后端返回字段级错误时映射到对应字段,比满屏
ElMessage友好得多
13. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 校验不触发 | prop 和 rules 的键名对不上 |
检查两处拼写 |
嵌套字段 a.b.c 校验不生效 |
prop 不支持点号 |
改用数组字段或扁平化表单 |
v-model.number 初始值是空字符串 |
空字符串 parseInt 成 NaN | 初始值直接写 0 或 null |
| 自定义 validator 不报错也不通过 | 忘了调 callback |
每条路径都必须调 callback |
| 切换字段(如证件类型)后旧错误还在 | 规则变了但 validateField 没重跑 |
watch 里调 validateField |
resetFields 重置后还是旧值 |
form 初始值不是 el-form 首次挂载时的值 |
自己维护 initial + Object.assign 回去 |
小练习
- 写一个注册表单:用户名(异步查重)、邮箱、密码、确认密码、同意条款
- 把密码规则抽到
src/utils/validators.ts里复用 - 实现"证件类型切换时重新校验证件号"的动态规则
- 给一个表单加"前端校验 + 后端错误回显到字段"的完整提交流程
- 测试把规则的
trigger从blur改成change,观察体验差异
延伸阅读
- Element Plus - Form 表单
- async-validator 规则参考
- Vue 3 - Composition API FAQ
- VeeValidate(另一个流行校验库,脱离 UI 库)
- Zod(TypeScript 优先的 schema 校验,前后端共用类型神器)