前端表单验证这块几乎每个项目都会碰见。早些年大家用jQuery Validation,后来转向各种React/Vue的表单组件,再后来是Yup、Zod这类模式验证库。这些工具确实强大,但如果你要做的只是一个普通的后台注册页或者简单的联系表单,引入一整套验证方案反而让项目变重。其实现代浏览器已经内置了一套完善的约束验证机制,搭配一点原生JavaScript和正确的HTML属性,完全可以做出体验不错且完全无障碍的表单校验。这篇文章就是我自己把公司注册页的验证逻辑从第三方库切回原生能力的一次复盘。
浏览器自带的能力被严重低估了
大多数开发者习惯性忽略HTML5引入的表单验证属性,主要是觉得默认的提示气泡太丑,又不能自定义样式,而且各个浏览器的渲染不一样。这些不满确实存在,但约束验证API(Constraint Validation API)恰好解决了这些问题:它允许你手动触发验证、获取验证结果、设置自定义错误信息,而且完全控制错误展示的DOM结构。
核心思路是:先把原生验证属性写好(required、type、pattern等),然后用JavaScript阻止默认气泡,通过willValidate、checkValidity()、validity对象拿到每个字段的状态,再按照自己的UI规范把错误显示出来。这样既不丢掉浏览器提供的内置验证逻辑,又完全掌控了表现层。
搭一个完整的注册表单,只用原生属性
先看HTML结构。一个典型的注册表单,包含用户名、邮箱、密码、确认密码和提交按钮。每个字段都配上合适的type和验证属性:
<form id="register-form" novalidate>
<div class="field">
<label for="username">用户名</label>
<input type="text" id="username" name="username"
required minlength="3" maxlength="20"
pattern="[a-zA-Z0-9_]+"
aria-describedby="username-hint username-error">
<span id="username-hint" class="hint">字母、数字或下划线,3-20位</span>
<span id="username-error" class="error" role="alert"></span>
</div>
<div class="field">
<label for="email">邮箱</label>
<input type="email" id="email" name="email"
required
aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>
</div>
<div class="field">
<label for="password">密码</label>
<input type="password" id="password" name="password"
required minlength="8"
pattern="(?=.*d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
aria-describedby="password-hint password-error">
<span id="password-hint" class="hint">至少8位,含大小写字母和数字</span>
<span id="password-error" class="error" role="alert"></span>
</div>
<div class="field">
<label for="confirm-password">确认密码</label>
<input type="password" id="confirm-password" name="confirm-password"
required
aria-describedby="confirm-error">
<span id="confirm-error" class="error" role="alert"></span>
</div>
<button type="submit">注册</button>
</form>
特别注意几点:表单加了novalidate属性,阻止浏览器默认气泡;每个input都关联了label和错误提示span,并用aria-describedby把提示和错误信息绑在一起,辅助设备能自动播报变化;role=”alert”让错误信息出现时被屏幕阅读器即时读出。
用约束验证API接管错误显示
JavaScript部分的核心思路是:监听表单提交事件,遍历所有表单控件,利用它们的validity接口逐个检查状态,然后把错误信息写进对应的span里。如果遇到自定义验证(比如确认密码必须与密码一致),用setCustomValidity()动态设置错误。
const form = document.getElementById('register-form');
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirm-password');
// 确认密码的自定义验证
function validateConfirmPassword() {
if (password.value !== confirmPassword.value) {
confirmPassword.setCustomValidity('两次输入的密码不一致');
} else {
confirmPassword.setCustomValidity('');
}
}
password.addEventListener('input', validateConfirmPassword);
confirmPassword.addEventListener('input', validateConfirmPassword);
form.addEventListener('submit', function(event) {
// 先把所有自定义验证跑一遍
validateConfirmPassword();
if (!form.checkValidity()) {
event.preventDefault(); // 阻止提交
// 遍历所有控件,展示错误
const elements = form.querySelectorAll('input');
elements.forEach(input => {
showError(input);
});
// 聚焦到第一个出错的字段
const firstInvalid = form.querySelector('input:invalid');
if (firstInvalid) {
firstInvalid.focus();
}
} else {
// 全部通过,执行提交逻辑(这里可以换成AJAX)
console.log('表单验证通过,执行注册');
// 如果使用AJAX,也需要调用 event.preventDefault() 然后手动发送数据
}
});
// 清除某个字段的错误提示
function clearError(input) {
const errorSpan = document.getElementById(input.id + '-error');
if (errorSpan) {
errorSpan.textContent = '';
}
input.removeAttribute('aria-invalid');
}
// 显示某个字段的错误信息
function showError(input) {
const errorSpan = document.getElementById(input.id + '-error');
if (!errorSpan) return;
if (input.validity.valid) {
errorSpan.textContent = '';
input.removeAttribute('aria-invalid');
return;
}
let message = '';
if (input.validity.valueMissing) {
message = '此字段不能为空';
} else if (input.validity.typeMismatch && input.type === 'email') {
message = '请输入有效的邮箱地址';
} else if (input.validity.tooShort) {
message = `至少需要${input.minLength}个字符`;
} else if (input.validity.patternMismatch) {
// 根据字段给出更具体的提示
if (input.id === 'username') {
message = '用户名只能包含字母、数字和下划线';
} else if (input.id === 'password') {
message = '密码必须包含大小写字母和数字,至少8位';
} else {
message = '格式不正确';
}
} else if (input.validity.customError) {
message = input.validationMessage;
} else {
message = input.validationMessage;
}
errorSpan.textContent = message;
input.setAttribute('aria-invalid', 'true');
}
// 实时校验:用户离开字段时马上给出反馈
form.querySelectorAll('input').forEach(input => {
input.addEventListener('blur', function() {
// 如果字段已经不是初始状态,进行校验
if (input.value.length > 0 || input.validity.valid === false) {
showError(input);
}
});
// 输入时清除错误状态(让用户感觉到在修正)
input.addEventListener('input', function() {
clearError(input);
});
});
这段逻辑把验证完全掌控在自己手里。validity对象提供了各种布尔值:valueMissing、typeMismatch、tooShort、patternMismatch、customError等,优先级从上到下排列,可以组合出精准的提示语。确认密码的校验则通过setCustomValidity动态注入,完美融入同一套检测流程。
几个容易被忽略的细节
- required和minlength组合时:如果字段为空,validity.valueMissing为true;如果填了但长度不够,validity.tooShort为true。顺序判断时先检测valueMissing能给出正确指引。
- pattern属性:正则表达式不需要加斜杠,直接在HTML里写。但如果用了^和$,要注意浏览器已经隐含着首尾匹配,多加可能导致意外。
- email类型:浏览器不仅检查@符号,还会对域名部分做一定验证。如果业务需要更宽松的规则(比如允许中文邮箱),应该用type=”text”加自定义pattern,不用原生的email验证。
- 无障碍:错误容器使用了role=”alert”,意味着内容变化时会自动播报。但要注意避免在页面加载时就触发播报,所以初始状态下这些span应该为空。
与第三方库对比,到底省了什么
换成原生方案后:
- JS部分大约60行,没用任何依赖。以前用Yup + 自定义表单组件时,光验证逻辑就超过200行,还要维护一套错误消息枚举。
- 打包体积明显下降,省下的10KB看似不多,但对于后台系统这类不需要大量复杂交互的页面而言,每一KB都宝贵。
- 可访问性更容易做。以前用组件库时,总有同事忘记给错误信息加role,或者不关联aria-describedby。现在代码集中在一个地方,规则明确,不容易遗漏。
- 跨框架复用性高。因为全程操作的是真实DOM和原生API,无论是Vue、React还是纯多页应用,这套逻辑可以直接移植,最多换个事件绑定方式。
当你需要更多验证规则时
复杂的业务规则(比如用户名不能是保留字、邮箱不允许临时域名)可以通过setCustomValidity实现。通常我会把这些规则封装成一个个函数,然后在input事件或blur事件里调用。函数返回错误字符串或者空串,调用setCustomValidity之前先跑一遍所有规则,这样所有逻辑都保持在约束验证API的框架内。
如果表单存在异步验证(比如检查用户名是否已注册),同样可以在input或blur时发起请求,根据返回结果动态调用setCustomValidity。不过要注意防抖和请求竞态,避免用户输入过程中频繁触发校验。
总结
把表单验证从第三方库迁移回浏览器原生功能后,代码更薄、依赖更少、可控性反而更高了。前端技术这些年一直在往“更低层、更标准”的方向走,很多时候我们缺的不是能力更强的库,而是对原生API的熟悉程度。下次再接到一个不需要极端复杂交互的表单需求,真的可以先试试这篇里写的思路,也许你会发现以前一直需要的验证库其实完全可以扔掉了。

