
1. 从实际需求聊起为什么自定义校验是绕不开的坎用过 Element Plus 做表单的同学应该都有这个体会内置的校验规则能解决 80% 的基础场景比如必填、长度限制、邮箱格式但剩下 20% 的业务规则靠内置规则根本搞不定。我举个例子注册页面要校验手机号你直接用pattern写正则当然可以但遇到“身份证号需要校验最后一位校验码”“两次输入的密码必须一致”“开始时间不能晚于结束时间”这种跨字段、带业务逻辑的校验rules对象里那几行配置瞬间就不够用了。这时候就轮到validator登场。网上关于 Element Plus 自定义校验的教程其实不少但我翻了一圈大部分只是贴一段官方示例代码就完了没人把validator的函数签名、回调机制、异步场景、耦合校验这些细节掰开揉碎讲清楚。很多初学者照抄代码能跑通换一个场景就抓瞎甚至写出“callback 调了三次”“校验一直不通过”“表单提交前校验根本没执行”这种问题然后一头雾水。这篇文章我打算按照自己在真实项目里用 Element Plus 的经验把validator的底层逻辑、常见坑位和完整实战一次讲透适合刚接触 Vue3 Element Plus 的初级开发者也适合那些已经写过几个表单但始终对校验规则“知其然不知其所以然”的同学。顺便提一句这阵子 Element Plus 中文社区里经常有人问“vue3 使用 element plus 的时候组件显示的是英文”这个锅其实不在校验身上而是默认语言包没切换成中文属于组件库的全局配置问题但因为它和el-form的校验提示信息强相关我也会在后面的问题排查章节单独拿出来聊因为校验报错信息是中文还是英文直接影响用户体验和调试效率。2. 认识 validator参数与运行机制2.1 函数签名rule、value、callback 到底怎么用Element Plus 的表单校验底层依赖async-validator这个库validator是rules对象中某个字段的自定义校验函数。官方文档给的签名长这样validator: (rule, value, callback) { ... }三个参数看着简单实际含义却经常被误解。第一个参数rule是当前字段对应的完整校验规则对象。什么意思就是你写在rules里的那一整条规则它会把规则里的所有属性都带进来。举个例子const rules { phone: [ { required: true, message: 请输入手机号, trigger: blur, validator: validatePhone } ] }在validatePhone函数内部打印rule你会看到一个对象里面包含required: true、message: 请输入手机号、trigger: blur以及你自定义传入的其他属性。这个特性很关键意味着你可以在规则对象里塞一些额外的参数然后在validator里拿出来用。比如你想让同一个校验函数复用于不同长度的限制const rules { name: [{ min: 2, max: 10, validator: validateLength }] } const validateLength (rule, value, callback) { if (value (value.length rule.min || value.length rule.max)) { callback(new Error(长度需在 ${rule.min} 到 ${rule.max} 之间)) } else { callback() } }不用写死逻辑直接从rule.min、rule.max取值这就是rule参数存在的意义。第二个参数value就简单了它就是当前表单字段的值不需要额外解释。但有一个细节容易踩坑如果你的表单字段初始值是undefined而不是空字符串在validator里直接用value.length会直接报 TypeError。所以任何自定义校验的第一步都建议先判断值是否存在再做后续逻辑。第三个参数callback是最核心也最容易被用错的东西。callback是一个函数它代表校验结果的通知机制。callback()表示校验通过callback(new Error(提示信息))表示校验失败。有一个很反直觉的点如果你在validator里既没有调用callback()也没有调用callback(new Error(...))表单会一直处于“校验中”状态validate方法返回的 Promise 永远不会 resolve 或 reject整个表单就“卡死”了。2.2 回调与 Promise两种写法怎么选async-validator从早期版本开始就支持两种异步校验的写法经典回调风格和 Promise 风格。Element Plus 官方文档主推的是回调风格但在实际开发中很多人更习惯写 async/await所以第二种写法也完全可以。回调风格是最稳妥的因为它对版本兼容性最好const validatePhone (rule, value, callback) { if (!value) { callback(new Error(请输入手机号)) } else if (!/^1[3-9]\d{9}$/.test(value)) { callback(new Error(手机号格式不正确)) } else { callback() } }Promise 风格其实就是在validator里直接返回一个 Promiseconst validatePhone (rule, value) { if (!value) { return Promise.reject(new Error(请输入手机号)) } if (!/^1[3-9]\d{9}$/.test(value)) { return Promise.reject(new Error(手机号格式不正确)) } return Promise.resolve() }两种写法的校验效果完全一样区别只在代码风格。我个人推荐有异步请求需求的场景用 Promise 风格因为配合async/await阅读起来更直观比如校验用户名是否已存在的场景const validateUsername async (rule, value) { if (!value) { return Promise.reject(new Error(请输入用户名)) } const isExists await checkUsernameExists(value) if (isExists) { return Promise.reject(new Error(用户名已被注册)) } return Promise.resolve() }注意这里有个细节值得留意Promise 风格里 reject 的错误对象会自动转成校验失败信息resolve 则表示通过。有些同学写成return Promise.reject(用户名已被注册)传了一个普通字符串而不是 Error 对象这会导致 Element Plus 在渲染错误信息时出现一些诡异的行为最好还是统一new Error()。这个习惯养成之后无论你是在浏览器里直接跑还是在单元测试里断言错误信息都不会出幺蛾子。3. 实战高频校验场景的完整实现3.1 手机号校验的两种思路手机号校验是我在后台管理系统里写的最多的自定义 validator没有之一。很多表单都要录手机号而内置的pattern规则虽然能写正则但你想在“为空”和“格式不正确”时给出不同的提示信息就不得不拆成两条规则或者直接上自定义校验。先说最朴素的实现const validatePhone (rule, value, callback) { if (!value) { callback(new Error(请输入手机号)) } else if (!/^1[3-9]\d{9}$/.test(value)) { callback(new Error(请输入正确的手机号)) } else { callback() } }这段代码逻辑清晰能覆盖大部分需求。但有一个隐藏问题如果你配合了required规则并且在validator里又写了!value的判断会出现重复报错的情况。为什么因为 Element Plus 执行校验时会先检查required如果字段值为空直接就会触发required对应的message根本不会执行到validator。所以你需要想清楚validator里的空值判断到底要不要写。我个人的习惯是如果这条规则已经设置了required: true那么validator里就不再写空值判断只处理“非空但格式不对”的情况如果字段是选填的才需要在validator里加空值放行逻辑否则用户不填也会触发格式校验体验很糟糕。选填字段的正确写法是“空值直接放行”const validateOptionalPhone (rule, value, callback) { if (!value) { callback() return } if (!/^1[3-9]\d{9}$/.test(value)) { callback(new Error(请输入正确的手机号)) return } callback() }实战中还有一个升级需求校验座机号、400 电话、手机号任意一种。这时候正则就要改成兼容多种格式const validateContact (rule, value, callback) { if (!value) { callback() return } const phoneReg /^1[3-9]\d{9}$/ const telReg /^0\d{2,3}-?\d{7,8}$/ if (phoneReg.test(value) || telReg.test(value)) { callback() } else { callback(new Error(请输入正确的手机号或座机号)) } }这种“多格式兜底”的需求在真实项目里非常常见直接写死在pattern里反而更难维护用validator可以把判断逻辑写得可读性更高。3.2 身份证号码校验从正则到校验码算法身份证校验是另一个高频场景。刚开始写的时候我以为/(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/这个正则就够用了。后来被测试同事提了一个 bug说“身份证号 110105194912310027 校验通过了但这是网上能找到的标准错误号码”我才意识到光靠正则不够还得校验最后一位校验码。身份证号码的校验码算法并不复杂核心是“加权因子”和“模 11 余数映射表”。前 17 位数字分别乘以对应的加权因子求和后除以 11取余数再通过余数找到对应的校验码。完整实现如下const validateIdCard (rule, value, callback) { if (!value) { callback() return } const reg /^\d{17}[\dXx]$/ if (!reg.test(value)) { callback(new Error(身份证号码格式不正确)) return } const idCard value.toUpperCase() const weights [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] const checkCodes [1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2] let sum 0 for (let i 0; i 17; i) { sum Number(idCard[i]) * weights[i] } const checkCode checkCodes[sum % 11] if (checkCode ! idCard[17]) { callback(new Error(身份证号码校验码不正确)) return } callback() }这段代码看起来很专业但其实里面的原理不复杂。加权因子是国标里固定写死的checkCodes数组的索引正好对应sum % 11的余数。我建议你把它保存成工具函数因为不仅表单校验能用列表页批量校验、Excel 导入校验都能复用。还有一点必须注意身份证号码包含出生日期进阶做法是连生日和性别一起校验。比如月份只能是 01-12日期要符合当月天数第 17 位奇数为男、偶数为女。但这些都是“业务加强版”如果你的系统只是记录身份证信息校验码算法已经能过滤掉 99% 的无效号码了不需要一步到位搞太复杂。3.3 跨字段联动校验确认密码与区间时间联动校验是整个自定义校验体系里最体现“自定义”价值的地方。最简单的例子是确认密码密码和确认密码必须相等。因为validator作用在单个字段上所以你在“确认密码”的校验函数里需要拿到“密码”字段的当前值这就要借助表单实例或者响应式数据对象。以script setup写法为例const formData reactive({ password: , confirmPassword: }) const validateConfirmPassword (rule, value, callback) { if (!value) { callback(new Error(请再次输入密码)) } else if (value ! formData.password) { callback(new Error(两次输入的密码不一致)) } else { callback() } }这种写法能跑通但存在一个隐患如果你在“密码”字段清空了值然后立刻修改“确认密码”此时formData.password是空字符串值当然不相等会报“两次输入的密码不一致”。这个体验是合理的但其实还有更精细的做法当密码字段变化时主动触发确认密码字段重新校验而不是等用户手动去碰那个字段才校验。Element Plus 的FormInstance上有一个validateField方法可以单独触发某个字段的校验const formRef ref() const handlePasswordChange () { formRef.value.validateField(confirmPassword).catch(() {}) }这样用户改完密码确认密码字段会立刻重新校验一次配合trigger: [blur, change]体验会顺畅很多。这里注意validateField返回的是一个 Promise如果不捕获异常控制台会报 unhandled rejection。很多新手在这里会踩一个坑明明逻辑写对了但控制台总有一堆红色报错就是因为没有.catch()。时间区间联动的思路也是一样的。比如“开始时间不能晚于结束时间”const validateStartTime (rule, value, callback) { if (!value) { callback(new Error(请选择开始时间)) } else if (formData.endTime value formData.endTime) { callback(new Error(开始时间不能晚于结束时间)) } else { callback() } } const validateEndTime (rule, value, callback) { if (!value) { callback(new Error(请选择结束时间)) } else if (formData.startTime value formData.startTime) { callback(new Error(结束时间不能早于开始时间)) } else { callback() } }需要注意时间组件绑定的值格式取决于你用的date-picker配置。如果你设置了value-formatYYYY-MM-DD HH:mm:ss那么value就是字符串理论上可以直接用和比较。但字符串比较依赖格式统一万一有人某处没设置value-format拿到的是Date对象直接比较大小也成立但最稳妥的方式是在校验前统一转成Date再比较。联动校验的核心思想就一句话在单个字段的 validator 里读取共享状态并依赖组件事件主动触发关联字段的重新校验。想清楚这两件事任何“字段间互相牵制”的需求无非是在这个框架里加逻辑而已。4. 常见问题与踩坑实录4.1 callback 只能调用一次否则后果很隐蔽先说一个我在 code review 里反复强调的规则一个校验函数里callback只能执行一次且一旦执行就要立刻return。很多人写校验不太注意控制流代码会写成这样const validateXxx (rule, value, callback) { if (!value) { callback(new Error(不能为空)) } if (value bad) { callback(new Error(非法值)) } callback() }这段代码问题很大。如果value为空第一个条件成立callback(new Error(...))被调用一次但代码没返回继续往下走如果value ! bad又会执行callback()。等于同一个校验周期里先报错再通过最终哪个结果生效这在async-validator内部是没准的可能表现为表单一直在转圈、错误信息一闪而过、或者报错后立刻消失。排查这种 bug 极其痛苦因为它不是必现的。正确的姿势是每个判断分支都带上returnconst validateXxx (rule, value, callback) { if (!value) { callback(new Error(不能为空)) return } if (value bad) { callback(new Error(非法值)) return } callback() }记住“一分支一回调一返回”这个口诀。如果你用if/else链也要保证逻辑分支互斥。实际开发里我建议在自定义校验函数的第一行就明确想清楚“有哪些退出路径”确保每个路径只调用一次callback。4.2 异步校验不生效或提示不更新异步校验最常见的坑是数据还没回来校验就已经结束了。比如const validateUsername (rule, value, callback) { fetch(/api/check, { body: value }).then(res { if (res.exists) { callback(new Error(用户名已存在)) } else { callback() } }) }这段代码看似没问题但如果fetch请求耗时较长用户在拿到结果前就点了提交按钮validate会一直挂在 pending 状态直到请求返回才 resolve 或 reject。这本身是符合预期的但问题是很多项目会在loading状态下禁用提交按钮如果请求长时间不返回用户会以为系统卡死了体验很糟糕。解决思路有两个。第一个是加超时机制超过 3 秒直接通过或者提示“校验服务不可用请稍后重试”避免表单永远卡住。第二个是防抖等用户停止输入 500ms 后再发起请求减少无意义的校验请求。还有一个隐蔽的问题异步校验里如果捕获了异常但忘了调用callback同样会导致表单卡死。比如const validateUsername async (rule, value, callback) { try { const res await fetch(...) ... } catch (e) { // 这里没有调用 callback } }请求报错时callback 没有被调用表单永远处于校验中没有任何提示。我强烈建议在异步校验的catch分支里要么callback(new Error(校验失败请重试))要么至少callback()放行绝不能让异常静默吞掉。4.3 trigger 配置不当校验永远不触发很多同学写完validator发现校验不触发第一反应是怀疑函数写错了但真正的问题往往出在trigger配置上。trigger决定校验在什么事件下触发常见的值是blur和change。对于el-input这两个值都有意义blur是失焦时校验change是值变化时校验。对于el-select、el-date-picker这类组件change才是触发时机blur可能根本不会触发。如果只配置了blur下拉选择改变时是不会触发校验的。一个容易遗漏的细节自定义校验函数里trigger的值并不影响函数本身的执行逻辑只影响“什么时候调用这个函数”。而async-validator在校验时会过滤掉与触发事件不匹配的规则。所以如果你的规则是这样的rules: { type: [{ required: true, message: 请选择类型, trigger: blur, validator: validateType }] }而你的组件是el-select那么required和validator都不会在change事件触发时执行只有字段失焦时才校验。很多下拉框根本没有 blur 事件导致校验看起来从来没生效过。建议的配置方式是表单项的规则统一使用trigger: change或者同时配置trigger: [blur, change]这样适配性最好。如果你搞不清楚组件到底支持哪些事件就用数组形式通用性最强。4.4 Element Plus 组件显示英文与校验提示语言的联动问题前面提到“vue3 使用 element plus 的时候组件显示的是英文”这是老生常谈的国际化问题。Element Plus 默认使用英文语言包所以el-pagination的“Total”字样、el-table的空数据文案、el-date-picker的日期面板都是英文同理el-form内置规则的默认提示信息比如 required 的默认 message也会变成 “is required” 这样的英文提示。解决办法是在引入 Element Plus 时指定中文语言包。以main.js为例import { createApp } from vue import ElementPlus from element-plus import zhCn from element-plus/es/locale/lang/zh-cn import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus, { locale: zhCn }) app.mount(#app)如果你用的是按需导入可以在根组件外面包一层el-config-providertemplate el-config-provider :localezhCn router-view / /el-config-provider /template script setup import zhCn from element-plus/es/locale/lang/zh-cn /script做了这个配置之后内置规则的默认提示信息才会变成中文。但要注意如果你在自定义validator里自己new Error(请输入手机号)这个提示是你自己传的跟语言包没有任何关系所以语言配置不影响自定义提示内容。这一点要分清避免排查半天以为语言包没生效其实错误信息是你自己写死的。4.5 校验函数中引用响应式数据的陷阱还有一个很容易被忽略的问题validator函数如果引用了响应式数据表单初始渲染时拿到的可能是旧值。比如const formData reactive({ minPrice: 0, maxPrice: 100 }) const validateMaxPrice (rule, value, callback) { if (value formData.minPrice) { callback(new Error(最大值不能小于最小值)) } else { callback() } }看起来逻辑没毛病但如果minPrice变了而maxPrice字段不重新触发校验这个错误提示不会自动更新。尤其是两个字段都有值时改了最小值最大值那边还是旧状态直到你手动重新触发最大值校验才会更新。应对办法是在minPrice的change事件里主动触发maxPrice的validateField这和前面联动校验的思路一致。这种做法从一开始就把“校验依赖”设计清楚而不是依赖用户操作顺序。真实项目中各种数值区间、时间区间、父子级联选择都会遇到这个问题提前规划好联动触发关系能省去上线后一大堆体验类 bug。5. 把自定义校验优雅地整合进项目5.1 从“散落函数”到“校验规则模块”写了几个自定义校验函数之后你可能会发现它们在多个页面重复出现用户管理页要校验手机号、订单页也要校验手机号、审批流配置页还是要校验手机号。如果每个页面都复制粘贴一遍validatePhone一旦规则有变化比如新增了虚拟号段你就要全局搜索替换非常痛苦。比较好的做法是抽一个独立的校验模块统一导出。比如在src/utils/validators.js里export const validatePhone (rule, value, callback) { ... } export const validateIdCard (rule, value, callback) { ... } export const validateEmail (rule, value, callback) { ... }然后在需要的地方按需引入import { validatePhone, validateIdCard } from /utils/validators这种做法有几个额外的好处可以统一维护错误提示文案、可以加单元测试验证正则逻辑、可以在多个业务模块间共享规则。对于一个中大型项目来说这些都是刚需。如果你管理的是更复杂的动态表单配置系统还可以把校验规则定义成 JSON 描述结构然后在渲染层动态解析生成rules对象。这种场景通常出现在低代码平台、自定义表单设计器里属于更进阶的玩法。核心思路是给每个校验规则一个可序列化的标识比如type: phone、type: idcard再用一个映射表把标识解析成真正的validator函数。5.2 动态校验规则的实现思路有些业务的校验规则不是写死的而是根据后端配置来的。比如 A 客户的系统要求用户名字段“必填长度 2-20”B 客户要求“选填长度 1-50”。这种动态化需求前端总不能每次发版调整代码。正确的做法是校验规则由接口下发前端动态组装。拿到后端返回的配置之后可以这样处理const buildRules (fieldConfig) { const rules [] if (fieldConfig.required) { rules.push({ required: true, message: ${fieldConfig.label}不能为空, trigger: blur }) } if (fieldConfig.min || fieldConfig.max) { rules.push({ min: fieldConfig.min, max: fieldConfig.max, message: ${fieldConfig.label}长度需在 ${fieldConfig.min} 到 ${fieldConfig.max} 之间, trigger: blur }) } return rules }如果后段下发了更复杂的校验类型比如正则表达式或者自定义校验标识你可以在映射表里做匹配const validatorMap { phone: validatePhone, idcard: validateIdCard, email: validateEmail, custom: (rule, value, callback) { // 从 rule.customRule 里取出动态正则 if (value !new RegExp(rule.customRule).test(value)) { callback(new Error(rule.customMessage)) } else { callback() } } }这种做法把“校验规则”和“业务代码”彻底解耦规则变了不用动前端逻辑只改接口配置就行。不过要注意安全边界动态正则表达式如果来自不可信的后端建议做一层白名单校验防止注入恶意表达式影响页面性能。5.3 给业务封装一层“带状态的提交逻辑”最后分享一个我在多个项目里反复使用的模式把“校验 提交 防重复点击”整合成一个自定义 Composable。这样业务组件里只需要调用一行代码就能完成表单校验和提交的完整流程。// useFormSubmit.js import { ref } from vue export function useFormSubmit(formRef, submitFn) { const submitting ref(false) const handleSubmit async () { if (submitting.value) return try { await formRef.value.validate() } catch (err) { console.warn(表单校验失败, err) return } submitting.value true try { await submitFn() } finally { submitting.value false } } return { submitting, handleSubmit } }使用的时候const { submitting, handleSubmit } useFormSubmit(formRef, async () { await api.submit(formData) })这个组合式函数把“校验失败不提交”“提交过程中按钮禁用”“防重复提交”这些细节全部封装进去业务代码就干净多了。你可能会问这和validator有什么关系关系在于任何校验逻辑最终都服务于表单提交这个动作。当你的validator、trigger、validateField这些基础细节都搞定之后整个提交链路才算真正闭合。否则页面里校验规则一堆但提交时其实没有正确触发校验那也白搭。我在实际项目中见过太多次这种情况开发同学把精力花在写各种精巧的 validator 上却没有注意提交前validate()是否被正确调用。千万不要做那个“校验函数写了一堆但提交照样放行”的人。6. 写在最后的一点实战建议如果你准备在正式项目里大规模使用自定义校验我建议先花半小时梳理一遍所有需要校验的字段把字段类型、校验规则、提示文案列成一个表。我自己维护项目时一直用这个办法效果很明显。拿手机号来说你把“必填提示”和“格式错误提示”分开写测试验收的时候就不会再有“到底有没有填”和“填得对不对”的混淆。另一点小技巧开发阶段给validator函数都加上console.log(rule, value)你就能直观看到每次校验触发的时机和携带的参数排查 trigger 配置不准的问题会非常快。上线前再把日志去掉就行。关于 Element Plus 组件的英文显示问题如果你已经按前面说的在入口处配置了zhCn语言包界面还有个别组件显示英文先检查是不是有组件单独重新import过语言包或者某个组件模板里直接写死了英文文本。这个坑不常见但遇到过一次就够你折腾半天。最后再分享一个判断标准当你在表单里发现某个校验逻辑超过了“一个正则 一个提示”的复杂度就直接上validator自定义校验不要硬塞在rules配置里。可维护性远比少写几行代码重要。实际项目里校验规则永远在变把逻辑封装出来以后改起来才会真的轻松。