前端表单验证:如何设计可复用的校验规则
可复用校验规则的本质是三件事:把规则抽成纯函数、把字段与规则解耦、把错误信息集中管理。做到这三点,新增一个字段只需要在 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 里加一行」,校验逻辑也能单独写单元测试,不再依赖渲染环境。
原文链接:https://www.gj0.com/thread-509.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。