手把手教你实现灵活可扩展的自定义规则引擎
谁更灵活?谁更省事?
最近在给一个表单组件加自定义规则,不是简单的“必填”或“邮箱格式”,而是要动态判断:比如“当用户选了‘企业客户’,手机号必须是11位且以13/15/17/18开头;如果选了‘个人客户’,手机号可以留空”。这种规则,靠 required 或 pattern 是搞不定的。
我试了三种主流方案:原生 HTML5 的 setCustomValidity()、Zod + React Hook Form、手写校验函数配合状态管理。没一上来就查文档,先翻自己去年写的旧项目——结果发现两个用了 Zod 的项目,一个因为升级到 v4 后 refine 用法变了,校验逻辑直接失效;另一个在异步规则里忘了 .superRefine(),导致接口报错后 UI 卡在 loading 状态半天没反馈……行吧,又来一遍踩坑总结。
原生 setCustomValidity:轻量但容易翻车
我比较喜欢在快速原型或纯静态页里用它,代码少、不依赖库、浏览器支持也够用(Chrome 10+、Firefox 4+)。但它有个致命问题:一旦调用了 setCustomValidity('xxx'),input 就永远处于 invalid 状态,直到你显式调用 setCustomValidity('')。这个细节我踩过三次坑,最后一次是半夜改登录页,提交失败后点了清空按钮,再输密码依然提示“密码格式错误”——因为清空时只清了 value,没重置 validity。
用法长这样:
const input = document.querySelector('#phone');
input.addEventListener('input', () => {
const val = input.value.trim();
if (val && !/^1[3-9]d{9}$/.test(val)) {
input.setCustomValidity('请输入有效的中国大陆手机号');
} else {
input.setCustomValidity(''); // 这行不能漏!
}
});
优点是真的轻:没打包体积、没学习成本、调试方便。缺点也很实在:没法组合规则(比如“长度+正则+异步查重”得自己拼)、不支持 i18n(错误文案硬编码)、跟 React/Vue 的响应式体系完全脱节。如果你只是写个临时 demo 或 CMS 后台的简单表单,我一般会选它;但只要涉及状态联动、异步校验、多语言,我就立刻切走。
Zod + React Hook Form:类型安全,但上手有点“拧巴”
这是我现在新项目默认选型,但不是因为它多优雅,而是因为 TS 类型能自动对齐校验逻辑和表单值结构,改字段名时 IDE 会直接报错,比人肉 grep 安全多了。比如后端返回的字段叫 user_phone,但你前端用 phone,Zod schema 里写错一个字母,TS 就红。这点救过我两次——一次是接口字段改名没同步到校验层,另一次是团队协作时有人把 email 写成 mail。
核心代码就这几行:
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const formSchema = z.object({
customerType: z.enum(['personal', 'enterprise']),
phone: z.string().optional()
.refine(val => {
if (form.getValues().customerType === 'enterprise') {
return /^1[3-9]d{9}$/.test(val || '');
}
return true;
}, { message: '企业客户需填写有效手机号' }),
});
type FormValues = z.infer<typeof formSchema>;
const Form = () => {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
// 提交时自动校验,错误信息进 formState.errors
};
这里注意我踩过好几次坑:refine 是同步的,异步校验得用 superRefine + ctx.addIssue(),而且不能直接 await fetch(),得包一层 Promise。还有个小坑:Zod v4 把 refine 的 message 参数改成对象了,旧写法 { message: 'xxx' } 直接被忽略,错误提示变空字符串,debug 半小时才发现是版本升级惹的祸。
优点是:类型强约束、错误路径清晰、可复用 schema(API 请求体、表单、后端校验都能共用一套定义);缺点是:学习曲线略陡、异步校验写法反直觉、打包体积增加约 12KB(gzip 后)。如果你团队用 TS、有长期维护计划、表单逻辑复杂,我会毫不犹豫推 Zod。
手写校验函数:自由度最高,也最费劲
这是我最早用的方案——写个 validatePhone(value, context) 函数,context 里传当前表单状态,手动判断,返回 { valid: boolean, message: string }。好处是:想怎么写就怎么写,加缓存、加防抖、加日志都随你。坏处是:每个项目都得重复造轮子,出问题全是自己的锅。
我之前在一个后台系统里这么干过:
const validateForm = (data) => {
const errors = {};
if (data.customerType === 'enterprise' && !data.phone) {
errors.phone = '企业客户必须填写手机号';
} else if (data.phone && !/^1[3-9]d{9}$/.test(data.phone)) {
errors.phone = '手机号格式不正确';
}
// 异步查重
if (data.phone && data.customerType === 'enterprise') {
errors.phone = '正在检查手机号是否已被注册…';
checkPhoneExists(data.phone).then(exists => {
if (exists) {
errors.phone = '该手机号已被注册';
}
});
}
return errors;
};
看到没?这里有个典型问题:异步校验结果没法“回填”到 errors 对象里(JS 对象引用不变),得配合 useState 或 useReducer 手动更新,一不小心就出现“提示文字还是‘正在检查…’,但接口早就返回了”的情况。折腾了半天发现得用 useEffect 监听 phone 变化 + 防抖 + 取消上次请求……最后代码量比 Zod 方案还多。
我的结论是:除非你有非常特殊的校验逻辑(比如需要 canvas 截图识别、调本地摄像头扫码验证),否则别手写。它自由,但自由的代价是你得自己处理所有边界。
我的选型逻辑
看场景,我一般选这三个中的一个:
- 静态页 / 快速 PoC / 不需要状态联动 → 原生
setCustomValidity,5 分钟搞定,不纠结 - 中大型 React 项目 / 多人协作 / 有后端 API → Zod + RHF,前期多花 1 小时配好,后面半年省心
- 需要深度定制 UI 反馈(比如实时高亮某段文本)或特殊输入源(语音转文字后校验) → 手写函数,但会基于 Zod 的基础校验再封装,不从零开始
没有银弹。Zod 不是万能的,比如它不直接支持“当 A 字段变化时,重新触发 B 字段校验”——得靠 watch() + trigger() 组合,写起来不如 Vue 的 computed 直观。但比起手写一堆 useEffect 和 useState,我还是选 Zod。
踩坑提醒:这三点一定注意
1. 原生 validity 一定要手动清空,别信“用户改了就自动恢复”,不会。
2. Zod 的 refine 是同步的,异步用 superRefine,且必须调 ctx.addIssue(),不能直接 return false。
3. 所有方案都要考虑“空字符串”和“undefined”的区别,React 中受控组件的初始值常是 undefined,而后端可能要求 null,Zod 的 .optional() 和 .nullable() 行为完全不同,别混用。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 SWR 缓存校验结果、用 Zod 生成 JSON Schema 供后端复用,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。
