本节摘要:表单是 Web 最老的交互组件,原生提交却意味着整页跳转——正好撞上 Ajax 要解决的问题。本节完成一次完整的异步表单改造:拦截默认提交、客户端校验、数据收集与序列化、防重复提交、成功与失败的界面反馈,覆盖普通字段与文件字段两种形态,并整理改造中每个决策的取舍。
一个注册表单,字段:用户名、邮箱、密码、头像(可选文件)。原生写法是 <form action="/register" method="post">,用户点提交按钮,浏览器导航到 action 地址。这个"最标准"的方案有三个断点:
断点一:错误反馈是断崖式的。密码强度不够,服务器渲染一张新的错误页(或带错误信息重定向回表单页),用户填过的其他字段若没被服务端回填就全没了——填了十分钟资料,因一个字段打回重来,怒气值直接拉满。
断点二:提交期间无法反馈。原生提交没有"提交中"状态,用户等了两秒没动静,再点一次——两个请求进队列,重复注册、重复下单就是这么来的。
断点三:页面状态全部丢失。提交即导航,滚动位置、上游页面带过来的上下文、未保存的其他编辑,一并清零。
Ajax 改造的目标就是把这三个断点逐一焊上:错误原地显示在字段旁、按钮有明确的提交中状态、页面不动。

