HTML 表单验证完全指南:从原生到自定义校验

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-10-07 14:21 ·3 浏览 ·3 回复

学完这篇,你能从零搭起一套「浏览器原生兜底 + 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 管。

第二步:接管提示文案

浏览器自带的提示跟着系统语言走,样式不可控。三种接管方式,按力度递增:

  1. 什么都不做——原生气泡,够用就行;
  2. 在 <form> 上加 novalidate,原生校验照常触发(invalid 事件仍然会冒泡),但气泡不再弹出,你自己渲染;
  3. 完全手写,靠下面第三步的 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 能省掉大量状态切换代码
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-737.html
转载请注明出处,版权归原作者所有。

全部回复 3

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-07 14:31

这套「原生打底 → novalidate 接管 → Constraint API 补刀」的链路是对的,我把你断在 validity 那儿的后半段和最常踩的几个坑补上。

el.validity 是 9 个只读布尔:valueMissing、typeMismatch、patternMismatch、tooLong、tooShort、rangeUnderflow、rangeOverflow、stepMismatch、badInput,外加 valid 和 customError。别一个个 if,直接做一张状态→文案映射表,按优先级取第一个为 true 的,以后加规则只改表:

const first = Object.keys(MSG).find(k => el.validity[k]);
el.setCustomValidity(first ? MSG[first] : '');

三个高频坑:

  1. setCustomValidity() 是有状态的——设过之后控件一直 invalid,用户改对了也提交不了,必须在 input 事件里 setCustomValidity('') 清空。这是自研提示最常见的 bug。
  2. form.submit() 会绕过校验和 submit 事件,想触发校验用 form.requestSubmit();另外表单如果没有 type="submit" 按钮,回车也不会触发校验。
  3. type="number" 输入 abc 时 el.value 是空字符串(不是 NaN),只能靠 badInput 识别;而 maxlength 是硬截断,用户根本敲不进第 N+1 个字符,minlength 却要提交时才报错——想实时提示得自己监听 input。

时机策略建议「首次 blur 提示、之后 input 复查」,别一上来就红一片。

最后一句:前端校验只负责体验,后端必须拿 $_POST 再走一遍同样规则——DevTools 里删掉 pattern、required 是零成本的。

晁铭
晁铭 正式会员正式会员认证极客认证极客 熊猫保镖 Lv1 #508 2楼 2026-10-07 14:41
aixiu:这套「原生打底 → novalidate 接管 → Constraint API 补刀」的链路是对的,我把你断在 `validity` 那儿的后半段和最常踩的几…

补三个点:多规则别各自调 setCustomValidity、中文输入要躲开 IME 组合期、前后端规则最好只存在一份。

多规则会互相覆盖。 一个字段挂两条以上自定义规则时,常见写法是每条规则各调一次 setCustomValidity(消息),后执行的会把前面的盖掉,而且「什么时候清空」也容易漏。建议改成「跑完所有规则 → 收集第一条失败消息 → 最后统一 set 一次」,结构上一劳永逸。

IME 是实时校验的头号误报源。 监听 input 做校验时,中文拼音输入过程中的 value 是 nihao 这类拼音串,pattern 必然判错,用户还没选词就红一片。用 compositionstart / compositionend 标记组合期:组合期间跳过校验,compositionend 再补跑一次。日文、韩文输入同理。

规则漂移比写错更常见。 你说的「后端拿 $_POST 再走一遍」我完全同意,但实际项目里最容易翻车的是前端改了 pattern、后端忘改,两边规则慢慢对不上。更稳的做法是把规则抽成一份 JSON(字段 → 必填/类型/长度/正则),后端渲染时输出到 <script type="application/json"> 或 data- 属性,前后端读同一份,改规则只改一处。

顺带两个小坑:disabled 的控件完全不参与约束校验、也不随表单提交,拿它做条件隐藏的字段等于自动关掉校验;另外 :user-invalid 伪类现在主流浏览器都支持了,纯 CSS 就能实现「用户交互过之后才标红」,你那套 blur/input 时机状态机可以直接省掉,值得试试。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #509 3楼 2026-10-07 14:50
晁铭:补三个点:多规则别各自调 `setCustomValidity`、中文输入要躲开 IME 组合期、前后端规则最好只存在一份。 **多规则会互相覆盖。** 一个…

三条我都认,尤其"规则只存在一份"这条,是这套链路能不能长期维护的分水岭,但真落地时有几个细节会让它当场翻车。

统一 set 一次的补充:清空点也要收口。 既然改成"跑完所有规则→取第一条→最后 set 一次",那 setCustomValidity('') 就别再散落在各处监听里,直接放在这个校验函数的入口,每次进来先清、再按结果设。另外映射表要定死优先级:valueMissing 必须排在 patternMismatch、tooShort 前面,否则用户没填时会被报成"格式不对",这类文案错位比漏校验更招骂。

IME 不用自己维护标志。 compositionstart/end 的坑在于它和 input 的触发顺序在各浏览器并不一致,靠顺序判断迟早出问题。直接用 InputEvent.isComposing(input 事件回调里判断 e.isComposing)即可,标准属性、全程可靠;compositionend 后再手动补跑一次校验收尾。

单一规则源最大的雷是正则方言。 JS 和 PHP 的正则不是一套东西——命名组 (?<name>) vs (?P<name>)、\p{L} 要 u 标志、lookbehind 的兼容性都不同。所以那份 JSON 里尽量只放长度/类型/枚举/简单字符集这类能跨语言表达的东西,复杂正则宁可两边各写一份,配一个边界用例测试集兜着,比强行共用安全。

顺带把 disabled 说透:disabled 和 readonly 都是 barred from constraint validation,都不参与校验;区别是 readonly 的值会随表单提交、disabled 不会。所以条件隐藏字段别用 disabled,要么用 type="hidden"(同样不参与校验,但值能提交),要么留在表单里由校验函数自己判断该不该验。

:user-invalid 值得上,但记得用 @supports selector(:user-invalid) 包一层,不支持时退回你原来的状态机,别裸写——老浏览器上整条规则会静默失效,红框全没了你还以为是 CSS 写错了。