身份证正则表达式这个东西,几乎每个写过表单校验的人都被它折腾过一次。我见过的写法五花八门,从最早的^\d{18}$,到后来动辄一百多个字符的超长正则都有,但真正上线之后能同时做到"不误杀正常用户、不放行脏数据、报错提示还让人看得懂"的并不多。这篇就按我自己做表单校验和数据处理的实际顺序,把身份证正则表达式的构造逻辑、正则管不到的那部分、以及几个跨语言踩过的坑,完整捋一遍。不管你是刚接触正则的新手,还是已经在项目里维护过好几版校验规则的老手,应该都能从中挑到能直接用的东西。
1. 一条身份证正则该管什么、不该管什么
在动手写字符之前,先想清楚这条正则的职责边界,比直接抄一个网上流传的"万能正则"要省事得多。正则的强项是"形状匹配"——总长度对不对、某几位是不是数字、末位是不是 X。它的弱项是"语义判断"——这一天到底存不存在、这个号码是否真实有效。
1.1 正则能覆盖的三层校验
我一般把身份证相关的校验拆成三层来看,每层的实现手段完全不同:
第一层是字符层。整串必须由 17 位数字加 1 位校验码组成,校验码可以是 0-9 或者字母 X。这一层是正则的主场,一条^[0-9]{17}[0-9Xx]$就能拿下来,成本几乎为零。
第二层是结构层。号码的 18 位不是随便排的,前 6 位是地址码,第 7 到 14 位是出生日期YYYYMMDD,第 15 到 17 位是顺序码,第 18 位是校验码。正则可以按位置切开,检查月是不是在 01-12、日在不在 01-31。这一层正则依然能做,只是写法开始变长。
第三层是语义层。2 月 30 日不存在,闰年的 2 月有 29 天,平年的 2 月只有 28 天;末位校验码是由前 17 位通过模 11-2 算法算出来的,必须对得上。日期合法性还能塞进正则(虽然写出来很难读),但校验位是算术运算,正则表达式的表达能力根本做不到,必须落到代码里。
提示:凡是看到有人声称"一条正则校验身份证 100% 准确",基本可以判断他没算过校验位。正则不是计算器。
1.2 不同业务场景需要的严格程度完全不同
我在实际项目里发现,同样是"校验身份证",不同场景的诉求差别巨大,一刀切用最严格的正则反而会带来麻烦。
| 场景 | 需要的严格度 | 推荐方案 | 常见误判代价 |
|---|---|---|---|
| 注册表单的前端即时校验 | 中 | 结构正则 + 日期判断 | 过严会拦住合法用户,转化率下降 |
| 后端接口入参校验 | 高 | 结构正则 + 日期 + 校验位 | 过松会存进脏数据,后期清洗成本高 |
| 日志/数据仓库的批量筛查 | 低 | 宽松正则粗筛 | 过严会漏掉历史遗留格式 |
| 数据脱敏展示前的合法性确认 | 中 | 结构正则即可 | 过严会导致部分记录无法脱敏 |
| 纯正则引擎环境(如某些日志系统) | 中 | 尽可能长的单条正则 | 无法覆盖校验位,只能接受 |
这张表是我想强调的核心:不要追求一条正则打天下。前端为了体验应该宽松一些,后端为了数据质量必须严格,批处理为了不漏数据应该先粗筛再精筛。把这三者的责任分开,代码可维护性会好很多。
另外一个常被忽略的点是:校验失败时的提示语设计。用户填错身份证号,如果只弹一个"格式错误",他根本不知道该改哪里。我在项目里会把失败原因分成"长度不对""出生日期不合法""校验位不匹配"三类分别提示,虽然实现上多写十几行,但客服工单量明显下降。
2. 把 18 位号码拆成四段,再翻译成正则
直接背网上那些长正则很难记住,我的做法是把号码结构拆开,一段一段翻译成字符,最后拼起来。这样写出来的正则即使忘了也能现推。
2.1 四段结构的取值规则
地址码(第 1-6 位):前两位是省级代码,中间两位是市级,最后两位是县区级。规则上首位不能是 0,所以可以用[1-9]开头。中间四位理论上可以是 0,但实际公布的行政区划代码里很少出现连 0 的情况。很多严格版本会要求前两位落在 11 到 82 这个区间内,我的建议是不做这么细的硬编码,因为行政区划代码是会更新的,写死反而容易误杀。
出生日期码(第 7-14 位):格式固定为YYYYMMDD。年份范围要自己定,我一般用(?:18|19|20)[0-9]{2}覆盖 1800 到 2099 年,实际业务里更常见的做法是限制在 1900 年之后,甚至限制在"当前年份往前推 100 年"以内。月份用(?:0[1-9]|1[0-2]),日期先粗放地写成(?:0[1-9]|[12][0-9]|3[01]),具体哪天存不存在交给后面处理。
顺序码(第 15-17 位):三位纯数字,[0-9]{3}。这里有个大家都知道的小知识——第 17 位是奇数代表男性,偶数代表女性。我在做数据统计时用过这个位,但在校验逻辑里我不建议对它做任何限制,因为没有任何依据表明某个取值不合法。
校验码(第 18 位):[0-9Xx]。注意一定要把大小写 x 都收进来,很多用户习惯手打小写,直接拒掉体验很差。
2.2 从结构图到正则的对应关系
把上面四段拼起来,就得到骨架版正则:
^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9Xx]$我特意把^和$都写上了,这一点非常重要。如果只写中间那段不加锚点,test()会在任意子串位置上尝试匹配,导致一长串乱码里只要有 18 个符合规则的字符就会返回 true。我在代码审查里见过不少这种漏锚点的写法,属于典型的一失足成千古恨。
另外注意我在年份那里用了(?:...)而不是(...)。非捕获组不影响匹配结果,但能减少引擎的捕获开销,在批量处理几十万条数据时有意义。虽然这个优化微乎其微,但养成习惯没坏处。
如果你只想校验"看起来像身份证",上面这条就够用了。它同时解决了长度、字符集、月份范围、日期范围四个问题,比^\d{18}$强了不止一个档次。但它依然会放过2020 年 2 月 31 日这种不存在的日期,需要进入下一节处理。
3. 由松到紧的五版写法,到底该选哪一版
我在不同项目里积累下来大概五版正则,从最宽松到最严格。把它们放在一起对比,能很清楚地看到每一版的取舍在哪儿。
3.1 前三版:宽松写法和它放过的东西
V1:^\d{18}$。这是最偷懒的一版,只检查长度和"全是数字"。实测下来它会把末位为 X 的号码全部拒掉,等于把一部分正常用户堵在门外,而且完全不管日期,19900231照样通过。这一版唯一的用处是在数据清洗脚本里做粗筛。
V2:^\d{17}[\dXx]$。补上了末位 X,解决了 V1 最致命的误杀问题。剩下的问题依旧:日期不管、地址码不管、前导 0 不管。
V3:^[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]$。这就是上一节给出的骨架版。大部分业务停在这一版已经够用,特别是只需要前端做格式提示的场景。
顺便说一个容易忽略的细节:V3 系列里我用了\d而不是[0-9],这两者在某些语言里并不等价,后面第 6 节会专门讲。
3.2 第四版和第五版:把日历塞进正则
V4:加入大小月判断。这一版把每个月的天数差异写死进去:
^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:(?:0[13578]|1[02])(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)(?:0[1-9]|[12][0-9]|30)|02(?:0[1-9]|1[0-9]|2[0-8]))[0-9]{3}[0-9Xx]$它的思路是把 12 个月分成三组:31 天的月、30 天的月、2 月。2 月先按 28 天算,闰年的 2 月 29 日单独处理。这一版已经能挡住20200231这种明显不存在的日期了。
V5:把闰年规则也写进去。闰年的判断是"能被 4 整除且不能被 100 整除,或者能被 400 整除"。翻译成正则,对 19xx 和 20xx 这个区间来说,末两位为00的只有 2000 年是闰年(1900 年不是),其余闰年的末两位落在04、08、…、96这个集合里:
(?:0[48]|[2468][048]|[13579][26])把它和 V4 的 2 月分支合并,就得到完整的闰年版。写出来大概是这样:
^[1-9][0-9]{5}(?:(?:18|19|20)[0-9]{2})(?:(?:(?:0[13578]|1[02])(?:0[1-9]|[12][0-9]|3[01]))|(?:(?:0[469]|11)(?:0[1-9]|[12][0-9]|30))|(?:02(?:0[1-9]|1[0-9]|2[0-8]))|(?:02-29))[0-9]{3}[0-9Xx]$等等,这个写法里我故意留了个错误演示——把02-29写成带连字符的形式是错的,因为身份证里日期段没有分隔符。正确的闰年 2 月分支应该是:
(?:(?:(?:18|19|20)(?:0[48]|[2468][048]|[13579][26])|2000)02(?:29))然后把它作为一条额外的可选分支插到日期组里。完整的正则会长到一百六十多个字符,可读性已经很低了。我的实际选择是:需要闰年判断时不再堆正则,改成"正则骨架 + 代码判断日期",理由有两个。
第一是可读性。那条一百六十字符的正则,三个月后你自己都看不懂,改一个年份范围要盯着看五分钟。
第二是可维护性。业务上经常会加"必须年满 18 周岁"这种规则,这时候你无论如何都得用代码取当前日期做比较,正则再多做一层也没意义。
| 版本 | 长度 | 覆盖能力 | 误杀风险 | 适用位置 |
|---|---|---|---|---|
| V1 | 8 字符 | 仅长度与数字 | 高(拒 X 结尾) | 数据粗筛 |
| V2 | 14 字符 | 长度、数字、X | 中 | 日志扫描 |
| V3 | 约 90 字符 | + 月份日期范围 | 低 | 前端提示 |
| V4 | 约 130 字符 | + 大小月 | 低 | 后端兜底 |
| V5 | 约 165 字符 | + 闰年 | 低 | 纯正则环境 |
| V3 + 代码 | 约 90 字符 | 全部(含校验位) | 最低 | 推荐方案 |
这张表是我给团队新人讲这块内容时必贴的,选型一目了然。
4. 校验位是真正的分水岭,正则做不到
前面反复提到校验位,这里展开讲。第 18 位不是随机的,它是前 17 位按加权求和再取模算出来的。这套算法的正式名称是模 11-2 校验,本质是一个防输错的校验码机制。
4.1 权重和映射表的由来
每一位数字都有一个权重,权重是 2 的幂对 11 取模的结果。具体来说,第 i 位的权重等于 2^(18-i) mod 11。手算一下前几位:
- 第 1 位:2^17 mod 11。因为 2^10 mod 11 = 1,所以 2^17 mod 11 = 2^7 mod 11 = 128 mod 11 = 7
- 第 2 位:2^16 mod 11 = 2^6 mod 11 = 64 mod 11 = 9
- 第 3 位:2^15 mod 11 = 2^5 mod 11 = 32 mod 11 = 10
- 第 4 位:2^14 mod 11 = 2^4 mod 11 = 16 mod 11 = 5
一直算到第 17 位,得到的权重序列是:
7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2把前 17 位数字和对应权重相乘再求和,得到 S。然后 S 对 11 取余,余数 R 落在 0 到 10 之间。最后通过一张固定的映射表把余数转成校验字符:
| 余数 R | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 校验码 | 1 | 0 | X | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
这张表就是全部秘密。余数为 2 时对应 X,这也是"末位 X"的唯一来源。
4.2 一次完整的手算验算
光看公式容易飘,我拿一个虚构号码实际算一遍。下面这个号码里的地址码123456是我随手编的占位值,只用于演示算术过程,不代表任何真实地区。
取号码12345619900315123X,前 17 位是12345619900315123,逐位乘权重:
| 位置 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 数字 | 1 | 2 | 3 | 4 | 5 | 6 | 1 | 9 | 9 | 0 | 0 | 3 | 1 | 5 | 1 | 2 | 3 |
| 权重 | 7 | 9 | 10 | 5 | 8 | 4 | 2 | 1 | 6 | 3 | 7 | 9 | 10 | 5 | 8 | 4 | 2 |
| 乘积 | 7 | 18 | 30 | 20 | 40 | 24 | 2 | 9 | 54 | 0 | 0 | 27 | 10 | 25 | 8 | 8 | 6 |
把乘积加起来:7+18+30+20+40+24+2+9+54+0+0+27+10+25+8+8+6 = 288。
288 ÷ 11 = 26 余 2。查表,余数 2 对应的校验码是 X。所以这个号码的第 18 位必须是 X,换成任何别的字符都不合法。
这个手算过程我建议每个写过校验逻辑的人都亲手跑一遍,跑完之后你对这套算法的理解会完全不一样,后面写代码就是机械翻译。
4.3 三种语言的落地实现
JavaScript 版本,适合放前端和 Node 服务端:
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const CHECK_MAP = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; const SHAPE = /^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]$/; function validateId(raw) { if (typeof raw !== 'string') return { ok: false, reason: 'type' }; const id = raw.trim().toUpperCase(); if (!SHAPE.test(id)) return { ok: false, reason: 'shape' }; // 日期真实性单独判断,避免正则过分膨胀 const y = Number(id.slice(6, 10)); const m = Number(id.slice(10, 12)); const d = Number(id.slice(12, 14)); const dt = new Date(y, m - 1, d); if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) { return { ok: false, reason: 'date' }; } let sum = 0; for (let i = 0; i < 17; i++) sum += Number(id[i]) * WEIGHTS[i]; if (CHECK_MAP[sum % 11] !== id[17]) return { ok: false, reason: 'checksum' }; return { ok: true, reason: null }; }这段代码里有两处值得专门说。第一处是日期判断用了new Date(y, m-1, d)然后回读三个字段比对。为什么不能直接new Date('1990-02-30')?因为 JavaScript 的日期解析会把溢出的日期静默滚动到下一个合法日期,2 月 30 日会被自动变成3 月 2 日,你不比对字段根本发现不了。这是我在项目里踩过的第一个坑。
第二处是new Date(y, m-1, d)这个三参数构造用的是本地时区,不受字符串解析的时区歧义影响,比较稳妥。如果传年份是 0 到 99 的值,JavaScript 会自动加 1900,不过我们的年份范围限制在 1800 以后,不会碰到这个问题。
Python 版本:
import re WEIGHTS = (7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2) CHECK_MAP = '10X98765432' SHAPE = re.compile( r'^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}' r'(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])' r'[0-9]{3}[0-9X]$' ) def validate_id(raw: str) -> bool: if not isinstance(raw, str): return False s = raw.strip().upper() if not SHAPE.match(s): return False import datetime y, m, d = int(s[6:10]), int(s[10:12]), int(s[12:14]) try: datetime.date(y, m, d) except ValueError: return False total = sum(int(s[i]) * WEIGHTS[i] for i in range(17)) return CHECK_MAP[total % 11] == s[17]Python 的datetime.date构造器会在日期不存在时抛ValueError,这一点比 JavaScript 靠谱,不用回读字段。但要注意strip()只能去掉常规空白,全角和零宽字符它不管,后面第 6 节会展开。
Java 版本:
private static final int[] W = {7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2}; private static final char[] C = {'1','0','X','9','8','7','6','5','4','3','2'}; public static boolean checkId(String raw) { if (raw == null) return false; String s = raw.trim().toUpperCase(); if (!s.matches("^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}" + "(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])" + "[0-9]{3}[0-9X]$")) { return false; } int sum = 0; for (int i = 0; i < 17; i++) sum += (s.charAt(i) - '0') * W[i]; return C[sum % 11] == s.charAt(17); }Java 里s.matches()自带首尾锚定,^和$其实可以省略,但我习惯写全,免得换到Pattern加Matcher.find()时忘记。另外 Java 的\d默认只匹配 ASCII 数字(除非开了 UNICODE_CHARACTER_CLASS 标志),这一点和 Python 相反,下面的坑里会细说。
5. 把校验串进表单链路的三道关卡
代码写对了只是第一步。真实项目里,用户提交的脏数据五花八门,全靠正则挡不住。我在实践中把防御分成三道关卡,每一道处理不同性质的问题。
5.1 输入阶段:全角、空格、零宽字符和粘贴
用户操作输入框的方式大致三种:手打、粘贴、扫码/自动填充。手打的主要问题是大小写和全角半角;粘贴的问题是有时从 Excel 或文档里带出看不见的字符。
全角字符:中文输入法下用户很容易打出全角数字0123和全角 XX。这些字符的 Unicode 码位和半角不在一起,正则匹配不上。处理办法是进校验前先做一次归一化,把全角字符映射回半角(Unicode 全角数字在FF10-FF19,全角 X 在FF38,全角 x 在FF58)。
零宽字符和特殊空白:从网页或 PDF 复制出来的字符串里可能夹着零宽空格\u200B、零宽非连接符\u200C、不间断空格\u00A0、字节序标记\uFEFF。这些字符肉眼完全看不见,但会让长度变成 19 或 20,正则直接判失败,而用户在输入框里怎么看都是对的——他被拦了也不知道为什么。这类问题排查起来极其费劲,我建议在校验入口统一做一次清洗:
const sanitize = (s) => s.replace(/[\u200B-\u200D\uFEFF\u00A0\u3000]/g, '') .replace(/[\uFF10-\uFF19]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)) .replace(/[\uFF38\uFF58]/g, 'X') .trim();顺序很重要:先去不可见字符,再处理全角,最后trim()。如果先trim()再去零宽字符,可能留下一个"去不干净"的尾巴,长度依然不对。
输入框层面的过滤:在移动端我更倾向于双向处理。一个是inputmode提示,让键盘弹出数字布局;另一个是onInput时实时把非[0-9Xx]的字符剔除,同时把 x 自动转大写。用户输入过程中就不会看到自己打了一堆非法字符,心理负担小很多。
注意:不要直接把用户输入的非法字符静默全部删掉后提交。如果用户粘贴了一整段带额外内容的文本,静默清洗会把错误藏起来。我的做法是清洗后如果长度变化过大(比如超过 3 个字符),给出提示让用户确认。
5.2 提交阶段:双端校验和错误分流
前端校验的价值在于即时反馈,后端校验的价值在于数据安全。这两者不是二选一的问题,是都要有。
前端的策略我一般设为"宽松但友好":跑 V3 骨架正则 + 日期判断,不跑校验位。原因很简单,校验位算错的情况下用户也没法自己修正——他不知道哪里错了。这时候前端拦住他反而制造困惑,不如放过去让后端判,返回一个明确的"号码校验不通过,请检查是否输错"。
后端的策略是"严格且留痕":骨架正则 + 日期 + 校验位,三道全过才入库。不通过时不要返回笼统的"参数错误",我在接口里会把失败原因用枚举返回:
| 错误码 | 触发条件 | 前端提示文案建议 |
|---|---|---|
ID_SHAPE | 字符集或长度不对 | 号码应为 18 位,末位可能是 X |
ID_DATE | 出生日期不存在 | 填写的出生日期不存在,请核对 |
ID_CHECKSUM | 校验位不匹配 | 号码校验未通过,请检查是否输错 |
ID_TYPE | 入参不是字符串 | 参数类型错误(内部排查用) |
把失败原因拆开的好处是:后端日志可以直接统计哪类错误最多,如果发现ID_CHECKSUM异常高,说明前端某个地方可能有问题,比如脱敏后又被提交回来了——这种情况我遇到过两次,都是表单状态没清干净导致的。
5.3 展示阶段:脱敏和日志处理
身份证号属于敏感信息,存进数据库、写进日志、返回给前端展示时都要脱敏。脱敏规则很简单,保留前 3 位和后 4 位,中间全部替换:
const maskId = (id) => id.slice(0, 3) + '*'.repeat(11) + id.slice(-4);18 位号码脱敏后是 3 + 11 + 4,长度保持一致,方便对齐。这里有两个细节要注意。
第一,脱敏后的字符串不能再拿去跑校验。它是 18 位没错,但中间全是星号,通不过正则。我在一个项目里就栽过这个跟头:列表页展示的是脱敏值,编辑弹窗直接把展示值塞进了表单初始值,用户点保存就报"格式错误"。后来改成编辑时单独请求明文(走权限),或者让用户重新填写完整号码。
第二,日志脱敏要做在写日志之前,不能依赖日志平台事后过滤。异常堆栈里经常会把整个入参对象打出来,如果入参里有明文号码,一样会泄漏。我的做法是在 DTO 层就做脱敏,或者在日志序列化器里统一拦截包含idCard的字段名。
6. 踩过的坑:\d 的陷阱、回溯爆炸和老号兼容
这一节是我这些年踩过的、并且觉得值得写下来的问题。它们都不是"正则写错了",而是"环境差异"和"边界情况"。
6.1\d在不同语言里根本不是一回事
这是我觉得最容易翻车的一个点。同样写^[1-9]\d{5},在三种语言里行为完全不同:
| 语言/环境 | \d的匹配范围 | 风险 |
|---|---|---|
| JavaScript(无 u 标志) | ASCII 0-9 | 安全 |
| Java(默认) | ASCII 0-9 | 安全 |
| Java(开了 UNICODE_CHARACTER_CLASS) | 所有 Unicode 数字 | 会放过阿拉伯-印度数字٠١٢٣٤٥ |
| Python 3 | 默认匹配 Unicode 数字(Nd类) | 会放过各类非 ASCII 数字 |
| .NET | 默认 Unicode | 有风险 |
| Go(regexp) | ASCII 0-9 | 安全 |
Python 3 的行为尤其需要注意:re.match(r'^\d{18}$', '١٢٣٤٥٦٧٨٩٠١٢٣٤٥٦٧٨')会返回匹配成功,因为 Python 的\d默认匹配 Unicode 的Nd类别。这意味着某些输入法或特殊来源的数字可以绕过校验进入系统。
解决方案很简单:所有数字位统一写成[0-9],不要用\d。多打四个字符,换来的是跨语言行为一致。我在团队规范里把这条写成了硬性要求,因为它在代码评审时极难肉眼发现。
6.2 长正则的回溯风险和性能问题
理论上我给出的那些正则都是线性的,没有嵌套量词,不存在灾难性回溯。但如果你自己改动过,比如为了"兼容中间允许有空格",写成了(?:[0-9]+\s*){17},那就很危险了:+套在{17}里面,遇到一长串非法输入时,引擎会尝试大量组合,耗时指数级增长。
我实际测过一次,一个^(?:[0-9]+\s*){17}$的正则,遇到 30 个字符的恶意输入,某些引擎会卡住好几秒。正常的 18 位校验不可能这么慢。
几个降低风险的做法:
- 不要在量化组里嵌套量化,
(?:[0-9]{1,2})+这种写法一定要避免 - 先做长度判断再做正则匹配,长度不对的直接返回,不给引擎回溯的机会
- 若确实需要处理带分隔符的输入,先做字符串清洗再用线性正则匹配
批处理场景还要注意正则对象的复用。JavaScript 里如果在循环中new RegExp(...),几十万次下来开销不小;用/.../字面量创建的会在编译期就完成,性能好很多。不过要注意带g标志的正则会保留lastIndex状态,循环里用test()会出现"跳过一条命中一条"的诡异现象,这是我早年调试了半天的经典问题。
6.3 15 位老号和边界情况
历史上存在过 15 位身份证号,结构是 6 位地址码 + 6 位出生日期(年份只有两位)+ 3 位顺序码,没有校验位。现在新发的号码都是 18 位,但老数据里确实会有。
我的建议是:不要在主校验函数里兼容 15 位。如果你的数据源里有历史数据,单独写一个迁移或者兼容函数,把 15 位按规则升位成 18 位(补19前缀和校验码)后再走统一校验。把两种格式混在一个正则里,可读性会崩溃,而且容易误判——15 位的[\dX]{15}和 18 位的规则完全不同,混着写几乎必然出错。
还有一个边界是年份范围。我见过有系统把年份写死成19\d{2}|20[0-1]\d,到 2022 年之后就再也注册不了了。这种"写死未来"的写法非常危险。正确做法是用(?:18|19|20)[0-9]{2}这种宽松范围,再用代码判断是否在合理区间内(比如不晚于今天,不早于 1900 年)。业务上的"年满 18 周岁"这类规则,也一定要用当前日期动态计算,绝不能在正则或常量里硬编码。
最后是一个关于顺序码的现实问题。第 17 位奇偶代表性别,但如果你在做数据统计时用它来判断,要注意跨世纪的老号和特殊情况下的编码规则可能有出入。统计用途下我一般只做参考,不做强断言。
7. 一份可以直接抄走的最终版实现
上面几节讲了很多点,这里给一个整合后的实现,把我认为最合理的做法固化下来。它的设计原则是:正则负责形状,代码负责语义,各司其职。
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const CHECK_MAP = '10X98765432'; // 只做形状校验:地址码首位非0、年份区间、月份、日期粗范围、末位 const SHAPE = /^[1-9][0-9]{5}(?:18|19|20)[0-9]{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])[0-9]{3}[0-9X]$/; function checkIdCard(raw, options = {}) { const { minYear = 1900, adultOnly = false } = options; if (typeof raw !== 'string') return { ok: false, reason: 'type' }; // 1. 清洗:不可见字符、全角、大小写 const s = raw .replace(/[\u200B-\u200D\uFEFF\u00A0\u3000]/g, '') .replace(/[\uFF10-\uFF19]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xFEE0)) .replace(/[\uFF38\uFF58]/g, 'X') .trim() .toUpperCase(); // 2. 形状 if (!SHAPE.test(s)) return { ok: false, reason: 'shape' }; // 3. 日期真实性 const y = Number(s.slice(6, 10)); const m = Number(s.slice(10, 12)); const d = Number(s.slice(12, 14)); const dt = new Date(y, m - 1, d); if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) { return { ok: false, reason: 'date' }; } // 4. 年份区间 const now = new Date(); if (y < minYear || dt > now) return { ok: false, reason: 'range' }; // 5. 成年判断(可选) if (adultOnly) { const adultLine = new Date(now.getFullYear() - 18, now.getMonth(), now.getDate()); if (dt > adultLine) return { ok: false, reason: 'adult' }; } // 6. 校验位 let sum = 0; for (let i = 0; i < 17; i++) sum += Number(s[i]) * WEIGHTS[i]; if (CHECK_MAP[sum % 11] !== s[17]) return { ok: false, reason: 'checksum' }; return { ok: true, reason: null, normalized: s }; }这个实现有几个我坚持的设计点。返回值用对象而不是布尔,是为了让调用方能区分失败原因;normalized字段返回清洗后的标准格式,让调用方统一存这一份,避免"同一用户存了两种格式"的脏数据问题;minYear和adultOnly做成参数,是因为不同业务对年龄的要求不一样,写死会限制复用。
用几组数据验一下,能覆盖绝大多数分支:
| 输入 | 预期结果 | 命中的原因 |
|---|---|---|
12345619900315123X | 通过 | 手算校验位为 X |
12345619900315123x | 通过(归一化后) | 小写 x 被转大写 |
123456199003151234 | 失败 | 校验位不匹配 |
123456199002301231 | 失败 | 2 月 30 日不存在 |
123456199002291234 | 视校验位而定 | 1990 不是闰年,日期层已失败 |
02345619900315123X | 失败 | 地址码首位为 0 |
1234561990031512 X | 需先清洗 | 含空格,长度不对 |
123456199003151230 | 需先清洗 | 末位全角数字 |
其中"1990 年 2 月 29 日"这条特别值得注意:1990 能被 4 整除吗?不能,1990 ÷ 4 = 497.5,所以 1990 年不是闰年,2 月 29 日不存在。这类判断如果只靠 V3 那种粗范围正则,是拦不住的,必须走代码的日期构造。
真正上线前我会拿一批脱敏后的历史数据跑一遍回归,重点看两件事:一是原本合法的号码有没有被新规则误杀(这是最严重的),二是历史脏数据里到底有多少能通过校验位。第二项数据往往能暴露出上游数据源的问题,我遇到过一次上游系统把校验位算错了整整两年,直到我们做全量复核才发现。
8. 几句个人体会
写校验逻辑这件事,我最大的感受是:能算的就别用正则算。正则擅长描述"形状",不擅长"推理"。把日期、年龄、校验位这些需要判断的东西塞进正则,短期看是省了一行代码,长期看是给自己挖了一个改不动的坑。
另外就是,做这类校验一定要留好失败原因。布尔值只能告诉你"不对",枚举原因才能告诉你"为什么不对"。数据质量的问题,往往就是从这些失败原因的分布里看出来的。
还有一个我一直在坚持的小习惯:在所有校验函数里,清洗和校验分成两步,清洗函数单独暴露出去。因为很多时候业务方需要的不是"判断对不对",而是"帮我把它变成标准格式"。比如批处理导入时,用户的 Excel 里可能混着全角、空格、小写 x,直接判失败会让整批数据被退回,先清洗再校验才是合理的处理顺序。这个拆分在后续接入任何新数据源时都会省下不少重复代码。