自定义校验规则在前端表单中的实战应用与优化技巧
我的写法,亲测靠谱
做表单校验这么多年,我早就放弃了用框架自带的那套“看起来很美”的规则。尤其是自定义校验,很多人一上来就写个 validator: (value) => value.length > 5,结果上线后一堆边界问题。我自己折腾过好几轮,现在基本固定了一套写法,稳定、可维护、还能复用。
核心思路就一点:把校验逻辑和 UI 解耦,做成纯函数。这样测试方便,复用也简单。比如手机号校验,别在每个表单里都写一遍正则,抽成一个函数:
// utils/validators.js
export const isMobile = (value) => {
if (!value) return false;
const reg = /^1[3-9]d{9}$/;
return reg.test(value);
};
export const validateMobile = (value) => {
if (!value) return '手机号不能为空';
if (!isMobile(value)) return '手机号格式不正确';
return true;
};
然后在表单里直接用:
// 表单组件
const rules = {
phone: [
{ validator: validateMobile, trigger: 'blur' }
]
};
这种写法的好处是:校验逻辑集中管理,改一次全站生效;单元测试也好写,直接测 validateMobile('13800138000') 就行;而且返回值统一(true 或错误信息),不容易出错。
这几种错误写法,别再踩坑了
我见过太多人在这几个地方栽跟头,自己也踩过,列出来给大家避雷。
- 在 validator 里直接操作 DOM 或调用 this:比如有人写
validator: function(value) { return this.someMethod(value) }。这在 Vue 2 的 Options API 里可能“碰巧”能跑,但一旦换到 Composition API 或 React 里就炸了。而且这种写法根本没法单元测试。记住:validator 必须是纯函数,不要依赖上下文。 - 异步校验不处理 loading 状态:比如用户名是否重复,要发请求。很多人只写了
async validator(value) { const res = await api.check(value); return res.available; },但没考虑用户快速切换输入框时,前一个请求还没回来,后一个又发了,导致状态错乱。正确的做法是加个防抖,或者至少在校验开始时清掉上一次的 loading 状态。 - 返回值混乱:有的 validator 返回
false,有的返回字符串,有的抛异常。框架(比如 Element Plus)通常要求返回true/false/Promise/ 错误信息字符串。混用会导致 UI 不显示错误,或者控制台报错。我建议统一:同步校验返回true或错误字符串;异步校验返回Promise<true | string>。
举个反面例子:
// ❌ 千万别这么写
const badValidator = (rule, value, callback) => {
if (value === 'admin') {
callback(new Error('不能使用 admin'));
} else {
callback(); // 这里应该传 null 或不传,但有些框架要求必须传
}
};
这种基于 callback 的写法不仅啰嗦,还容易漏掉 callback() 导致校验卡住。现代框架基本都支持 Promise 或直接返回值,没必要用 callback。
实际项目中的坑
上周刚修了一个线上 bug:用户注册时,密码强度校验在 Safari 上失效。查了半天,发现是因为校验函数里用了 Array.prototype.includes,但没 polyfill,而项目最低兼容到 iOS 9。所以自定义校验函数里的 JS 语法,一定要考虑兼容性。现在我都会在 validator 里只用最基础的语法,或者确保构建工具已经处理了。
另一个坑是异步校验的触发时机。比如邮箱唯一性校验,如果设成 trigger: 'change',用户每敲一个字就发请求,服务器压力大,体验也差。我一般设成 blur,或者配合防抖:
import { debounce } from 'lodash';
const debouncedCheckEmail = debounce(async (email, callback) => {
try {
const res = await fetch(https://jztheme.com/api/check-email?email=${email});
const data = await res.json();
if (data.exists) {
callback(new Error('邮箱已被注册'));
} else {
callback();
}
} catch (err) {
callback(new Error('校验失败,请稍后重试'));
}
}, 500);
const validateEmailUnique = (rule, value, callback) => {
if (!value) return callback();
debouncedCheckEmail(value, callback);
};
不过注意,防抖的 validator 要手动管理 debounce 实例的销毁,否则可能内存泄漏。在 React 里用 useRef 存 debounce 函数,在 Vue 里用 onBeforeUnmount 清理。
还有个小细节:校验函数里别写 console.log。听起来很蠢,但真有人在生产环境留了 log,导致敏感信息泄露。现在我会在 validator 里加个环境判断,或者干脆不用 log,用 Sentry 捕获异常。
复杂场景怎么搞?
有时候校验不是单字段的,比如“结束时间必须大于开始时间”。这时候别硬塞进单个字段的 validator,而是用表单级别的校验。以 Element Plus 为例:
const formRef = ref();
const validateForm = async () => {
try {
await formRef.value.validate();
// 再校验跨字段逻辑
if (form.endTime <= form.startTime) {
// 手动设置错误
formRef.value.validateField('endTime', (err) => {
if (!err) {
// 如果 endTime 本身格式正确,但逻辑不对,需要手动报错
formRef.value.setFields([
{ prop: 'endTime', message: '结束时间必须晚于开始时间' }
]);
}
});
return false;
}
return true;
} catch {
return false;
}
};
虽然有点麻烦,但比把 startTime 作为参数传给 endTime 的 validator 要清晰。后者会导致 validator 签名膨胀,而且难以测试。
另外,对于特别复杂的业务规则(比如保险投保的几十条校验),我会把校验规则配置化。比如:
const insuranceRules = [
{ field: 'age', min: 18, max: 65, message: '年龄需在18-65岁' },
{ field: 'income', min: 10000, message: '年收入不低于1万元' }
];
// 然后写个通用校验器
const validateByRules = (formData, rules) => {
const errors = {};
for (const rule of rules) {
const value = formData[rule.field];
if (rule.min !== undefined && value < rule.min) {
errors[rule.field] = rule.message;
}
// ... 其他规则
}
return errors;
};
这样产品经理改规则,前端改个 JSON 就行,不用动代码。当然,前提是规则不太动态,否则还是得写死逻辑。
最后的小建议
自定义校验别追求一步到位。我一般先写最简版本,上线后根据用户反馈再迭代。比如一开始只校验非空,后面再加格式、再加异步唯一性。毕竟很多校验规则业务自己都说不清,边做边调更实际。
另外,校验文案要友好。别写“格式错误”,写“请输入11位手机号”。用户不知道什么是“格式”,但知道要输11位数字。
以上是我个人对自定义校验的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论