表单校验写崩通常不是规则难,而是规则放错层、时机选错、异步结果互相覆盖。三个都不处理,结果就是"按钮灰了,用户不知道为什么"。
按层往下走:规则归属 → 触发时机 → 同步实现 → 异步竞态 → 跨字段 → 状态与展示 → 与服务端分工。
1. 三层校验,各归其位
| 层 | 例子 | 放哪 | 理由 |
|---|---|---|---|
| 单字段格式 | 邮箱格式、手机号位数、必填 | 前端 | 零成本即时反馈 |
| 跨字段一致性 | 确认密码、结束日期晚于开始 | 前端 | 只有前端知道全部输入 |
| 业务规则 | 用户名已被占用、余额不足、优惠券门槛 | 服务端 | 前端拿不到权威数据 |
唯一不能省的一条:服务端的业务规则必须服务端自己校验,前端校验是体验不是安全。前端校验通过只是"请求发得出去",不代表"结果正确"。
2. 时机:三种事件,三种意图
| 事件 | 何时触发 | 适合校验什么 | 不适合 |
|---|---|---|---|
input / change |
每次输入 | 长度上限、字符集 | 格式类(打字中途必然错) |
blur |
离开字段 | 格式、必填 | 首次聚焦就离开的场景 |
submit |
提交 | 全量校验 | 唯一依赖 |
type Trigger = 'input' | 'blur' | 'submit';
const POLICY: Record<Trigger, boolean> = {
input: false, // 只清错误,不做新校验
blur: true, // 真正校验
submit: true, // 全量校验
};
input 阶段只做一件事:已有错误就清掉。用户开始修正时立刻收红灯,但不抛新错——边打字边报"邮箱格式不对"最招人烦。
"提交后不再显示未触碰字段的错误"是另一条规矩:没碰过的字段提交前不该红。做法是提交时全部置 touched 再统一校验。
3. 同步校验:规则表而非 if 堆
type Rule = {
required?: boolean;
pattern?: RegExp;
min?: number;
max?: number;
message: string; // 文案只写"哪里不对",不写"怎么办"
when?: (v: FormState) => boolean; // 条件规则:隐藏字段不校验
};
const RULES: Record<keyof FormState, Rule[]> = {
email: [
{ required: true, message: '请填写邮箱' },
{ pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, message: '邮箱格式不正确' },
],
age: [{ min: 18, message: '需年满 18 周岁' }],
};
function validateField(k: keyof FormState, v: FormState): string | null {
for (const r of RULES[k]) {
if (r.when && !r.when(v)) continue;
// ...按 required / pattern / min / max 顺序判定
}
return null;
}
| 规则字段 | 作用 | 容易写错的地方 |
|---|---|---|
required |
非空 | 空格串算不算空,要显式 trim() |
pattern |
格式 | 带 g 标志的 RegExp 有 lastIndex 状态,会时对时错 |
min/max |
数值或长度 | 字符串长度与数值大小混用 |
when |
条件生效 | 忘了让"隐藏字段"跳过校验 |
message |
文案 | 文案里写"请重新输入",等于没说 |
规则表的价值在可枚举:能出文档、能生成测试、能和服务端规则对齐。模板里的 v-if 校验做不到。
4. 异步校验:竞态是主要矛盾
用户名校验的真正难点是"后发先至":
- 输入
abc→ 发请求 A - 输入
abcd→ 发请求 B - B 先返回"可用",A 后返回"已占用"
- 界面显示"已占用",而当前输入是
abcd
解法两层:先取消旧的,再校验序号。
let seq = 0;
let ac: AbortController | null = null;
async function checkName(name: string) {
const my = ++seq; // 每次调用取新序号
ac?.abort(); // 取消上一次
ac = new AbortController();
try {
const r = await fetch(`/api/name?n=${encodeURIComponent(name)}`,
{ signal: ac.signal });
if (my !== seq) return; // 回来晚了就丢弃
setError('name', r.ok ? null : '该用户名已被占用');
} catch (e) {
if ((e as Error).name === 'AbortError') return;
}
}
| 手段 | 解决什么 | 单独用行不行 |
|---|---|---|
| 防抖 300–500 ms | 减少请求数 | 不解决乱序 |
AbortController |
取消在途请求 | 取消有延迟,仍需序号兜底 |
| 序号比对 | 丢弃过期响应 | 必须有,最可靠 |
| 提交前强制复校验 | 上次结果可能已过期 | 必须有 |
AbortController 替代不了序号:请求可能已返回、正在队列里排队,abort() 拦不住已完成的响应。序号是唯一严格正确的做法。
异步校验还有保鲜期问题:通过后用户可能在别处被注册。提交时仍要服务端复核,前端结论只作即时提示。
5. 跨字段规则与循环依赖
| 规则 | 依赖方向 | 触发时要做什么 |
|---|---|---|
| 确认密码 = 密码 | confirm ← password |
改 password 要重校验 confirm |
| 结束日期 ≥ 开始日期 | end ← start |
改 start 要重校验 end |
| 区间最小值 ≤ 最大值 | 双向 | 改任一方都要校验另一方 |
| 字段联动清空 | A 变则 B 清空 | 清空后 B 的 touched 要重置 |
const DEPENDENTS: Partial<Record<keyof FormState, (keyof FormState)[]>> = {
password: ['confirm'],
start: ['end'],
country: ['province', 'city'], // 联动清空
};
function onFieldChange(k: keyof FormState) {
for (const dep of DEPENDENTS[k] ?? []) {
if (CLEAR_ON_CHANGE.includes(dep)) { resetField(dep); continue; }
revalidate(dep); // 只重校验,不改 touched
}
}
循环依赖必须打断:指定一个"主字段",副字段只在主字段变化时被动校验。双向都主动会互相触发,表现为打一个字跑十几次校验。
6. 错误展示与可达性
| 展示方式 | 适合 | 问题 |
|---|---|---|
| 字段下方内联 | 绝大多数场景 | 长表单要滚动才看到 |
| 顶部汇总条 | 跨字段规则、服务端错误 | 与字段脱节 |
| Tooltip / 气泡 | 紧凑表单 | 移动端遮挡、无障碍差 |
| 只把按钮置灰 | 任何场景 | 用户不知道为什么 |
"禁用按钮代替错误提示"是最常见的错。按钮灰着,用户判断不了是漏字段还是格式错,只能盲试。
function focusFirstError(errors: Record<string, string>) {
const first = Object.keys(errors)[0];
const el = formEl.querySelector<HTMLElement>(`[name="${first}"]`);
el?.focus();
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
可达性三件事:错误文案挂 aria-describedby、容器加 aria-invalid、出现时用 role="alert" 让读屏播报。只标红,读屏用户收不到。
7. 表单四态:dirty / touched / validating / submitted
type FieldState = {
value: string;
dirty: boolean; // 值被改过
touched: boolean; // 聚焦后离开过
validating: boolean; // 异步校验在途
error: string | null;
};
| 态 | 置位时机 | 用来决定 |
|---|---|---|
dirty |
值 ≠ 初始值 | 离开页面时是否提示"有未保存修改" |
touched |
blur 一次 |
是否显示错误(未触碰不显示) |
validating |
异步请求发出到返回 | 禁提交、显示 loading |
submitted |
点击提交 | 强制显示全部错误、触发全量校验 |
关键组合:submitted && error → 一定显示;!touched && !submitted → 一定不显示。这一条能消掉大半"表单到处飘红"的抱怨。
dirty 与 touched 不能混:前者管"离开时是否提示",后者管"是否显示错误"。混成一个布尔值两个需求就打架。
8. 与服务端校验的分工
| 服务端返回 | 前端动作 |
|---|---|
字段级错误({ field, message }[]) |
回填到对应字段,并标记 touched |
| 表单级错误(业务规则) | 顶部汇总条,别塞进某个字段 |
| 401/403 | 跳登录或提示无权限,不做字段回填 |
| 5xx | 通用提示 + 可重试,不标红字段 |
async function submit() {
submitted = true;
if (!validateAll()) { focusFirstError(errors); return; }
const res = await api.save(values);
if (res.fieldErrors?.length) {
for (const { field, message } of res.fieldErrors) {
setError(field, message); // 回填
setTouched(field, true); // 否则错误不显示
}
focusFirstError(errors);
return;
}
router.push('/done');
}
回填只回填字段级错误。把通用错误塞进第一个字段,用户会在那反复改,永远改不对。
9. 排错:六种"校验不生效"
| 现象 | 原因 | 处理 |
|---|---|---|
| 异步结果覆盖新输入 | 没做序号比对 | 加 seq 并丢弃过期响应 |
| 规则改一处漏一处 | 规则散在模板与脚本 | 抽成规则表,单一来源 |
| 按钮灰着但不提示 | 用禁用代替错误 | 允许点击,点击后给全量错误 |
pattern 时对时错 |
正则带 g 或 y 标志 |
去掉 g,或每次 new RegExp |
| 隐藏字段报"必填" | 条件规则缺失 | 用 when 跳过隐藏字段 |
| 提交后错误不显示 | 只 set error 没 set touched |
回填时同步置 touched |
另一类是提交重复触发:validating 期间只把样式变灰、onClick 仍可触发,双击就发两次。用 disabled 属性,或在 handler 首行 if (submitting) return。
10. 边界:什么时候别自己写
| 场景 | 更合适 | 理由 |
|---|---|---|
| 字段 50+ 的复杂动态表单 | 表单引擎(schema 驱动) | 手写状态爆炸 |
| 需要跨步骤向导与草稿 | 带持久化的表单框架 | 状态机与恢复逻辑复杂 |
| 校验规则需要服务端下发 | JSON Schema + 校验器 | 前后端共用一份规则 |
| 极简表单(3 个字段内) | 手写即可 | 引入库反而增加心智负担 |
一句话:决定上不上框架的是规则数量与联动复杂度,不是表单"重要性"。三个字段的登录页上重型表单库是自找麻烦。
11. 自测
- 三层校验里,哪一层绝对不能只在前端做?为什么?
input阶段应该校验什么、不应该校验什么?- 只加
AbortController为什么仍然会出现"后发先至"的错误结果? - 跨字段双向校验为什么会递归?怎么打断?
dirty与touched各自决定什么?混用一个布尔值会出什么问题?- 服务端返回字段级错误后,为什么还必须把该字段置为
touched?