D02 表单校验与异步提交

2026-10-31 16:590 阅读约 17 分钟JavaScript

D02 表单校验与异步提交

摘要:表单的复杂度集中在四处:规则与提示一一对应、错误定位到字段、提交期间禁止重复提交、失败后保留已填内容。可维护做法是把规则写成数据表,校验函数只负责遍历;提交状态用一个枚举表示,界面由状态派生。本文给出规则表、校验器、提交状态机与错误回填四项实现。
关键词:表单校验,规则表,提交状态,防重复提交,错误回填

所属:frontend/js / D-实战 | 上一篇:D01-组件封装与状态管理 | 下一篇:D03-请求层封装

1 引言

把校验写成 if 链后,新增一条规则要改三处:判断、提示文案、字段定位。提交按钮不加状态,用户连点五次会产生五条记录。

本文回答四个问题:规则怎么表达、错误怎么显示、提交过程怎么表示、失败后怎么恢复。范围见表 1-1。

项 本文覆盖 本文不覆盖
规则 必填、长度、格式、范围、自定义异步 服务端校验规则
状态 idle/validating/submitting/error/done 全局状态管理
交互 字段级提示、首个错误聚焦、重复提交拦截 多步骤表单

2 预备知识

项 要求 核对方式
环境 Chrome 130+,本地服务器 页面经 http 打开
前置 A08、B06 会事件委托与异常分类
文件 form/index.html、form/validate.js 编码 UTF-8

3 原理与实现

3.1 规则表驱动的校验

每条规则是一条数据:字段、判定函数、提示文案。校验器只遍历规则,如图 3-1 所示。

flowchart TD
    A["读取表单值 values"] --> B["遍历规则表 rules"]
    B --> C{"字段是否为空且非必填"}
    C -->|是| D["跳过该条规则"]
    C -->|否| E["执行判定 ok(value)"]
    E -->|通过| B
    E -->|不通过| F["记录 errors[field] = message"]
    F --> B
    B --> G{"errors 是否为空"}
    G -->|是| H["进入提交流程"]
    G -->|否| I["聚焦首个错误字段"]

图 3-1 校验器按规则表遍历,错误集中在 errors 对象

图上可见:规则的顺序即提示的优先级,同一字段的多条规则只会显示第一条不通过的提示,避免一次抛出全部错误。

3.2 完整实现

<!-- form/index.html;运行环境:Chrome 130+ -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>表单校验</title>
  </head>
  <body>
    <form id="signup" novalidate>
      <p>
        <label for="email">邮箱</label>
        <input id="email" name="email" autocomplete="email" />
        <span class="err" data-for="email" role="alert"></span>
      </p>
      <p>
        <label for="nickname">昵称</label>
        <input id="nickname" name="nickname" />
        <span class="err" data-for="nickname" role="alert"></span>
      </p>
      <p>
        <label for="password">密码</label>
        <input id="password" name="password" type="password" />
        <span class="err" data-for="password" role="alert"></span>
      </p>
      <button type="submit">注册</button>
      <output id="status"></output>
    </form>
    <script type="module" src="./validate.js"></script>
  </body>
</html>
// form/validate.js;运行环境:Chrome 130+
const form = document.querySelector('#signup');
const status = document.querySelector('#status');

// 1 规则表:数据而非分支
const rules = [
  { field: 'email', test: (v) => v !== '', message: '邮箱不能为空' },
  { field: 'email', test: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v), message: '邮箱格式不正确' },
  { field: 'nickname', test: (v) => v.length >= 2 && v.length <= 12, message: '昵称需 2~12 个字符' },
  { field: 'password', test: (v) => v.length >= 8, message: '密码至少 8 位' },
  { field: 'password', test: (v) => /[0-9]/.test(v) && /[a-zA-Z]/.test(v), message: '密码需同时包含字母与数字' },
];

function readValues(fd) {
  return {
    email: String(fd.get('email') ?? '').trim(),
    nickname: String(fd.get('nickname') ?? '').trim(),
    password: String(fd.get('password') ?? ''),
  };
}

function validate(values) {
  const errors = {};
  for (const { field, test, message } of rules) {
    if (errors[field]) continue;              // 每个字段只留第一条错误
    if (!test(values[field])) errors[field] = message;
  }
  return errors;
}

function paint(errors) {
  for (const slot of form.querySelectorAll('.err')) {
    const field = slot.dataset.for;
    slot.textContent = errors[field] ?? '';
  }
  for (const input of form.elements) {
    if (!input.name) continue;
    input.setAttribute('aria-invalid', String(Boolean(errors[input.name])));
  }
  const first = Object.keys(errors)[0];
  if (first) form.elements[first].focus();
}

// 2 提交状态机
const STATUS = {
  idle: '空闲',
  submitting: '提交中…',
  error: '提交失败,可重试',
  done: '注册成功',
};

let state = 'idle';
let submitting = false;

