手把手教你实现灵活可扩展的自定义规则引擎

W″东焕 工具 阅读 2,353
赞 27 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

最近在给一个表单组件加自定义规则,不是简单的“必填”或“邮箱格式”,而是要动态判断:比如“当用户选了‘企业客户’,手机号必须是11位且以13/15/17/18开头;如果选了‘个人客户’,手机号可以留空”。这种规则,靠 requiredpattern 是搞不定的。

手把手教你实现灵活可扩展的自定义规则引擎

我试了三种主流方案:原生 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 把 refinemessage 参数改成对象了,旧写法 { 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 对象引用不变),得配合 useStateuseReducer 手动更新,一不小心就出现“提示文字还是‘正在检查…’,但接口早就返回了”的情况。折腾了半天发现得用 useEffect 监听 phone 变化 + 防抖 + 取消上次请求……最后代码量比 Zod 方案还多。

我的结论是:除非你有非常特殊的校验逻辑(比如需要 canvas 截图识别、调本地摄像头扫码验证),否则别手写。它自由,但自由的代价是你得自己处理所有边界。

我的选型逻辑

看场景,我一般选这三个中的一个:

  • 静态页 / 快速 PoC / 不需要状态联动 → 原生 setCustomValidity,5 分钟搞定,不纠结
  • 中大型 React 项目 / 多人协作 / 有后端 API → Zod + RHF,前期多花 1 小时配好,后面半年省心
  • 需要深度定制 UI 反馈(比如实时高亮某段文本)或特殊输入源(语音转文字后校验) → 手写函数,但会基于 Zod 的基础校验再封装,不从零开始

没有银弹。Zod 不是万能的,比如它不直接支持“当 A 字段变化时,重新触发 B 字段校验”——得靠 watch() + trigger() 组合,写起来不如 Vue 的 computed 直观。但比起手写一堆 useEffectuseState,我还是选 Zod。

踩坑提醒:这三点一定注意

1. 原生 validity 一定要手动清空,别信“用户改了就自动恢复”,不会。

2. Zod 的 refine 是同步的,异步用 superRefine,且必须调 ctx.addIssue(),不能直接 return false

3. 所有方案都要考虑“空字符串”和“undefined”的区别,React 中受控组件的初始值常是 undefined,而后端可能要求 null,Zod 的 .optional().nullable() 行为完全不同,别混用。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 SWR 缓存校验结果、用 Zod 生成 JSON Schema 供后端复用,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
FSD-红芹
文章里提到的一个细节处理方式,让我优化了项目中的一个模块,效果很好。
点赞 4
2026-02-14 10:25