表单验证根据明确规则检查输入,并把可修复的问题反馈给用户;浏览器验证改善交互,服务器验证才负责接受或拒绝数据。
客户端约束可以被绕过,自定义错误也会一直保持,直到代码用空字符串显式清除。
先使用原生 HTML 约束,再补充跨字段和异步规则;让错误与控件关联,并在服务器上独立复验。
是什么,为什么存在
表单验证(form validation)是把用户输入与一组约束比较,并把结果用于反馈或提交决策的过程。HTML 已经能表达必填、类型、长度、数值范围和格式等常见规则。JavaScript 只需补充原生属性表达不了的跨字段、业务或异步规则。
浏览器内建的 约束验证(constraint validation) 解决两个直接问题:在发送请求前发现明显错误,并给键盘、触摸和辅助技术用户提供一致的控件状态。它不是信任边界。用户可以修改 DOM、直接构造 HTTP 请求,或调用绕过交互验证的接口,所以服务器必须按自己的权威规则重新验证。
验证也不等于清理输入。验证回答“这个值是否符合当前契约”,规范化会把等价输入转换成统一形式,转义或安全的输出 API 则处理值进入 HTML、SQL 或命令等不同上下文时的风险。把这些工作混成一个正则表达式,通常既遗漏边界情况,也掩盖真正的安全控制。
你会在注册、结账、搜索筛选、文件上传和设置页面遇到表单验证。规则应来自数据契约和任务需求,而不是组件库的默认配置。字段是否必填、允许哪些值、错误由谁裁决,都应在写界面前说清楚。
一次验证通过只描述当前输入与当前规则的关系,不保证数据之后仍然可用。凡是依赖库存、账户状态或权限的决定,都要在执行操作时再次确认。
工作原理
浏览器先确定哪些表单控件参与验证,再根据控件类型、当前值和约束属性计算状态。常用声明包括 required、type="email"、minlength、maxlength、min、max、step 与 pattern。被禁用的控件和部分只读或特殊控件不会参加同一套检查,因此动态表单必须同步更新控件状态和规则。
每个可验证控件都有一个 有效性状态(ValidityState) 。其中的 valueMissing、typeMismatch、patternMismatch、rangeUnderflow、rangeOverflow、stepMismatch 与 customError 等布尔值说明失败原因,valid 表示所有适用约束都通过。读取具体标志比解析本地化错误文字稳定。
checkValidity() 执行检查并返回布尔值;遇到无效控件时还会派发 invalid 事件,但不会主动显示浏览器提示。reportValidity() 执行相同检查,并对未取消的失败显示浏览器反馈。正常的提交按钮会触发交互式验证,novalidate 会关闭这一步。
setCustomValidity(message) 把应用规则接入同一个状态模型。非空消息让 customError 成为 true,空字符串才表示该自定义错误已经消失。控件的 验证消息(validation message) 可用于错误文本,但它由浏览器本地化;自动化测试更适合断言有效性标志、错误关联和焦点,而不是固定文案。
错误反馈应同时包含语义、文字和导航。标签说明字段是什么,字段附近的消息说明哪里不对以及怎样修复,aria-describedby 把二者关联,aria-invalid="true" 表示当前失败状态。长表单还可以提供带字段链接的错误摘要;只改变边框颜色既无法说明原因,也不能保证辅助技术获知变化。
反馈时机同样属于验证设计。提交后应展示所有需要修复的问题,并把焦点移到错误摘要或第一个无效控件。输入过程中只适合给出稳定、及时且不会反复打断用户的反馈;异步检查还必须处理取消、乱序响应和网络失败。
三层规则各司其职
第一层是 HTML 能直接表达的字段约束。它们应留在标记中,让没有加载应用脚本的页面仍保留基本行为,也让测试和辅助技术能检查同一份语义。用 JavaScript 复制 required 或 min 不会增强契约,只会增加漂移机会。
第二层是浏览器中的组合规则,例如结束日期不早于开始日期,或某个选择使另一个字段变成必填。这一层应把结果写回控件有效性,并同步更新可见消息。组合规则需要监听所有依赖字段,而不只是最终显示错误的字段。
第三层是服务器权威规则。它覆盖所有字段约束和业务状态,并处理客户端无法可靠判断的并发变化。三层可以共享测试用例或规则数据,但每一层仍要明确自己的输入类型与失败输出。
提交接口改变验证路径
用户激活提交按钮时,浏览器先运行交互式约束验证;失败时不会派发 submit 事件。requestSubmit() 模拟这条路径,还会保留所选提交按钮的名称、值和覆盖属性。自动化代码需要模拟真实提交时,它通常比直接调用处理函数更合适。
form.submit() 是更低层的接口,会绕过约束验证和 submit 事件。旧代码和生成代码常用它“继续提交”,结果跳过了刚写好的检查。需要在通过自定义异步规则后继续时,应防止递归,再使用明确且经过审查的提交路径。
novalidate 与提交按钮上的 formnovalidate 是产品决策,不是调试开关。它们适合“保存草稿”等刻意允许不完整数据的操作,但服务器必须知道当前操作采用哪套契约。最终提交和草稿保存不应只靠按钮文字区分。
重置表单也需要重置验证呈现。原生 reset 会恢复控件默认值,却不会自动删除应用添加的错误节点、aria-invalid 或请求编号。把值状态和呈现状态放进同一个重置流程,才能避免一个看似空白的新表单仍读出旧错误。
示例
原生约束作为第一层
这个注册表单用类型和属性声明基本契约。checkValidity() 在同一份 DOM 上先检查两组错误值,再检查两组有效值。
<form id="signup">
<label>
Email
<input id="email" name="email" type="email" required />
</label>
<label>
Invite code
<input
id="invite"
name="invite"
pattern="[A-Z]{4}-[0-9]{2}"
title="Four uppercase letters, a hyphen, and two digits"
required
/>
</label>
<button>Join</button>
</form>
<script>
const form = document.querySelector('#signup');
const email = document.querySelector('#email');
const invite = document.querySelector('#invite');
email.value = 'not-an-email';
invite.value = 'ab-1';
console.log(`invalid data: ${form.checkValidity()}`);
email.value = '[email protected]';
invite.value = 'WIKI-26';
console.log(`valid data: ${form.checkValidity()}`);
</script>invalid data: false
valid data: true原生属性让浏览器、密码管理器和辅助技术共享同一份基础语义。pattern 只负责邀请代码的语法;代码是否存在、是否过期仍要由服务器判断。title 描述预期格式,但可见的字段说明通常更容易在输入前发现。
跨字段约束
邮政编码格式取决于国家,单个静态 pattern 无法表达这个依赖。下面的函数每次都计算完整结果,并用空字符串清除旧错误。
<form id="address">
<label>
Country
<select id="country" name="country">
<option value="US">United States</option>
<option value="FR">France</option>
</select>
</label>
<label>
Postal code
<input id="postal" name="postal" required />
</label>
</form>
<script>
const country = document.querySelector('#country');
const postal = document.querySelector('#postal');
const formats = {
US: { pattern: /^\d{5}$/, message: 'Use a 5-digit US ZIP code' },
FR: { pattern: /^\d{5}$/, message: 'Use a 5-digit French postal code' }
};
function validatePostalCode() {
const rule = formats[country.value];
const message = rule.pattern.test(postal.value) ? '' : rule.message;
postal.setCustomValidity(message);
}
postal.value = '75A01';
validatePostalCode();
console.log(`first: ${postal.validationMessage}`);
postal.value = '75001';
validatePostalCode();
console.log(`second: valid=${postal.validity.valid}`);
</script>first: Use a 5-digit US ZIP code
second: valid=true真实界面应在国家或邮政编码变化时调用 validatePostalCode(),提交前再调用一次。示例只演示约束连接方式,不是全球邮政编码库。生产规则应来自明确维护的数据契约,无法验证的地区不应被随意拒绝。
可访问的错误反馈
这个表单关闭浏览器的交互式气泡,由页面自己渲染错误。原生有效性状态仍然可用,错误消息通过已有的 aria-describedby 关系连接到字段。
<form id="profile" novalidate>
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" required
aria-describedby="display-name-error" />
<p id="display-name-error" hidden></p>
<label for="contact-email">Email</label>
<input id="contact-email" name="email" type="email" required
aria-describedby="contact-email-error" />
<p id="contact-email-error" hidden></p>
<button>Save</button>
</form>
<script>
const form = document.querySelector('#profile');
const fields = [...form.elements].filter(
(element) => element instanceof HTMLInputElement
);
form.addEventListener('submit', (event) => {
event.preventDefault();
for (const field of fields) {
const invalid = !field.validity.valid;
const error = document.querySelector(`#${field.id}-error`);
field.setAttribute('aria-invalid', String(invalid));
error.hidden = !invalid;
error.textContent = invalid ? field.validationMessage : '';
}
const invalidFields = fields.filter((field) => !field.validity.valid);
invalidFields[0]?.focus();
console.log(`errors: ${invalidFields.length}`);
console.log(`focus: ${document.activeElement.id}`);
});
document.querySelector('#contact-email').value = 'wrong';
form.requestSubmit();
</script>errors: 2
focus: display-namenovalidate 只适合在页面提供了完整替代反馈时使用。这里先更新所有字段状态,再移动一次焦点,避免第一个错误出现后就中断循环。实际提交成功后还要清理旧消息,并用明确状态确认任务完成。
防止异步结果倒灌
用户名可用性检查可能乱序返回。递增的请求编号让较旧的结果失效,因此较慢的 alice 响应不会覆盖较新的 alicia 状态。
<label for="username">Username</label>
<input id="username" name="username" />
<script>
const input = document.querySelector('#username');
const availability = new Map([
['alice', false],
['alicia', true]
]);
let latestRequest = 0;
async function validateUsername(name) {
const request = ++latestRequest;
const delay = name === 'alice' ? 30 : 10;
await new Promise((resolve) => setTimeout(resolve, delay));
if (request !== latestRequest) {
console.log(`ignored: ${name}`);
return;
}
const message = availability.get(name) ? '' : 'Username is already taken';
input.setCustomValidity(message);
console.log(`${name}: ${input.validity.valid ? 'valid' : message}`);
}
Promise.all([
validateUsername('alice'),
validateUsername('alicia')
]).then(() => console.log(`final: valid=${input.validity.valid}`));
</script>alicia: valid
ignored: alice
final: valid=true请求编号解决的是应用状态竞态,不会取消网络工作。生产代码可以再用 AbortController 取消不需要的请求,并把超时或离线状态与“用户名已占用”区分开。即使客户端显示可用,服务器创建账户时仍须原子地执行唯一性约束。
陷阱
修复方法: 服务器独立解析请求,并按相同业务契约验证类型、范围、允许值、权限和当前状态。客户端规则负责提前反馈,不负责授权;数据库唯一约束等并发不变量也必须留在权威层。
修复方法: 每次相关字段变化时重新计算完整规则,通过时调用 setCustomValidity('')。同时更新可见消息和 aria-invalid,不要维护三套互相漂移的布尔状态。
修复方法: 优先保留原生交互。确实需要自定义呈现时,必须覆盖字段消息、错误摘要、焦点移动、键盘操作和辅助技术通知,并在目标浏览器中测试整个提交路径。
修复方法: 必填与格式错误通常在失焦或首次提交后显示,用户修正时再及时更新。远程检查应防抖或按需求触发,并通过请求标识或取消机制忽略过期响应。
修复方法: 需要模拟提交按钮时使用 requestSubmit(),并用真实键盘输入覆盖长度规则的端到端测试。单元测试仍应直接测试共享的业务验证函数和服务器验证器。
服务器权威与信任边界
服务器收到的是请求数据,不是浏览器里的表单对象。解析器可能遇到缺失字段、重复键、意外数组、超长字符串、错误编码或无法转换的数字。服务器验证应从不可信的原始结构开始,明确决定每个字段允许的形状,再进行规范化和业务检查。
客户端与服务器可以共享规则定义,但不能让客户端的“已通过”标记成为证据。共享模式也要考虑运行时差异,例如空字符串是否变成 null、时区如何解释日期、数字格式是否允许本地化分隔符。对账户名唯一性、库存和优惠资格这类随时间变化的规则,服务器必须在写入附近重新判断。
错误响应应区分字段级错误和表单级错误,并使用稳定字段键而不是显示标签。客户端把字段键映射到控件,把无法归属单个字段的问题放进摘要。返回给用户的文字不能泄露内部查询、堆栈或敏感账户状态;日志则应保留足够的请求关联信息供诊断。
验证失败不应被当成异常崩溃。服务器应返回明确的失败状态,保留用户可以安全重用的输入,并让客户端重新建立错误关联。授权、CSRF 防护、速率限制、输出编码和参数化查询仍是独立控制,不能因为“表单已经验证”而省略。
动态表单的状态一致性
条件字段会改变哪些数据当前有意义。例如选择“公司账户”后才出现税号字段时,界面要同时更新可见性、required、禁用状态和错误消息。只隐藏一个仍为必填的控件,可能让用户无法修复错误;反过来,只隐藏而不禁用,也可能继续提交过期值。
动态列表还需要稳定的字段标识。删除中间一项后,如果错误只按数组索引保存,原本属于第三项的消息可能跳到第二项。使用稳定项目键映射客户端错误,提交时再由服务器根据当前载荷返回可定位的路径。
多步骤表单不应把“通过当前页面”误认为“整个事务有效”。返回上一步修改字段后,后续步骤可能重新失效;最后提交前必须验证完整数据。草稿保存可以采用较宽松的契约,但应明确标记草稿状态,不能复用最终提交的成功提示。
测试矩阵至少覆盖条件分支的开启与关闭、添加与删除、前后导航、刷新恢复和服务器拒绝。对异步字段,再加入快速输入、乱序响应、取消与离线场景。目标不是枚举每个 DOM 事件,而是证明一个规则在所有状态转换后仍只有一个明确结果。
测试完整契约
规则表
先把每条规则写成表格:字段、输入形状、空值策略、边界、依赖字段、客户端反馈时机和服务器错误键。这个表格把产品语言转换成可执行条件,也能暴露客户端与服务器对空字符串、缺失值或数组的不同解释。
从每条规则至少派生一个通过用例、一个失败用例和边界用例。依赖当前时间或远程状态的规则还要固定时钟或服务响应,避免测试结果随环境漂移。随机数据可以扩展覆盖范围,但不能替代一组可读的契约示例。
浏览器与服务器可以消费同一组语言无关的输入样例,断言各自的结构化结果。不要共享已经验证过的布尔结果;共享的是输入与期望。这样才能发现某一层遗漏规则,而不是让两层共同相信同一个错误实现。
浏览器交互矩阵
浏览器测试应通过标签找到控件并模拟真实输入,而不是直接修改组件内部状态。覆盖鼠标、键盘提交和触摸等效路径,并确认浏览器阻止无效提交时页面仍给出可理解的反馈。至少在项目支持的浏览器引擎中运行核心路径。
无障碍断言应检查名称、描述、状态和焦点,不只检查错误文字是否存在于 DOM。错误出现后,控件必须仍有可见标签,消息必须能通过稳定 ID 关联,修正后旧关联不能继续描述失败。错误摘要中的链接还要把焦点带到正确控件。
本地化会改变 validationMessage,浏览器版本也可能调整具体措辞。端到端测试应断言 validity 标志、消息非空和导航结果;只有产品自己拥有的文案才适合精确匹配。这样测试既验证行为,也不会把浏览器翻译当成应用 API。
服务器拒绝矩阵
服务器测试必须绕开界面直接发送请求。删除必填键、重复同名键、改变值类型、超过长度限制,并组合客户端界面不会生成的状态。每种拒绝都应产生稳定状态码和可解析错误结构,而不是未处理异常。
并发规则需要并发测试。两个请求同时认领同一用户名或最后一件库存时,最多一个操作可以成功,失败方收到可处理的业务错误。先检查再写入而没有数据库约束或事务保护,会在顺序测试中通过,却在真实负载下失效。
还要验证成功路径不会回显秘密字段,失败日志不会记录密码或完整支付数据。错误消息应足以指导修复,但不能确认攻击者不应知道的账户存在性或内部状态。安全审查关注的是整个数据流,不只是一个验证函数。
回归证据
一次可信的验证改动应保留规则表、浏览器交互结果、服务器测试结果和相关无障碍检查。代码审查者据此确认契约在哪些层发生变化,并判断新增字段是否同时更新了错误映射、翻译和监控。
4个问题 · 1 道输出预测题 · 1 道找错题