D02 表单校验与异步提交
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 验证与讨论
- 直接点击「注册」,确认三条错误依次显示且焦点停在邮箱。
- 填写合法内容但邮箱以
@blocked.dev结尾,确认输出「该邮箱已被注册」且输入内容保留。 - 快速连点提交按钮五次,确认只发起一次请求(
status只变一次)。 - 在提交过程中关闭页面或执行
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 |
- 上一篇:D01-组件封装与状态管理
- 下一篇:D03-请求层封装
- 返回:D00-总结 | 科目 README
参考文献
[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
还没有评论,快来抢沙发吧~