表单校验: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>

三个要件缺一不可

  1. ref —— 调方法
  2. :model —— 数据
  3. 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 怎么选


4. 类型陷阱:type: 'number'v-model.number

<el-input v-model.number="form.age" />

不加 .number 修饰符时,v-model 绑定的是字符串 "18",不是数字 18。规则里写 type: 'number' 会报"类型不匹配"。

两种解法

  1. v-model.number —— 自动转成数字
  2. <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。忘了调,表单会一直卡在"校验中"状态。


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',
    },
  ],
}

两种写法等价

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
  })
}

关键点


13. 常见坑点

现象 原因 解法
校验不触发 proprules 的键名对不上 检查两处拼写
嵌套字段 a.b.c 校验不生效 prop 不支持点号 改用数组字段或扁平化表单
v-model.number 初始值是空字符串 空字符串 parseInt 成 NaN 初始值直接写 0null
自定义 validator 不报错也不通过 忘了调 callback 每条路径都必须调 callback
切换字段(如证件类型)后旧错误还在 规则变了但 validateField 没重跑 watch 里调 validateField
resetFields 重置后还是旧值 form 初始值不是 el-form 首次挂载时的值 自己维护 initial + Object.assign 回去

小练习

  1. 写一个注册表单:用户名(异步查重)、邮箱、密码、确认密码、同意条款
  2. 把密码规则抽到 src/utils/validators.ts 里复用
  3. 实现"证件类型切换时重新校验证件号"的动态规则
  4. 给一个表单加"前端校验 + 后端错误回显到字段"的完整提交流程
  5. 测试把规则的 triggerblur 改成 change,观察体验差异

延伸阅读