前端表单验证:如何设计可复用的校验规则

juming
juming 正式会员超兽战士
发布于 2026-10-07 19:56 ·0 浏览 ·0 回复

可复用校验规则的本质是三件事:把规则抽成纯函数、把字段与规则解耦、把错误信息集中管理。做到这三点,新增一个字段只需要在 schema 里加一行声明,不用再复制粘贴 if-else。推荐落地形式是「规则工厂 + 组合器 + 声明式 schema」三层结构。

为什么表单校验代码越写越乱?

结论:乱在三个耦合——校验逻辑和 UI 耦合、单字段规则和跨字段规则耦合、错误文案和判断逻辑耦合。典型症状是组件里堆满 if (!value) setError('请填写'),然后 if (value !== form.password) 这种跨字段判断又混进来,最后谁都改不动。解法是把校验变成「输入一个值,输出布尔或错误信息」的纯函数,组件只负责渲染结果,不负责判断。

一条可复用的校验规则,函数签名该怎么定?

结论:统一签名为 (value, formValues) => true | string,返回 true 表示通过,返回字符串表示失败并直接作为错误文案。formValues 作为第二个参数是为了让「确认密码等于密码」这类跨字段规则也能用同一个签名,不需要额外的特殊分支。

type Rule = (value: any, formValues: Record<string, any>) => true | string;

const required = (msg = '必填'): Rule =>
  (v) => (v === undefined || v === null || v === '' ? msg : true);

const minLen = (n: number, msg?: string): Rule =>
  (v) => (String(v ?? '').trim().length >= n ? true : (msg ?? `至少 ${n} 个字符`));

const pattern = (re: RegExp, msg: string): Rule =>
  (v) => (v === '' || re.test(String(v)) ? true : msg);

注意 minLen 里先 trim():用户输入三个空格时,' '.length 是 3,不 trim 会误判通过。

多个规则怎么组合?应该短路吗?

结论:用 all 组合器按顺序执行,遇到第一个失败就短路返回,这样错误提示永远只有一条,用户不会被六条红字同时淹没。

const all = (...rules: Rule[]): Rule =>
  (v, form) => {
    for (const r of rules) {
      const res = r(v, form);
      if (res !== true) return res;
    }
    return true;
  };

const passwordRule = all(
  required('请输入密码'),
  minLen(8, '密码至少 8 位'),
  pattern(/^(?=.*[A-Za-z])(?=.*\d).+$/, '密码需同时包含字母和数字'),
);

schema 怎么写才算真正解耦?

结论:schema 是一张「字段 → 规则数组」的映射表,校验器只做遍历,组件只消费结果,两边都不认识对方。

const schema: Record<string, Rule[]> = {
  username: [required(), minLen(3), pattern(/^[a-zA-Z0-9_]+$/, '只能含字母数字下划线')],
  password: [passwordRule],
  confirm: [all(required(), (v, f) => (v === f.password ? true : '两次密码不一致'))],
};

function validate(schema, form) {
  const errors: Record<string, string> = {};
  for (const key in schema) {
    const res = all(...schema[key])(form[key], form);
    if (res !== true) errors[key] = res;
  }
  return errors; // 空对象即通过
}

换到 React Hook Form、Formik、Vee-Validate 这些库时思路完全一样,只是把 validate 挂到库的 resolver 上,规则本身可以原样复用。

异步校验(如用户名是否已存在)怎么做才对?

结论:异步规则必须加 300ms 防抖 + 竞态取消,否则会出现「先发的慢请求后返回,把最新结果覆盖掉」的经典 bug。实现上用 AbortController 或自增 requestId 比对。

let seq = 0;
async function checkUsername(name: string) {
  const id = ++seq;
  await sleep(300);              // 防抖
  if (id !== seq) return true;   // 已被更新的输入取代,丢弃
  const taken = await api.exists(name);
  return taken ? '该用户名已被占用' : true;
}

异步校验只在 blur 和提交时触发,不要在 onChange 上跑,否则每个字符都会打一次接口。

校验时机应该怎么安排?

结论:初次校验用 blur,提交时全量校验,字段已经报过错之后才切到 onChange 实时清除错误。三段式能同时避免「还没输完就报红」和「改完不消失」两个体验问题。具体状态机是:touched=false 时不显示错误;touched=true 且校验失败显示错误;用户继续输入且这次通过,立即清掉错误。

还有哪些容易踩的坑?

结论:四个高频坑——0 和 '' 的空值判断、trim 时机、错误信息硬编码、以及大表单全量重渲染。数字 0 是合法值,用 !value 判断会把 0 判成空,必须显式写 v === '' || v == null。错误文案统一走一个 messages 对象或 i18n 函数,别散落在各处,改文案时才不用全局搜索。字段超过 20 个时,把每个字段做成独立订阅的子组件,避免一次输入触发整表重渲染。

把规则抽成纯函数、用 all 短路组合、schema 声明字段与规则、异步加防抖与竞态取消、时机分三段——这套结构加上去,新增字段的边际成本基本就是「在 schema 里加一行」,校验逻辑也能单独写单元测试,不再依赖渲染环境。

版权声明:本文来自 GJ站长论坛《前端表单验证:如何设计可复用的校验规则》
原文链接:https://www.gj0.com/thread-509.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。

全部回复 0

还没有回复,来抢沙发~