function setState(next, detail = '') {
  state = next;
  status.textContent = detail || STATUS[next];
  const btn = form.querySelector('button[type="submit"]');
  btn.disabled = next === 'submitting';
  btn.setAttribute('aria-busy', String(next === 'submitting'));
}

// 3 模拟服务端:偶发失败用于验证错误回填
async function postSignup(payload, signal) {
  await new Promise((resolve, reject) => {
    const id = setTimeout(resolve, 400);
    signal.addEventListener('abort', () => {
      clearTimeout(id);
      reject(new DOMException('aborted', 'AbortError'));
    });
  });
  if (payload.email.endsWith('@blocked.dev')) {
    throw new Error('该邮箱已被注册');
  }
  return { id: 1, ...payload };
}

const controllerRef = { current: null };

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (submitting) return;                     // 双保险:按钮禁用 + 状态判断

  const values = readValues(new FormData(form));
  const errors = validate(values);
  paint(errors);
  if (Object.keys(errors).length > 0) {
    setState('idle', `有 ${Object.keys(errors).length} 处需要修改`);
    return;
  }

  submitting = true;
  setState('submitting');
  controllerRef.current = new AbortController();

  try {
    const saved = await postSignup(values, controllerRef.current.signal);
    setState('done', `注册成功,编号 ${saved.id}`);
    form.reset();
    paint({});
  } catch (err) {
    if (err.name === 'AbortError') {
      setState('idle', '已取消');
    } else {
      setState('error', err.message);         // 字段内容保留,不 reset
    }
  } finally {
    submitting = false;
    controllerRef.current = null;
  }
});

// 4 输入时即时清除该字段的错误
form.addEventListener('input', (event) => {
  const field = event.target.name;
  if (!field) return;
  const slot = form.querySelector(`.err[data-for="${field}"]`);
  if (slot?.textContent) {
    slot.textContent = '';
    event.target.setAttribute('aria-invalid', 'false');
  }
});

setState('idle');

3.3 实施清单

序号 改动 文件 说明
1 建立表单结构与错误槽位 form/index.html 每个字段一个 role="alert" 槽位
2 规则写成数据表 form/validate.js 新增规则只加一行
3 实现校验与错误绘制 form/validate.js 聚焦首个错误字段
4 提交状态与防重复 form/validate.js 按钮禁用 + 标志位双保险
5 失败保留内容、成功清空 form/validate.js 仅在成功分支 reset

4 验证与讨论

  1. 直接点击「注册」,确认三条错误依次显示且焦点停在邮箱。
  2. 填写合法内容但邮箱以 @blocked.dev 结尾,确认输出「该邮箱已被注册」且输入内容保留。
  3. 快速连点提交按钮五次,确认只发起一次请求(status 只变一次)。
  4. 在提交过程中关闭页面或执行 controllerRef.current.abort(),确认状态回到「已取消」。
序号 验收项 判定标准
1 必填与格式 每条规则都有对应提示
2 字段定位 焦点落在首个错误字段
3 去重提交 连点只提交一次
4 失败恢复 内容保留,可直接重试
5 可访问性 错误节点有 role="alert",输入有 aria-invalid

五项取舍见表 4-1。

决策点 采用方案 备选方案 不选的理由
规则表达 数据表 函数内 if 链 新增规则要改三处,易漏文案
错误呈现 字段级槽位 顶部汇总列表 汇总无法指出具体字段
防重复 按钮禁用 + 标志位 只用按钮禁用 表单可通过回车提交,绕过按钮
失败处理 保留输入,提示可重试 失败后 reset 用户需重新填写,成本高
校验时机 提交时全量 + 输入时单字段 输入时全量 边输入边报错干扰填写

讨论与局限。 服务端仍必须独立校验,前端校验只是体验层。另,异步校验(如昵称查重)会引入竞态,需按 D03 的方式取消旧请求后再比较结果。

5 常见问题

现象 原因 处理
回车提交绕过禁用 只禁用了按钮 加提交标志位判断
错误提示不消失 只在提交时重绘 监听 input 清空对应字段错误
提交失败后内容被清空 在 finally 中调用了 reset 只在成功分支 reset
中文输入法组合期间校验报错 监听了 input 未过滤合成事件 判断 event.isComposing
连点产生多条记录 请求无幂等键 服务端加幂等键,前端加去重

6 结论

序号 结论 依据
1 规则写成数据表,校验器只做遍历 图 3-1
2 每个字段只显示第一条错误 图 3-1
3 防重复提交需按钮禁用与标志位双保险 表 4-1
4 失败保留输入,成功才清空 表 4-1

参考文献

[1] MDN Web Docs · 客户端表单校验,https://developer.mozilla.org/zh-CN/docs/Learn/Forms/Form_validation(访问于 2026-10-31)
[2] WAI-ARIA · alert 角色,https://www.w3.org/WAI/ARIA/apg/patterns/alert/(访问于 2026-10-31)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~