表单这东西,平时大家可能不太在意,但一旦做得不好,用户填到一半就想摔手机走人。特别是用Bootstrap做前端页面的时候,很多人以为把表单样式套上去就万事大吉了,结果一点提交,要么直接跳转刷新,要么报错信息丑得没法看,要么明明填了正确的内容还是提示错误。这篇文章就从实际使用的角度,把Bootstrap表单验证如何实现,以及那些让人头疼的常见问题怎么修复,一次性讲明白。
一、为什么表单验证这么重要
咱们换个场景想一想。你去一个网站注册账号,好不容易把用户名、密码、手机号都填完了,一点注册,页面刷了一下,然后告诉你邮箱格式不对。更气人的是,之前填的内容全没了,得从头再来一遍。这种体验谁遇到谁炸毛。表单验证的作用,就是在用户把数据交出去之前,先在浏览器里帮我们检查一遍。填错了当场指出来,填对了再放行。这既能减少服务器的压力,又能让用户觉得“这个网站还挺聪明的”。Bootstrap本身虽然不提供一套完整的验证逻辑,但它给出了很好的样式基础,比如红色边框、绿色边框、错误提示气泡等等。我们只需要在这些基础上,写一些简单的脚本,就能做出很顺手的验证效果。
二、Bootstrap表单验证的基础玩法
2.1 先搭一个简单的表单
我们先用Bootstrap 5做一个最普通的登录表单。为了让代码能直接跑起来,我们用CDN引入Bootstrap的CSS和JS。这次所有示例都统一使用JavaScript(原生)搭配Bootstrap 5,过程中不使用jQuery,也不用其他验证插件。先看这个基础表单长什么样。
注意这个form上写了novalidate,表示先关掉浏览器自带的验证提醒。因为浏览器自带的提示气泡长得又丑又没法统一风格,我们更希望自己控制提示方式。
2.2 用Bootstrap自带的表单验证样式
Bootstrap 5官方文档里给了一套“was-validated”的玩法。简单说,当表单提交失败时,我们给form元素加上was-validated这个类,Bootstrap就会自动根据输入框的required、type等属性来显示红色边框和错误文字。我们先看这个最省事的例子。
// 先取出表单元素
const form = document.getElementById('demoForm');
// 监听表单的提交事件
form.addEventListener('submit', function (event) {
// 如果表单内容不符合HTML5验证规则,就阻止提交
if (!form.checkValidity()) {
event.preventDefault(); // 不给跳转
event.stopPropagation(); // 停止冒泡,防止其他脚本干扰
}
// 加上这个类,Bootstrap就会根据输入框的状态显示红框或绿框
form.classList.add('was-validated');
});
这段代码里,checkValidity()是HTML5自带的方法,它会检查每个带required、min、max、type这些属性的控件是否满足规则。只要有一个不满足,就返回false。然后我们给form加上was-validated类,Bootstrap的CSS就会让对应的.invalid-feedback显示出来。注意,每个输入框下面都要放一个.invalid-feedback的div,否则没地方显示错误文字。如果用户填对了,Bootstrap会自动给输入框加绿边框,但默认不会打对勾图标。要是想要那种带对勾的效果,还得再往输入框的父级加.validate之类的类,但咱们先不搞那么花哨,先把基础跑通。
三、自己写验证逻辑,把主动权握在手里
官方这套was-validated虽然省事,但有个大毛病——它只能依赖HTML5内置的规则。比如“两次输入的密码必须一致”、“手机号必须是11位且以1开头”、“验证码只能填写6位数字”,这些规则靠minlength和maxlength根本写不出来。所以实际项目中,我们还得自己写验证逻辑,然后再把Bootstrap的样式套上去。
3.1 验证一个输入框
我们先从最简单的开始:单独验证一个邮箱输入框。当用户离开输入框(blur事件)或者按键盘的时候,我们就检查输入的内容是不是合法的邮箱格式。如果不对,就把输入框的is-invalid类加上,同时显示出错误提示;如果对了,就换成is-valid类,让边框变绿。
// 拿到输入框和错误提示元素
const emailInput = document.getElementById('emailInput');
const emailError = document.getElementById('emailError');
// 定义一个简单的邮箱正则
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 写一个校验函数,输入框的元素作为参数传进来
function validateEmail() {
const value = emailInput.value.trim();
// 如果为空,提示不能为空
if (value === '') {
emailInput.classList.add('is-invalid'); // 加红框
emailInput.classList.remove('is-valid'); // 去掉绿框
emailError.textContent = '邮箱不能为空,别偷懒';
return false;
}
// 如果格式不正确
if (!emailPattern.test(value)) {
emailInput.classList.add('is-invalid');
emailInput.classList.remove('is-valid');
emailError.textContent = '邮箱格式不对,@和点号不能少';
return false;
}
// 一切正常,加绿框
emailInput.classList.remove('is-invalid');
emailInput.classList.add('is-valid');
return true;
}
// 监听输入事件:用户边打字边校验
emailInput.addEventListener('input', validateEmail);
// 监听失去焦点事件:输入完成后校验
emailInput.addEventListener('blur', validateEmail);
这里有个小细节:我们用了.invalid-feedback这个类,但默认它是display:none的,只有当输入框兄弟节点中有.is-invalid或者父级有.was-validated时才会显示出来。所以我们在JS里给输入框加了is-invalid类后,Bootstrap就能自动把错误提示显示出来。反过来,如果输入正确,我们会加上is-valid,输入框会变绿,但不会自动加对勾图标。想加对勾图标的话,得在输入框旁边放valid-feedback的div,然后把类换成is-valid后它自己就显示了。这里暂时先不加,后面会说到。
3.2 给表单加上全部验证
一个输入框会了,一整个表单也没啥难的。核心思路就是:在提交的时候,把表单里所有想验证的字段都跑一遍。只要有一个不通过,就不让提交。另外,我们还可以在用户点击提交的那一刻,让所有错误提示一次性冒出来,而不是填一个错一个,这样用户能知道自己到底哪几项填错了。
下面这个例子,模拟一个注册表单,包含姓名、电话、密码、确认密码四个字段。还引入了密码强度提示,算是一点点小加分项。
// 将所有需要校验的输入框和对应的错误提示元素放在一个对象里
const fields = {
name: { input: document.getElementById('name'), error: document.getElementById('nameError') },
phone: { input: document.getElementById('phone'), error: document.getElementById('phoneError') },
pwd: { input: document.getElementById('pwd'), error: document.getElementById('pwdError'), strengthBar: document.getElementById('pwdStrength') },
pwdConfirm: { input: document.getElementById('pwdConfirm'), error: document.getElementById('pwdConfirmError') }
};
// 手机号正则:以1开头,第二位是3-9,后面还有9位数字
const phonePattern = /^1[3-9]\d{9}$/;
// 给单个字段设置校验结果,并且更新UI
function setError(fieldObj, message) {
// 清除之前的样式
fieldObj.input.classList.remove('is-valid', 'is-invalid');
if (message) {
// 有错误消息就显示红框
fieldObj.input.classList.add('is-invalid');
fieldObj.error.textContent = message;
} else {
// 没有错误就显示绿框
fieldObj.input.classList.add('is-valid');
fieldObj.error.textContent = '';
}
}
// 校验姓名:长度至少2个字符,不能有数字
function validateName() {
const v = fields.name.input.value.trim();
if (v === '') {
setError(fields.name, '姓名不能空着');
return false;
}
if (v.length < 2) {
setError(fields.name, '姓名至少两个字');
return false;
}
if (/\d/.test(v)) {
setError(fields.name, '姓名不能包含数字');
return false;
}
setError(fields.name, '');
return true;
}
// 校验手机号
function validatePhone() {
const v = fields.phone.input.value.trim();
if (!v) {
setError(fields.phone, '手机号必须填');
return false;
}
if (!phonePattern.test(v)) {
setError(fields.phone, '手机号要11位,以1开头,第二位3-9');
return false;
}
setError(fields.phone, '');
return true;
}
// 更新密码强度进度条,顺便校验密码长度
function updatePwdStrength() {
const v = fields.pwd.input.value;
let strength = 0;
// 长度超过6位加20分
if (v.length >= 6) strength += 20;
// 含小写字母加20
if (/[a-z]/.test(v)) strength += 20;
// 含大写字母加20
if (/[A-Z]/.test(v)) strength += 20;
// 含数字加20
if (/\d/.test(v)) strength += 20;
// 含特殊字符加20
if (/[^a-zA-Z0-9]/.test(v)) strength += 20;
// 更新进度条的宽度和颜色
const bar = fields.pwd.strengthBar;
bar.style.width = strength + '%';
bar.className = 'progress-bar';
// 小于40分用红色,40-80用黄色,80+用绿色
if (strength < 40) {
bar.classList.add('bg-danger');
} else if (strength < 80) {
bar.classList.add('bg-warning');
} else {
bar.classList.add('bg-success');
}
// 密码是否算合法,只看长度
if (v.length < 6) {
setError(fields.pwd, '密码至少6位');
return false;
}
setError(fields.pwd, '');
return true;
}
// 校验确认密码是否和密码一致
function validatePwdConfirm() {
const pwd = fields.pwd.input.value;
const confirm = fields.pwdConfirm.input.value;
if (confirm === '') {
setError(fields.pwdConfirm, '确认密码不能空白');
return false;
}
if (pwd !== confirm) {
setError(fields.pwdConfirm, '两次密码不一样');
return false;
}
setError(fields.pwdConfirm, '');
return true;
}
// 把所有校验函数放到一个数组中,方便统一执行
const validators = [
validateName,
validatePhone,
updatePwdStrength,
validatePwdConfirm
];
// 给每个输入框绑定输入事件,实时校验单个字段
fields.name.input.addEventListener('input', validateName);
fields.phone.input.addEventListener('input', validatePhone);
fields.pwd.input.addEventListener('input', updatePwdStrength);
fields.pwdConfirm.input.addEventListener('input', validatePwdConfirm);
// 提交时执行全部校验
document.getElementById('registerForm').addEventListener('submit', function (event) {
let isAllValid = true;
// 把每个校验函数都跑一遍
for (const validator of validators) {
// 只要有一个返回false,整体就是false
if (!validator()) {
isAllValid = false;
}
}
// 如果还有问题,就阻止提交
if (!isAllValid) {
event.preventDefault();
event.stopPropagation();
} else {
// 能走到这里,就能把数据交给后端了
alert('验证通过,可以提交!');
}
});
这段代码看起来长,其实思路很清晰:每个字段的校验都是一个独立的函数,返回true或false。提交的时候把所有函数挨个调用一遍,只要有一个失败就拦住。输入框有边框颜色,错误提示有自己的div,密码有一个小进度条,体验比刚才好很多。
四、常见问题以及修复方法
先别急着高兴,实际开发中你可能会遇到一堆奇怪的问题。这里挑几个最常见的,一个一个说。
4.1 提交时还是跳转了,没拦住
很多人会碰到这种情况:明明写了event.preventDefault(),可表单还是提交了,页面一闪就刷新了。最常见的原因是忘了在form标签上加novalidate。如果不加这个属性,浏览器会先用自己的校验流程来拦一遍,可你监听的那个submit事件可能压根不会触发,因为浏览器在触发submit事件之前就把表单拦下来了。另外还有一个容易忽略的坑:如果你在JS里写form.submit()来触发表单提交,那是不会触发submit事件的,preventDefault自然也就不会执行。正确的做法是,永远让用户点击提交按钮来触发提交,或者用form.requestSubmit(),这个方法才会触发提交事件。
还有一个情况,就是你给form的submit事件绑定了处理函数,但函数里忘了写return false。如果是用onclick属性直接写在HTML里面,就必须写return false才能阻止默认行为。但现在咱们都用addEventListener,就不存在这个问题了。不过为了保险起见,我还是建议在提交按钮的click事件里也做一次校验,双保险。
4.2 验证通过了,但UI没有变化
有时候数据明明是对的,输入框却还是红边框,或者错误提示一直不消失。出现这种问题,通常是因为你给输入框设置了is-invalid类后,在通过验证时确实移除了,但CSS里可能还残留着另外的类,或者是父级容器有.was-validated在捣乱。比如你之前试过Bootstrap自带的验证方式,给form加了was-validated,然后你又自己用is-invalid去控制,这俩类互相打架,结果就乱套了。解决办法很简单:要么完全依赖was-validated,要么完全自己控制is-invalid/is-valid,不要混着用。如果非要用was-validated,那你就得保证所有输入框都符合HTML5校验规则,不然你自己写的错误提示就会跟Bootstrap的默认提示同时出现,看着很乱。
另外还有一个容易忽略的点:如果你给输入框加了disabled属性,Bootstrap就不会对它做任何验证,也不会显示红绿边框。需要验证的输入框一定不要加disabled,要加就加readonly,但这俩语义不同,别搞混。
4.3 提示文字全部挤在一起
当多个字段同时出错时,错误提示会按照文档流一个一个往下排,这个本身没啥问题。但有些人会把.invalid-feedback放进一个.input-group(输入组)里,这时候提示位置就会错乱,甚至会跑到输入框外面。原因很简单,Bootstrap的.invalid-feedback通常要求它紧跟在输入框后面,并且和输入框在同一个父级容器里。如果你为了做图标或按钮把结构改复杂了,可以用.invalid-tooltip来代替,这样提示会以气泡的形式浮在输入框上方。区别在于,.invalid-feedback是静态的块级元素,会挤占布局空间;而.invalid-tooltip是绝对定位的气泡,不会影响其他元素的位置。
@
但要注意,如果你把提示放在.input-group里,并且输入框已经有.is-invalid,Bootstrap会自动调整样式,但有时因为父级宽度不够,会导致提示换行。这时候可以考虑用invalid-tooltip,顺便把父级容器设成position: relative。
4.4 自定义校验规则失效
有些人自己写正则,比如检查身份证号、车牌号、银行卡号,结果发现怎么都不生效。大概率是因为正则表达式哪里写错了,比如少了转义符号,或者把test方法用错了地方。更隐蔽的是,你在输入框的oninput事件里做校验,但用户已经输入了内容,却一直没有触发input事件,比如浏览器自动填充表单的时候,就不会触发input事件。这时候你就需要监听change事件,还有一个比较冷门的autofill处理。最简单的办法,是除了绑定input之外,再绑定一下change,并且在页面加载完成后主动跑一次所有校验,这样自动填充的内容也能被发现。
另外一个常见原因,是校验函数返回了true,但紧接着代码里又有个地方把is-invalid加回去了,或者把错误消息赋了一个空字符串但没清除红框。遇到这种“时灵时不灵”的问题,最好的办法是在校验函数末尾用console.log打印一下校验结果,然后一步步排查。不要相信自己的眼睛,多打印几条日志,啥都能看出来。
4.5 动态生成的表单元素验证不生效
现在很多表单不是写死在页面上的,而是根据用户操作动态加出来的,比如点击一下“添加一行”按钮,就生成一个新的输入框。但之前绑定的addEventListener只对页面初始时的那些元素生效。新加进来的输入框没有事件监听,自然就不会被校验。
修复这种动态问题,有两个思路。第一个思路是:每生成一个新元素,就重新给这个元素绑定一次校验事件。这个思路最直白,但代码会显得零散。第二个思路是用“事件委托”,把监听器绑定在form这个父元素上,然后通过e.target判断用户操作的是不是输入框,再做对应处理。事件委托的好处是,不管以后加多少个输入框,都不用重新绑定事件。
下面演示一个用事件委托校验动态行的例子,只截取了核心部分,完整代码在本地就能跑。
动态添加成员
const dynamicForm = document.getElementById('dynamicForm');
const memberList = document.getElementById('memberList');
// 添加新行
document.getElementById('addRow').addEventListener('click', function () {
const row = document.createElement('div');
row.className = 'row mb-2';
row.innerHTML = `
`;
memberList.appendChild(row);
});
// 删除一行(用事件委托处理动态生成的删除按钮)
memberList.addEventListener('click', function (e) {
if (e.target.classList.contains('remove-btn')) {
e.target.closest('.row').remove();
}
});
// 用事件委托监听所有输入框的input事件
dynamicForm.addEventListener('input', function (e) {
// 只关心name和phone输入框
if (e.target.classList.contains('form-control')) {
validateSingleField(e.target);
}
});
// 验证单个输入框
function validateSingleField(input) {
// 判断它是姓名还是手机号,用占位符来识别,实际项目建议用data属性
if (input.placeholder === '成员姓名') {
if (input.value.trim().length < 2) {
input.classList.add('is-invalid');
input.classList.remove('is-valid');
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
} else if (input.placeholder === '手机号') {
const phonePattern = /^1[3-9]\d{9}$/;
if (!phonePattern.test(input.value.trim())) {
input.classList.add('is-invalid');
input.classList.remove('is-valid');
} else {
input.classList.remove('is-invalid');
input.classList.add('is-valid');
}
}
}
// 提交时遍历所有行
dynamicForm.addEventListener('submit', function (e) {
let allOk = true;
// 选取所有输入框(只选数据行里的,不要选默认的按钮)
const inputs = memberList.querySelectorAll('input.form-control');
inputs.forEach(function (input) {
validateSingleField(input);
// 如果这个输入框变成了is-invalid,说明还没通过
if (input.classList.contains('is-invalid')) {
allOk = false;
}
});
if (!allOk) {
e.preventDefault();
e.stopPropagation();
} else {
alert('所有行都填对了!');
}
});
事件委托的关键在于dynamicForm.addEventListener('input', ...),因为input事件会从子元素冒泡到form,所以我们只需要在这里监听一次,不管后来加多少行,都能捕获到。删除按钮也是同理,用memberList监听click事件,然后判断e.target是不是删除按钮。这种方法在写复杂表单时特别省心。
五、应用场景与优缺点
Bootstrap表单验证适合用在管理后台、简单官网、公司内部系统这些项目里。因为这些项目往往不需要特别复杂的交互,界面风格统一,用Bootstrap能快速出活。尤其是配合后端同步验证,前端做一层“温和”的提示,后端再做最后的兜底,这种模式非常常见。
它的优点很明显:第一,学习成本低,Bootstrap的样式类名是公开的,写起来和写普通HTML没啥区别;第二,样式一致,用Bootstrap做的表单不会出现一个红一个蓝的情况;第三,结合原生JavaScript就能做,不用再引入一个庞大的插件,项目构建也轻松。
缺点也存在。首先,Bootstrap本身不包含验证逻辑,需要自己写JS,而且没有现成的国际化错误消息,提示文字都得自己写。其次,对于特别复杂的表单,比如多步骤、联动判断、根据接口返回值动态更新错误,这些事用Bootstrap做起来不够爽,需要写很多额外代码。另外,如果页面已经用了别的UI库,再塞进Bootstrap,样式冲突会让你怀疑人生。所以,选不选Bootstrap做验证,得先看看你的项目是不是已经引入了Bootstrap。如果已经引入了,那就放心的用;如果完全没用过,只是为了表单验证而引入一个大CSS框架,那不太划算。
六、注意事项
在实际开发中,有几个细节一定要记牢。
第一,前端验证永远不能代替后端验证。用户可以在浏览器里禁用JavaScript,甚至自己拼接口提交假数据。你前端口头说“邮箱格式不对”,并不代表后端不接收这封邮件。后端必须再做一次完整的数据校验,这也是行业共识。
第二,错误提示文案要写人话。别写“非法字符”“格式不合法”这种冷冰冰的话,换成“密码只能填数字和字母”“手机号要11位”这种大家一看就懂的表达。还有,提示要出现在输入框附近,不要放在页面最底部,让用户来回看,眼睛会很累。
第三,注意校验时机。不要在用户填第一个字母的时候就弹红框,那样会把人吓跑。一般做法是:输入框还没被碰过的时候,不做多余的提示;等用户输入了一部分,再开始实时校验;等用户离开输入框,再给最终结论。这就是所谓的“模糊校验”和“严格校验”的区别。你可以为每个输入框设定一个touched状态,第一次失去焦点之后,再开始实时校验。
第四,当表单里有多个必填项时,提交按钮点击后,最好把第一个出错的输入框滚动到视野中,并且让它自动获得焦点。这样用户可以立刻知道从哪里开始改。用Bootstrap做这个很简单,就是firstInvalidElement.focus({preventScroll: false}),再配合scrollIntoView。
第五,需要考虑浏览器兼容性。Bootstrap 5对IE的兼容性很差,如果你的项目还需要支持IE11,那建议用Bootstrap 4,或者干脆自己写一套验证逻辑。另外,checkValidity()方法在所有现代浏览器里都有,但在一些老旧的webview里可能会出幺蛾子,保险起见可以用try-catch包一层,或者自己写一个简单的校验函数替代。
第六,动态生成的表单元素,一定要记得用事件委托或者重新绑定事件,不然就会像上面那样,新加的行根本不参与校验。还有,动态行删除的时候,也要检查一下是否留下了被移除元素的错误提示,不然界面上会残留红框。
七、文章总结
表格里那一个小小的验证框,看似不起眼,但真的能决定一个用户愿不愿意在你的网站上多停留几分钟。通过这篇文章,我们把Bootstrap表单验证的两种路线捋清楚了:一种是直接依赖Bootstrap自带的was-validated样式加上HTML5的验证属性,适合简单场景;另一种是用JavaScript自己控制is-invalid和is-valid,适合需要自定义规则的场景。同时也把提交拦不住、UI不更新、动态内容失效这些坑点都填了。
做表单验证这件事,本质上不是在跟技术较劲,而是在跟用户沟通。你要让用户觉得,填表是有指引的,是不容易被卡住的。哪怕他填错了,你也能温柔地告诉他哪里错了、该怎么改。只要能做到这一步,你手里的Bootstrap就真正派上用场了。
最后再说一句:无论你用了多漂亮的验证效果,记得后端一定要再做一次验证。前端和后端,一个是门口的保安,一个是仓库里的保管员,都存在才安心。希望你下一次做表单的时候,能稳稳当当的,不再被这些乱七八糟的小问题绊住脚。