Form校验在实际项目中的常见问题与高效解决方案

夏侯威威 组件 阅读 1,721
赞 25 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

Form校验这事,我干了快六年,从jQuery.validate手写正则开始,到Ant Design、Element Plus的内置规则,再到自己封装hooks——踩过的坑比填过的表单还多。现在项目里我基本不碰第三方校验库的默认行为,而是自己搭一层轻量但可控的校验层。核心就三点:字段级状态独立、提交时集中触发、错误信息绝不靠“猜”

Form校验在实际项目中的常见问题与高效解决方案

下面这段代码是我们当前主力项目里用的 useFormValidation(React + TypeScript),不是玩具 demo,是每天跑在生产环境里的东西:

import { useState, useCallback, useRef } from 'react';

export function useFormValidation(initialValues = {}) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const validatorsRef = useRef({});

  const setValidator = (name, validator) => {
    validatorsRef.current[name] = validator;
  };

  const validateField = useCallback((name) => {
    const validator = validatorsRef.current[name];
    if (!validator) return;

    const value = values[name];
    const error = typeof validator === 'function' ? validator(value, values) : null;
    
    setErrors(prev => ({ ...prev, [name]: error }));
    setTouched(prev => ({ ...prev, [name]: true }));
  }, [values]);

  const validateAll = useCallback(() => {
    const newErrors = {};
    Object.keys(validatorsRef.current).forEach(name => {
      const validator = validatorsRef.current[name];
      const value = values[name];
      const error = typeof validator === 'function' ? validator(value, values) : null;
      if (error) newErrors[name] = error;
    });

    setErrors(newErrors);
    setTouched(Object.keys(validatorsRef.current).reduce((acc, key) => {
      acc[key] = true;
      return acc;
    }, {}));

    return Object.keys(newErrors).length === 0;
  }, [values]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  const handleChange = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    // 注意:这里不自动校验!只改值
  }, []);

  const handleBlur = useCallback((name) => {
    validateField(name);
  }, [validateField]);

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validateField,
    validateAll,
    reset,
    setValidator
  };
}

为什么这么写?因为我在好几个项目里被“自动校验”搞崩溃过——比如用户刚点进输入框,还没打字,就弹出“邮箱格式错误”;或者异步校验(如用户名是否已存在)没做防抖,连输几个字母发了5个请求。所以我的原则是:值变更不触发校验,失焦才触发,提交前强制全量校验。而且 validator 是函数引用,支持闭包访问当前所有字段值,像“确认密码必须和密码一致”这种跨字段逻辑,直接写在 validator 里,不额外维护依赖。

这几种错误写法,别再踩坑了

来,说几个我见过最多、也自己亲手写错过好几次的写法:

  • 把校验逻辑塞进 onChange 里:尤其是带正则的邮箱、手机号,用户每按一个键都跑一遍 test() 还好,但一旦加了 debounce 或 fetch 就灾难了。有次我写了 setTimeout(validate, 300),结果用户快速切换输入框,上一个定时器还没执行,下一个又覆盖了——错误提示永远滞后两步。
  • 用 state 做 validator 的依赖数组:比如 useCallback(() => {...}, [values]),看着合理,但 values 是对象,每次 setState 都是新引用,导致 validator 函数无限重生成,React.memo 失效,子组件疯狂 rerender。后来我改成用 ref 存 values,callback 里手动读 ref.current,稳了。
  • 错误信息硬编码字符串,不走 validator 返回值:比如写死 if (!email) setError('邮箱不能为空')。问题来了——国际化怎么搞?后端返回的错误码怎么映射?不同场景提示语要不一样(注册页 vs 忘记密码页)怎么办?后来我们统一要求 validator 必须返回 string | null,空字符串也当 null 处理,前端只负责展示,不参与文案决策。
  • submit 事件里只 validate 一部分字段:比如用户点了“下一步”,只校验当前 tab 的字段,但忘了把其他 tab 的 touched 置为 true。结果切回去再点提交,那些字段还是“未触达”状态,错误不显示。我们后来加了 validateAll() 强制兜底,哪怕当前只关心 A 字段,B 字段也得检查一遍,只是不 focus 它而已。

实际项目中的坑

真实业务永远比 demo 复杂。举两个让我熬夜改了三版的例子:

动态表单项 + 异步校验:用户可添加多个联系人,每个联系人都要校验手机号唯一性。我一开始对每个 input 都绑了独立的 useEffect(() => { checkUnique(phone) }, [phone]),结果新增 10 行,页面卡成 PPT。后来改成:只在点击“提交”或“失焦”时,收集所有待校验的 phone 数组,去重后批量请求 /api/phones/check?list=138...,139...,返回 { "138...": true, "139...": false },再分发回对应字段。性能好了,错误定位也准了。

表单嵌套子组件,子组件有自己的校验:比如地址选择器(含省市区三级联动 + 详细地址输入)。我试过让子组件把校验结果通过 onValidate 回传,结果父组件要处理一堆回调,逻辑分散。后来改用 context + useImperativeHandle,父组件调 formRef.current.validate() 时,自动递归调用所有子组件的 validate 方法,统一收口。虽然多写了点 wrapper,但调试起来清爽太多。

还有个小细节:后端返回的错误字段名,经常和前端字段名不一致。比如前端叫 userPhone,后端返回 phone_number。我们不再手写映射表,而是在 API 层统一做一次 alias 转换,校验层只认前端字段名。这个转换逻辑放在 axios response interceptor 里,一行代码搞定:

// 响应拦截器中
if (error.response?.data?.field_errors) {
  const mapped = {};
  Object.entries(error.response.data.field_errors).forEach(([k, v]) => {
    const key = fieldMap[k] || k; // fieldMap = { phone_number: 'userPhone', ... }
    mapped[key] = v;
  });
  error.response.data.field_errors = mapped;
}

结尾

以上是我目前在 Form 校验上最常用、最省心的一套实践。它不炫技,不追求“最优雅”,目标就一个:改需求不改校验逻辑,加字段不加 bug,上线后 QA 找不出校验问题。当然也有妥协点——比如没支持自定义错误图标、没集成 i18n 的 message formatter,但这些我们用 CSS 和全局 message 工具补上了,没必要塞进校验 hook 里。

如果你也在用类似思路,或者有更好的解法(比如怎么更优雅地处理表单重置时的校验态同步?怎么让 async validator 支持 abort?),欢迎评论区交流。这个方案我们跑了大半年,线上零相关客诉,但谁知道下个项目会不会又被逼出新招数呢 😅

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论