表单校验实战:HTML5 约束验证 API 与自定义校验对比
学完这篇,你能在一小时内搞清楚「什么时候直接用 HTML5 约束验证、什么时候必须自己写校验」,并拿到一份可复制的混合方案代码。
表单校验的坑不在"能不能校验",而在"提示文案能不能改、异步规则能不能塞、跨字段依赖怎么处理"。下面按从简到繁的顺序走一遍。
第一步:先用 HTML5 原生属性把校验声明出来
不用写一行 JS,浏览器自带校验就能覆盖大半场景:
<form id="signup" action="/api/signup" method="post">
<label>用户名
<input name="username" required minlength="3" maxlength="16"
pattern="[a-zA-Z0-9_]+" autocomplete="username">
</label>
<label>邮箱
<input name="email" type="email" required>
</label>
<label>年龄
<input name="age" type="number" min="18" max="120">
</label>
<button>注册</button>
</form>
这些属性会直接映射到 `ValidityState`:`required` → `valueMissing`,`type="email"` → `typeMismatch`,`pattern` → `patternMismatch`,`min/max` → `rangeUnderflow/rangeOverflow`。
注意:`pattern` 是整个值全匹配,浏览器自动补 `^...$`。所以写 `pattern="\d{6}"` 而不是 `pattern="^\d{6}$"`,否则部分浏览器行为不一致。另外 `pattern` 对中文用户不友好——你写 `[a-zA-Z0-9_]+`,用户粘一个全角符号进来只能看到"请匹配要求的格式",看不懂。
第二步:认识约束验证 API 的常用成员
| 成员 | 作用 |
|---|---|
| `el.validity` | 只读对象,`valid/valueMissing/typeMismatch/customError` 等布尔位 |
| `el.validationMessage` | 当前浏览器生成(或你设置的)提示文案 |
| `el.checkValidity()` | 返回布尔,并触发 `invalid` 事件 |
| `el.reportValidity()` | 在 `checkValidity` 基础上,真正把气泡弹给用户看 |
| `el.setCustomValidity(msg)` | 写入自定义错误,`msg` 为空串表示清除 |
| `form.checkValidity()` | 一次性校验所有可校验控件 |
| `form.noValidate` | 置 true 后浏览器不再自动拦截提交 |
const form = document.getElementById('signup');
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.reportValidity(); // 弹出第一个不合格字段的气泡
}
});
第三步:用 setCustomValidity 补规则
这是 HTML5 路线里最实用的一招——保留浏览器气泡 UI,只替换规则:
const name = form.elements.username;
name.addEventListener('input', () => name.setCustomValidity('')); // 必须清
name.addEventListener('blur', () => {
if (name.validity.valueMissing) return; // 空值交给内置文案
if (/^\d/.test(name.value)) {
name.setCustomValidity('用户名不能以数字开头');
}
});
注意:`setCustomValidity` 只要设了非空串,`validity.customError` 就一直是 `true`,这个字段永远无法通过校验。所以每次输入都要无条件清空一次。这是新手最常见的"表单死活提交不了"原因。
第四步:完全接管——novalidate + 自写规则
一旦出现异步校验(用户名查重)或跨字段校验(确认密码、结束时间大于开始时间),HTML5 就无能为力了,只能自己来:
<form id="signup" novalidate>
const rules = {
username: [
v => v.trim() !== '' || '用户名不能为空',
v => v.length >= 3 || '至少 3 个字符',
v => /^[a-zA-Z0-9_]+$/.test(v) || '只能包含字母、数字、下划线',
async v => (await isNameTaken(v)) ? '该用户名已被占用' : '',
],
password2: [
(v, data) => v === data.password || '两次输入的密码不一致',
],
};
async function validateField(input) {
const list = rules[input.name] || [];
for (const rule of list) {
const msg = await rule(input.value, Object.fromEntries(new FormData(form)));
if (msg) { showError(input, msg); return false; }
}
clearError(input);
return true;
}
然后监听 `blur`(失焦即校验)和 `submit`(全部走一遍,用 `Promise.all` 并发跑异步规则)。
注意:自写校验必须补无障碍部分——给错误提示容器加 `aria-live="polite"`,给 input 加 `aria-invalid="true"` 和 `aria-describedby`。浏览器原生气泡是自带读屏支持的,你自己画的 UI 不会自动获得。
第五步:两者怎么选
| 维度 | HTML5 约束验证 | 自定义校验 |
|---|---|---|
| 代码量 | 零 JS | 规则 + 渲染 + 状态管理 |
| 提示样式 | 浏览器气泡,改不了 | 完全可控 |
| 异步规则 | 不支持 | 支持 |
| 跨字段依赖 | 不支持 | 支持 |
| 一致性 | 各浏览器文案/时机不同 | 完全统一 |
结论是别二选一,做两层:用 `type/required/min/max/pattern` 做声明式第一层,同时给表单加 `novalidate` 关掉默认气泡,然后自己接管 `invalid` 事件:
form.addEventListener('invalid', (e) => {
e.preventDefault(); // 阻止浏览器气泡
showError(e.target, e.target.validationMessage);
}, true); // 必须用捕获阶段
注意:`invalid` 事件不冒泡,在 form 上用第三个参数 `true` 走捕获阶段才能收到。少了这个 `true`,你会以为它压根没触发。
再配 CSS 伪类收尾:`input:user-invalid { border-color: #e5484d; }`。用 `:user-invalid` 而不是 `:invalid`——`:invalid` 在页面刚加载时就会命中所有未填的必填项,红框一片,体验很差。
小结
- HTML5 约束验证零代码、无障碍免费,适合必填、格式、范围这类同步单字段规则。
- `pattern` 是全匹配、`setCustomValidity` 必须手动清、`invalid` 事件必须捕获阶段监听,这三个是翻车高发点。
- 异步校验(查重)和跨字段校验(确认密码)只能自定义,没有捷径。
- 生产环境的最优解是混合:属性声明规则 + `novalidate` 关气泡 + 捕获 `invalid` 事件接管提示 + 自写异步规则。
- 样式用 `:user-invalid`,别用 `:invalid`;自绘错误 UI 记得补 `aria-live` 和 `aria-describedby`。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