改造从"不让浏览器自己提交"开始。给 form 挂 submit 事件(注意是 form 的 submit,不是按钮的 click——回车键也会触发提交,只拦按钮会漏掉键盘用户):
<form id="register-form" action="/register" method="post"> <input name="username" required> <input name="email" type="email" required> <input name="password" type="password" required> <button type="submit">注册</button> </form>
const form = document.getElementById('register-form'); form.addEventListener('submit', function (e) { e.preventDefault(); // 核心:阻止原生导航提交 submitRegister(); });
保留 action 与 method 属性是个好习惯:JavaScript 加载失败或异常时,表单退化为原生提交仍然可用——这叫渐进增强,改造旧的、对可用性有要求的系统时尤其值得。
提交前先在本地过一遍,把明显问题拦在网络上之前:
function validate(form) { const errors = {}; const data = new FormData(form); if (data.get('username').trim().length < 3) { errors.username = '用户名至少 3 个字符'; } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.get('email'))) { errors.email = '邮箱格式不正确'; } if (data.get('password').length < 8) { errors.password = '密码至少 8 位'; } return errors; }
两个纪律。其一,校验规则与服务端保持同步——客户端说"通过"、服务端说"不合格",用户会觉得自己被戏耍。字段约束变了要两头改,共享校验规则(如 JSON Schema)是根治之道。其二,客户端校验只是体验优化,绕过它只需要禁用脚本或直接调接口,一切安全校验必须服务端重做。前端校验省的是一次无谓的网络往返,不是漏洞防线。
浏览器原生校验(required、type=email、pattern)也值得用:form.checkValidity() 触发原生校验并返回结果,省下大量手写规则。原生提示样式不满足设计稿时再自建,别一上来就全手写。
路线 A:FormData 直取。new FormData(form) 一行拿到全部字段(含文件),最适合有文件字段的场景;发送时直接作为 body,浏览器自动设置表单格式头(2.1 节强调过别手动设):
async function submitRegister() { const data = new FormData(form); // 含头像文件字段 const res = await fetch('/api/register', { method: 'POST', body: data }); // ... }
路线 B:转成 JSON。多数现代接口更喜欢 JSON。FormData 转 JSON 需要一小段桥接(文件字段除外——JSON 装不下文件):
function formToJson(form) { const data = new FormData(form); const obj = {}; for (const [key, value] of data.entries()) { if (typeof value === 'string') obj[key] = value; // 跳过文件 } return obj; } const res = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formToJson(form)) });
选型口诀与 2.3 节一致:有文件走 FormData,纯字段走 JSON。混搭场景(既有文件又有复杂结构)用 FormData 加 JSON 字符串字段的方案。
POST 不幂等(2.2 节),重复提交就是重复下单。前端的第一道防线是按钮状态机:
async function submitRegister() { if (form.submitting) return; // 请求中直接忽略 form.submitting = true; const btn = form.querySelector('button'); btn.disabled = true; btn.textContent = '提交中…'; try { const result = await sendRegisterData(); onSuccess(result); } catch (err) { showError(err); } finally { form.submitting = false; // 无论成败恢复可提交 btn.disabled = false; btn.textContent = '注册'; } }
要点有二。锁要放在请求发出前,且在 finally 里解锁——异常路径不解锁,按钮就永久卡死,比重复提交还糟。前端防线挡不住双击之外的重复(刷新重发、脚本直调),关键业务(支付、下单)要加服务端防线:请求携带唯一令牌、接口做幂等键。前端防的是手抖,服务端防的是恶意,两层都要。
失败反馈的黄金法则:错误出现在字段旁边,数据保留在表单里。对比原生提交的断崖式错误页,这就是异步化最大的体验收益:
function showError(err) { // err.fieldErrors 形如 { email: '邮箱已被注册' } 由服务端返回 clearErrors(); if (err.fieldErrors) { for (const [name, msg] of Object.entries(err.fieldErrors)) { const input = form.elements[name]; const tip = document.createElement('span'); tip.className = 'field-error'; tip.textContent = msg; input.closest('label').appendChild(tip); } } else { showBanner(err.message || '注册失败,请稍后再试'); // 非字段错误用横幅 } }
服务端返回字段级错误的约定(2.4 节接口设计的用武之地)值得坚持:错误结构与字段名对齐,前端才能精准落点。没有这层约定,前端只能把一切错误弹一个"操作失败",用户与服务端都在猜。
成功反馈分两种场景:完成后留在本页的(保存设置),给一个不打断的轻提示(右上角浮出几秒消失)并重置表单;完成后去别处的(注册成功进首页),给一句"注册成功,正在跳转"再做延时跳转——别省这句提示,直接消失比有过渡更让人不安。
把上面的片段组装成一个可直接落地的完整模块(JSON 路线):
form.addEventListener('submit', async function (e) { e.preventDefault(); const errors = validate(form); if (Object.keys(errors).length) { showFieldErrors(errors); return; } lockSubmit(true); try { const res = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formToJson(form)) }); const body = await res.json(); if (!res.ok || body.code !== 0) { throw Object.assign(new Error(body.message), { fieldErrors: body.fieldErrors }); } toast('注册成功'); form.reset(); } catch (err) { showError(err); } finally { lockSubmit(false); } });
四十几行,覆盖拦截、校验、序列化、防重、双路反馈——这就是一个生产可用的异步表单的全部骨架。往上加文件上传进度(4.3 节)、密码强度实时提示(监听 input 事件)、字段失焦即校验,都是在这个骨架上加肉。
会,只要表单里有 submit 按钮且焦点在文本输入框里,回车就触发 form 的 submit 事件——这正是拦 submit 而不是拦按钮 click 的原因。
JSON 路线拿不到进度,需要换 XHR 走 FormData 并监听 upload 的 progress 事件,完整实现在 4.3 节。
依赖接口约定:错误响应里带 fieldErrors 之类的字段错误映射(字段名到文案)。前端在 request 封装层解析并抛出结构化错误,表单层只管按字段名落点显示。
用一个更完整的演练把五步流程串起来。场景:期刊订阅表单,字段有邮箱、订阅周期(单选)、兴趣标签(复选)、一句备注。原生提交跳转到订阅确认页,改造目标是不跳转完成订阅并给反馈。
第一步接管提交,同时做渐进增强:form 保留 action 与 method,脚本加载失败时仍可用原生方式提交。第二步校验分两层:浏览器原生层用 required 与 type=email(form 的 submit 事件在原生校验不通过时根本不会触发,等于免费的第一道闸),自建层校验邮箱域名是否在黑名单、至少勾选一个标签。错误显示在字段下方,用aria-live属性让屏幕阅读器也能感知错误出现。第三步序列化走 JSON:复选框的多值要收集成数组(FormData 的 getAll 方法),单选取选中项的值,组成与接口约定一致的结构。第四步防重:提交按钮进入"订阅中"状态,同时禁止回车重复触发(在途期间 submit 事件直接忽略,与按钮锁共用一个状态位)。第五步反馈双路:成功后表单整体替换为一段"订阅成功,确认邮件已发出"的确认区(保留邮箱回显),失败按字段错误落点或横幅提示。
演练做完,对照检查三处容易松的地方。其一,回显:失败后用户已填的数据必须还在——异步化的一大意义就在于此,任何"失败后表单清空"的实现都是自我否定。其二,可访问性:错误提示要与对应输入框在结构上关联(label 包裹或属性关联),焦点在报错后落到第一个出错字段,键盘用户不用满页找错。其三,提交成功后的表单状态:重置还是保留?订阅类一次性表单重置合理,编辑类表单保留原值更合理——按业务语义定,别用惯性。
这个演练覆盖的细节密度,接近真实项目中表单工作的六成。剩下四成是各种定制(联动字段、草稿保存、条件显示),它们全是五步骨架上的变奏——骨架熟了,变奏只是查谱。
变奏一,联动字段:省市区选择、类型切换加载属性。要点是把"联动加载"也当作一次带竞态防护的请求处理——用户快速切换省份时,上一次城市列表的请求必须作废(3.4 节的取消方案直接套用),否则下拉框会出现"选了浙江显示着江苏的城市"这类灵异现象。变奏二,草稿自动保存:编辑类表单在内容变化后静默保存草稿。防抖间隔取三十秒到一分钟(比搜索联想长得多,写两行就存一次是打扰),保存失败不弹窗只在角落给轻提示,恢复时机是表单加载时先取草稿再取正式数据、以较新者为准。变奏三,条件显示字段:某些字段只在特定选项下出现。除了显隐控制,别忘了校验规则要跟着显隐切换——隐藏字段的必填校验要同步失效,否则用户会被一个看不见的字段卡住提交,这是投诉榜上的常客。
这些变奏的实现载体都没变:监听、请求、渲染、状态恢复,还是前几章的那些积木。表单工作做到后面,拼的不再是会写多少种交互,而是每个交互的异常路径都想到了没有——想全了,用户觉得"这表单真好用";漏一条,用户记住的就是那一次恼火。而"想全"是可以训练的:每实现一个变奏,就强迫自己列出它的三种失败可能(网络、重复、过期)并各写一行处理,三十次之后,这会变成不假思索的本能。到那时回头看本节开头的三个体验断点,你会发现自己已经能在一个需求评审会上,听完描述就顺手列出完整的风险清单——这份清单感,就是异步表单工作里最值钱的个人资产。它还会向相邻技能溢出:能把表单的异常路径想全的人,去做上传、支付、审批流这些更怕出错的交互时,天然就会多问一句"失败了怎么办"——岗位经验的差距,很多时候就是这一句话问没问的差距。