学完这篇,你能从零搭起一套「浏览器原生兜底 + JS 自定义规则 + 后端最终把关」的表单验证链路,用户填错当场就知道,脏数据进不了库。
第一步:先用原生属性挡住大部分错误
不用写一行 JS,光靠 HTML 属性就能覆盖半数以上的场景:
<form id="reg">
<input type="text" name="username" required minlength="3" maxlength="16"
pattern="[A-Za-z0-9_]+" autocomplete="username">
<input type="email" name="email" required>
<input type="number" name="age" min="18" max="99" step="1">
<input type="url" name="site">
</form>
常用的几个:
required:非空校验
type="email" / "url" / "number":格式与类型校验
min / max / step:数值范围
minlength / maxlength:长度(注意 maxlength 是硬截断,minlength 只在提交时才报错)
pattern:正则匹配,默认是整串全匹配,等于自带 ^(?:...)$,别再多写 ^ $
注意:pattern 里不要用 \d 之外的松散写法,也别试图在 pattern 里写 i 标志——它不支持标志位,需要忽略大小写就用 [A-Za-z] 这类字符集自己写全。另外 pattern 对空值不生效,空值校验归 required 管。
第二步:接管提示文案
浏览器自带的提示跟着系统语言走,样式不可控。三种接管方式,按力度递增:
- 什么都不做——原生气泡,够用就行;
- 在
<form> 上加 novalidate,原生校验照常触发(invalid 事件仍然会冒泡),但气泡不再弹出,你自己渲染;
- 完全手写,靠下面第三步的 API。
<form id="reg" novalidate>
const form = document.getElementById('reg');
form.addEventListener('invalid', e => {
e.preventDefault(); // 关掉原生气泡(配合 novalidate 更彻底)
showError(e.target, e.target.validationMessage);
}, true); // invalid 不冒泡,必须用捕获阶段
注意:invalid 事件不冒泡,必须用捕获(第三个参数 true)才能在最外层统一监听,这一点很容易踩。
第三步:用 Constraint Validation API 做程序化校验
每个表单控件上都有这套接口:
el.checkValidity():返回布尔值,静默校验
el.reportValidity():校验并弹出原生提示
el.validity:一个对象,含 valueMissing、typeMismatch、patternMismatch、tooShort、tooLong、rangeUnderflow、rangeOverflow、stepMismatch、badInput、customError、valid
el.validationMessage:当前错误文案
el.setCustomValidity(msg):写上非空字符串即视为校验失败,写 '' 清除
拿到 validity 就能自己决定给用户看什么中文提示,而不是受制于系统语言。
第四步:自定义校验规则
跨字段校验是原生属性做不到的,典型场景是「确认密码」:
const pwd = form.password, pwd2 = form.password2;
function checkMatch() {
if (pwd2.value !== pwd.value) {
pwd2.setCustomValidity('两次输入的密码不一致');
} else {
pwd2.setCustomValidity('');
}
}
pwd.addEventListener('input', checkMatch);
pwd2.addEventListener('input', checkMatch);
注意:setCustomValidity 设置后是「粘住」的,用户在输入框里改内容浏览器不会自动帮你清除,必须在 input 事件里手动调 setCustomValidity('')。忘了这一步,表单会永远提交不了,这是最经典的坑。
第五步:把校验接到 UI 上
CSS 伪类可以直接画出状态,省掉一半 JS:
input:invalid:not(:placeholder-shown) { border-color: #d33; }
input:valid:not(:placeholder-shown) { border-color: #3a3; }
:not(:placeholder-shown) 是为了让用户还没开始输入时别一片红。新规范里还有 :user-invalid,只在用户交互过之后才命中,比 :invalid 体验更好,只是老浏览器还不支持。
时机上,别在每次 keydown 就报错——用户打到一半是必然不合法的。常规做法是 blur 时校验一次并显示错误,之后切换成 input 实时校验。
无障碍别漏掉:错误文案的容器加 id,输入框加 aria-describedby="该id",出错时给输入框打 aria-invalid="true",文案容器加 role="alert"。
第六步:提交前的最终把关与后端兜底
form.addEventListener('submit', e => {
e.preventDefault();
if (!form.checkValidity()) {
form.reportValidity(); // 或走你自己的错误渲染
form.querySelector(':invalid')?.focus();
return;
}
// fetch 提交…
});
前端校验只是体验优化,服务端必须原样再校验一遍——请求可以绕过页面直接发。并且要能处理「前端过了、后端拒了」的情况,比如会话过期导致的 CSRF 校验失败,页面返回「页面已过期,请刷新后重试」这类提示时,正确做法是重新拉取 token 再让用户提交一次,而不是把用户填的内容丢掉重来。
注意:fetch 提交时记得带上表单的 CSRF token,并给重复点击做防抖(提交中禁用按钮),否则用户连点会重复提交。
小结
- 先用
required / type / pattern / min|max 等原生属性兜底,成本最低
pattern 是整串全匹配,invalid 事件必须用捕获阶段监听
setCustomValidity 设了要手动清,否则表单永久卡死
- 错误提示时机选
blur 起步,别在用户打字途中就报警
- 前端校验只为体验,服务端必须重校验,并处理好 token 过期等提交失败场景
- CSS
:invalid / :user-invalid 能省掉大量状态切换代码