1. 表单验证这件事,远比想象中更值得认真对待
做前端开发这些年,表单验证是我见过最容易被低估的环节。很多人觉得不就是写几个正则表达式吗,网上搜一搜复制粘贴就完事了。但真正做过线上项目的人都知道,验证规则写得不严谨,轻则用户体验糟糕,重则数据脏得一塌糊涂,后端同事追着你改bug。手机号验证、邮箱验证、身份证验证这些看似基础的东西,实际上藏着不少门道。
这篇文章主要面向刚入行的前端开发者、需要自己处理表单验证的全栈工程师,以及想系统梳理验证逻辑的技术爱好者。我会把日常项目中最常用的几类验证场景拆开来讲,包括手机号、邮箱、身份证、银行卡号、URL、密码强度等,不仅给出可直接使用的代码,还会解释每条正则背后的逻辑,以及在实际业务中怎么组合使用才能既保证数据质量又不让用户抓狂。
你可能会问,现在不是有很多现成的验证库吗,比如 validator.js、yup、zod 这些,为什么还要自己写?答案很简单:库确实好用,但你得知道它内部在干什么。遇到特殊业务需求时,比如某家运营商的号段规则调整了,或者需要支持港澳台地区的身份证格式,你如果只会调库的API,那就只能干瞪眼。理解底层原理,才能灵活应对变化。
另外,验证这件事不只是前端的事。前端验证是为了提升用户体验,让用户尽快得到反馈;后端验证才是数据安全的最后一道防线。两者配合使用,才能既快又稳。我见过不少项目只做前端验证,结果有人绕过前端直接调接口,塞进来一堆脏数据,最后清洗数据的成本远高于当初写好验证逻辑的成本。
接下来我会按照验证类型逐一展开,每种类型都会覆盖:核心正则的写法与拆解、边界情况的处理、实际项目中的组合使用方式,以及我踩过的那些坑。代码以 JavaScript 为主,但思路适用于任何语言。
2. 手机号验证:从号段规则到国际格式的完整方案
2.1 中国大陆手机号验证的核心逻辑
中国大陆的手机号规则这些年一直在变。早期是 13 开头,后来陆续增加了 15、16、17、18、19 等号段。截至目前,主流号段包括:130-139、145、147、149、150-153、155-159、162、165、166、167、170-178、180-189、191、193、195、196、198、199 等。如果你只是简单地写/^1[3-9]\d{9}$/,虽然能覆盖绝大多数情况,但不够精确。
先看一个相对严谨的版本:
function validatePhoneCN(phone) { const reg = /^1(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])\d{8}$/; return reg.test(phone); }拆解一下这段正则:
^1表示必须以 1 开头,这是所有大陆手机号的共同特征。(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])是第二位和第三位的组合规则。比如4[5-9]覆盖了 145、146、147、148、149;5[0-35-9]排除了 154 这种不存在的号段;6[2567]对应 162、165、166、167;7[0-8]覆盖 170 到 178;9[0-35-9]排除了 194 这种未分配的号段。\d{8}$表示后面跟 8 位数字,加上前面的 3 位,总共 11 位。
注意:号段规则会随着时间变化,建议把正则提取成配置项,方便后续维护。不要把它硬编码在几十个文件里,否则改起来会想哭。
2.2 国际手机号的通用处理思路
做国际化产品时,手机号格式千差万别。美国是 10 位数字,日本是 11 位以 070/080/090 开头,英国是 11 位以 07 开头,德国是 10-11 位且开头多样。这时候用一条正则搞定所有情况是不现实的。
我的做法是:按国家代码维护一个配置表,每个国家有自己的正则和示例号码。用户选择国家后,动态切换验证规则。
const phoneRules = { CN: { reg: /^1(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])\d{8}$/, len: 11, example: '13800138000' }, US: { reg: /^[2-9]\d{9}$/, len: 10, example: '2125551234' }, JP: { reg: /^0[789]0\d{8}$/, len: 11, example: '09012345678' }, UK: { reg: /^07\d{9}$/, len: 11, example: '07123456789' }, DE: { reg: /^0[1-9]\d{8,10}$/, len: '10-11', example: '01512345678' } }; function validatePhone(phone, countryCode = 'CN') { const rule = phoneRules[countryCode]; if (!rule) return false; return rule.reg.test(phone.replace(/[\s-]/g, '')); }这种配置化的方式有几个好处:新增国家只需加一条配置,不用改逻辑;前端可以根据len字段动态限制输入框的最大长度;example可以直接用作 placeholder 提示,降低用户输入错误的概率。
2.3 手机号验证的实操心得与常见坑
第一个坑:用户输入时带空格或横线。比如138 0013 8000或138-0013-8000,这是很常见的输入习惯。我的建议是在验证前先做一次清洗,把空格、横线、括号都去掉,再跑正则。但要注意,清洗只用于验证,提交给后端的应该是清洗后的纯数字。
第二个坑:把验证放在input事件里实时触发。用户才输入了 3 位数字你就提示“手机号格式不正确”,体验非常差。正确的做法是:input时只做长度限制和数字过滤,blur或表单提交时才做完整验证。如果确实想实时反馈,可以设置一个延迟,比如用户停止输入 500ms 后再验证。
第三个坑:忽略虚拟号段。170、171、162、165、167 这些号段是虚拟运营商使用的,有些老旧的验证规则会把它们排除掉,导致用户无法注册。一定要确保你的正则覆盖了这些号段。
第四个坑:不区分“格式正确”和“号码存在”。正则只能验证格式,不能验证这个号码是否真实存在。如果业务需要确认号码有效性,必须走短信验证码流程,这是正则做不到的。
3. 邮箱验证:别再用那条万能但不准确的正则了
3.1 邮箱格式的构成与验证策略选择
邮箱验证是前端最常见的需求之一,也是最容易写出问题的。网上流传最广的那条正则:
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/这条正则能用,但不够严谨。它允许..连续出现,允许@前后的点号位置不规范,也允许域名部分出现下划线等非法字符。更严谨的版本需要考虑 RFC 5322 标准,但那个标准太复杂了,完整实现的正则长达几百个字符,可读性极差。
我的建议是:前端验证用一条“足够好”的正则,确保常见错误能被拦截;后端验证可以更严格一些,甚至发送验证邮件来确认邮箱真实存在。前端的目标是提升体验,不是做学术级的格式校验。
3.2 一条兼顾准确性与可读性的邮箱正则
function validateEmail(email) { const reg = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return reg.test(email); }这条正则的规则拆解:
- 本地部分(
@前面):允许字母、数字和常见特殊字符,包括.、!、#、$、%、&、'、*、+、/、=、?、^、_、`、{、|、}、~、-。这些是 RFC 5322 中允许的字符。 - 域名部分:每个标签以字母或数字开头和结尾,中间可以包含连字符,单个标签长度不超过 63 个字符。标签之间用点号分隔,顶级域名至少 2 个字符。
这条正则能拦截绝大多数错误输入,比如abc@、@domain.com、abc@domain、abc@@domain.com、abc@domain..com等。
3.3 邮箱验证的边界情况处理
实际项目中,邮箱验证有几个容易忽略的边界情况:
大小写问题:邮箱的本地部分理论上区分大小写,但绝大多数邮件服务商都按不区分大小写处理。所以验证时不需要做大小写转换,但存储时建议统一转成小写,避免同一个邮箱注册出两个账号。
长度限制:整个邮箱地址最长 254 个字符,本地部分最长 64 个字符,域名每段最长 63 个字符。如果用户输入超长邮箱,即使格式正确也应该拒绝。
国际化邮箱:有些邮箱包含非 ASCII 字符,比如中文、日文、阿拉伯文。这类邮箱需要先做 Punycode 编码再验证。如果你的产品面向国际用户,需要处理这种情况。不过大多数国内项目可以暂时忽略。
常见域名拼写错误:用户经常把gmail.com打成gmial.com、gamil.com,把qq.com打成qq.con。可以在验证通过后,做一个常见域名的模糊匹配提示,比如“您是不是想输入 gmail.com?”这种小功能能显著降低用户注册失败率。
const commonDomains = ['gmail.com', 'qq.com', '163.com', 'outlook.com', 'foxmail.com', 'hotmail.com']; function suggestEmailDomain(email) { const [local, domain] = email.split('@'); if (!domain) return null; for (const d of commonDomains) { if (levenshteinDistance(domain.toLowerCase(), d) === 1) { return `${local}@${d}`; } } return null; }这里的levenshteinDistance是编辑距离算法,用来计算两个字符串的差异程度。距离为 1 表示只差一个字符的增删改,很可能是拼写错误。这个函数需要自己实现,网上有很多现成的实现可以参考。
4. 身份证验证:18位号码背后的数学逻辑
4.1 身份证号码的结构解析
中国大陆的18位身份证号码不是随机生成的,每一位都有明确含义:
- 第1-6位:地址码,表示出生地的行政区划代码。
- 第7-14位:出生日期码,格式为 YYYYMMDD。
- 第15-17位:顺序码,同一地区同一天出生的人按顺序编号,其中第17位表示性别,奇数为男,偶数为女。
- 第18位:校验码,根据前17位通过特定算法计算得出,取值范围是 0-9 和 X。
校验码的计算方法是身份证验证的核心。具体步骤是:将前17位数字分别乘以对应的加权因子,求和后对11取模,再用余数去查校验码表。
加权因子数组是固定的:[7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]。
校验码表也是固定的:['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'],对应余数 0 到 10。
4.2 完整的身份证验证函数实现
function validateIdCard(id) { // 基础格式校验:18位,前17位为数字,最后一位为数字或X const reg = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/; if (!reg.test(id)) return false; // 校验码计算 const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; let sum = 0; for (let i = 0; i < 17; i++) { sum += parseInt(id[i]) * weights[i]; } const expectedCode = checkCodes[sum % 11]; if (id[17].toUpperCase() !== expectedCode) return false; // 日期有效性校验 const year = parseInt(id.substring(6, 10)); const month = parseInt(id.substring(10, 12)); const day = parseInt(id.substring(12, 14)); const date = new Date(year, month - 1, day); if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { return false; } // 年龄合理性校验(可选) const age = new Date().getFullYear() - year; if (age < 0 || age > 150) return false; return true; }这段代码做了四层验证:格式正则、校验码计算、日期有效性、年龄合理性。其中日期有效性校验很关键,因为正则只能限制月份在 01-12、日期在 01-31,但无法排除 2月30日、4月31日这种不存在的日期。通过new Date()构造日期后反向比对,就能准确判断日期是否真实存在。
4.3 身份证验证的实操注意事项
关于 X 的大小写:身份证最后一位的 X 是大写,但用户输入时经常打成小写 x。验证时应该统一转成大写再比对,但存储时建议保持大写格式。
关于 15 位身份证:老一辈人可能还在使用 15 位身份证。15 位身份证没有校验码,出生年份只有两位。如果需要兼容,可以单独写一条规则:/^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/。不过现在大多数场景已经不需要支持 15 位了。
关于港澳台身份证:香港身份证格式是 1-2 位字母加 6 位数字加括号内的校验码,比如A123456(7)。澳门身份证是 7 位数字加括号内的校验码。台湾身份证是 1 位字母加 9 位数字。这些格式和大陆完全不同,需要单独处理。
关于隐私保护:身份证号是敏感个人信息,前端验证后不应该明文存储在 localStorage 或 URL 参数中。提交时使用 HTTPS,展示时做脱敏处理,比如只显示前 6 位和后 4 位。
关于校验码的容错:有些用户的身份证校验码确实是对的,但因为历史原因,极少数老号码可能存在校验码不匹配的情况。如果业务允许,可以在校验码不匹配时给出警告而非直接拒绝,让用户确认后再提交。
5. 其他高频验证场景与组合使用策略
5.1 银行卡号验证与 Luhn 算法
银行卡号验证不能只靠正则,因为不同银行的卡号长度不同(借记卡通常 16-19 位,信用卡通常 16 位),而且需要验证卡号是否符合 Luhn 算法。
Luhn 算法的逻辑是:从右往左,偶数位(从0开始计数)的数字乘以2,如果乘积大于9则减去9;将所有数字相加,如果总和能被10整除,则卡号有效。
function validateBankCard(cardNo) { const cleaned = cardNo.replace(/\s/g, ''); if (!/^\d{16,19}$/.test(cleaned)) return false; let sum = 0; let shouldDouble = false; for (let i = cleaned.length - 1; i >= 0; i--) { let digit = parseInt(cleaned[i]); if (shouldDouble) { digit *= 2; if (digit > 9) digit -= 9; } sum += digit; shouldDouble = !shouldDouble; } return sum % 10 === 0; }Luhn 算法能拦截大部分输错一位或相邻两位颠倒的情况,但不能验证卡号是否真实存在。如果需要确认卡号有效性,必须走银行接口或第三方支付平台的验证服务。
5.2 密码强度验证的实用方案
密码强度验证不是越复杂越好。我见过要求“必须包含大小写字母、数字、特殊字符、长度不少于12位、不能有连续三个相同字符”的规则,用户直接放弃注册。
我的建议是采用分级策略:
function checkPasswordStrength(password) { let score = 0; if (password.length >= 8) score++; if (password.length >= 12) score++; if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++; if (/\d/.test(password)) score++; if (/[^a-zA-Z0-9]/.test(password)) score++; if (score <= 2) return { level: 'weak', message: '密码强度弱,建议增加长度和字符类型' }; if (score <= 3) return { level: 'medium', message: '密码强度中等,可以再加强一些' }; return { level: 'strong', message: '密码强度高' }; }最低要求是 8 位,包含至少两种字符类型。如果用户输入的是常见弱密码(如12345678、password、qwerty),即使满足长度要求也应该拒绝。可以维护一个常见弱密码列表,验证时做比对。
5.3 多字段组合验证与异步验证的处理
实际表单往往需要同时验证多个字段,而且有些验证是异步的(比如检查用户名是否已被注册)。这时候需要处理好验证的时序和错误提示。
我的做法是:每个字段维护独立的验证状态(idle、validating、valid、invalid),表单提交时检查所有字段是否都是valid。异步验证要加防抖,避免用户每输入一个字符就发一次请求。
const fieldStates = { phone: 'idle', email: 'idle', idCard: 'idle' }; async function validateField(name, value) { fieldStates[name] = 'validating'; let result; switch (name) { case 'phone': result = validatePhoneCN(value); break; case 'email': result = validateEmail(value); break; case 'idCard': result = validateIdCard(value); break; default: result = true; } fieldStates[name] = result ? 'valid' : 'invalid'; return result; } function canSubmit() { return Object.values(fieldStates).every(state => state === 'valid'); }对于异步验证,比如检查邮箱是否已注册,可以这样处理:
let emailCheckTimer = null; function onEmailInput(value) { clearTimeout(emailCheckTimer); if (!validateEmail(value)) { fieldStates.email = 'invalid'; return; } fieldStates.email = 'validating'; emailCheckTimer = setTimeout(async () => { const exists = await checkEmailExists(value); fieldStates.email = exists ? 'invalid' : 'valid'; }, 500); }这里的 500ms 延迟就是防抖,避免频繁请求。同时要注意,如果用户在等待期间又修改了邮箱,需要取消上一次的请求结果,否则可能出现旧请求覆盖新状态的问题。
6. 验证逻辑的工程化组织与维护建议
6.1 把验证规则抽离成独立模块
我见过太多项目把验证正则散落在各个组件里,同一个手机号正则在登录页、注册页、个人中心页各写一遍。后来号段规则变了,要改三个地方,漏一个就出 bug。
正确的做法是建一个validators.js或validators.ts文件,把所有验证函数集中管理:
// validators.js export const validators = { phoneCN: { reg: /^1(3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[0-35-9])\d{8}$/, message: '请输入正确的手机号', validate: (v) => validators.phoneCN.reg.test(v) }, email: { reg: /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, message: '请输入正确的邮箱地址', validate: (v) => validators.email.reg.test(v) }, idCard: { message: '请输入正确的身份证号', validate: validateIdCard } };这样组件里只需要调用validators.phoneCN.validate(value),错误提示直接用validators.phoneCN.message。规则变更时只改一个文件,所有引用处自动生效。
6.2 验证与业务逻辑的解耦
验证函数应该是纯函数:输入一个值,返回布尔值或错误信息,不依赖外部状态,不产生副作用。这样才方便单元测试和复用。
我习惯把验证分成两层:基础验证(格式、长度、类型)和业务验证(唯一性、权限、关联关系)。基础验证放在前端,业务验证放在后端。前端验证通过后,后端仍然要完整验证一遍,不能信任前端传来的任何数据。
6.3 错误提示的措辞与展示时机
错误提示的措辞直接影响用户体验。不要用“输入不合法”这种模糊表述,要具体说明问题所在。比如:
- 手机号:
请输入11位大陆手机号,以1开头 - 邮箱:
邮箱格式不正确,请检查是否缺少@或域名 - 身份证:
身份证号校验失败,请确认号码是否输入正确
展示时机也很关键。不要在用户还没输入完就报错。我的做法是:字段获得焦点后不验证,失去焦点时验证一次,之后每次输入都实时验证(因为用户已经在修改了)。表单提交时,如果有字段未验证过,全部验证一遍,并滚动到第一个错误字段。
6.4 单元测试与边界用例
验证函数是最适合写单元测试的,因为它们是纯函数,输入输出明确。我通常会为每个验证函数准备一组测试用例:
| 验证类型 | 有效用例 | 无效用例 |
|---|---|---|
| 手机号 | 13800138000、19912345678 | 12345678901、1380013800、138001380000 |
| 邮箱 | user@example.com、a.b+c@domain.co | user@、@domain.com、user@domain、user@@domain.com |
| 身份证 | 110101199003077758(示例) | 11010119900307775X(校验码错误)、110101199013077758(月份错误) |
| 银行卡 | 6222021234567890123(Luhn通过) | 6222021234567890124(Luhn失败) |
写测试时特别注意边界值:最小长度、最大长度、刚好少一位、刚好多一位、全数字、全字母、空字符串、null、undefined。这些情况在实际使用中都会遇到,提前处理好能省很多调试时间。
6.5 性能考量与防抖节流
验证本身的计算量很小,正则匹配在现代浏览器中都是微秒级的。真正影响性能的是验证触发的频率。如果每次input事件都跑一遍正则,在低端设备上快速输入时可能会有卡顿感。
我的建议是:对于同步验证,用requestAnimationFrame或简单的节流(比如 100ms 一次)来限制频率。对于异步验证,必须用防抖,而且要在请求发出前取消上一次未完成的请求。
function throttle(fn, delay = 100) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= delay) { last = now; fn.apply(this, args); } }; }这个节流函数保证验证逻辑最多每 100ms 执行一次,既不会漏掉用户的输入,也不会造成性能压力。
7. 我踩过的那些坑与最后分享的几个技巧
先说一个最让我印象深刻的坑。有一次做用户注册功能,手机号验证用的是网上抄来的正则,测试的时候用13800138000通过了,上线后陆续有用户反馈 165 号段的号码无法注册。一查才发现那条正则只覆盖了 13、15、18 开头,16 和 19 号段完全没考虑。后来我把正则改成配置化的方式,并且加了一条规则:每次运营商公布新号段时,第一时间更新配置并跑一遍回归测试。
第二个坑是关于身份证验证的。有个项目的身份证验证只做了正则格式校验,没有做校验码计算。结果有用户用110101199003077750这种校验码错误的号码注册成功了,后来做实名认证时对不上,又得让用户重新提交。校验码计算虽然多几行代码,但能拦截大量手误输入,非常值得。
第三个坑是邮箱验证的大小写问题。有个用户用User@Example.com注册,后来又用user@example.com注册,系统认为是两个不同的邮箱,导致同一个人的两个账号无法合并。后来我们在存储时统一转小写,问题才解决。
最后分享几个实用技巧:
- 把常用验证正则做成 VS Code 代码片段,输入
vphone就能自动补全手机号验证函数,省去重复敲代码的时间。 - 在表单的
novalidate属性上做文章,禁用浏览器自带的验证提示,用自己的 UI 展示错误信息,风格更统一。 - 对于需要支持多语言的产品,错误提示信息不要硬编码在验证函数里,而是通过 key 去语言包中查找,方便翻译。
- 定期回顾线上表单的提交失败率,如果某个字段的失败率明显偏高,可能是验证规则太严格或提示不清晰,需要优化。
验证这件事,写起来不难,写好却需要经验。希望这些内容能帮你少走一些弯路。