2691 字
约 8 分钟
13
S14-X13 · 表单校验体系:同步、异步与跨字段规则

表单校验写崩通常不是规则难,而是规则放错层、时机选错、异步结果互相覆盖。三个都不处理,结果就是"按钮灰了,用户不知道为什么"。

按层往下走:规则归属 → 触发时机 → 同步实现 → 异步竞态 → 跨字段 → 状态与展示 → 与服务端分工。


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. 异步校验:竞态是主要矛盾

用户名校验的真正难点是"后发先至":

  1. 输入 abc → 发请求 A
  2. 输入 abcd → 发请求 B
  3. B 先返回"可用",A 后返回"已占用"
  4. 界面显示"已占用",而当前输入是 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. 自测

  1. 三层校验里,哪一层绝对不能只在前端做?为什么?
  2. input 阶段应该校验什么、不应该校验什么?
  3. 只加 AbortController 为什么仍然会出现"后发先至"的错误结果?
  4. 跨字段双向校验为什么会递归?怎么打断?
  5. dirty 与 touched 各自决定什么?混用一个布尔值会出什么问题?
  6. 服务端返回字段级错误后,为什么还必须把该字段置为 touched?
S14-X13 · 表单校验体系:同步、异步与跨字段规则
https://www.clxhxhhr.top/posts/5828/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。