正则表达式基础:一个正则把商品导入页卡死之后,我把全项目的正则查了一遍

正则最危险的时候,不是写不出来,而是写得出来、跑起来、平时也没出事,直到某一份稍大一点的输入把页面直接卡死。那时你才会意识到,正则不只是“能不能匹配上”,还包括“会不会匹配得太慢”。

这次批量导入页的问题就死在这里。接口还没收到请求,页面已经在本地校验阶段转圈不动,说明瓶颈根本不在后端,而在那条看起来很普通的校验正则上。往下查,果然就是一次典型的灾难性回溯。

下面会从这条把页面卡死的正则往下拆,把锚点、量词、分组、贪婪匹配、testmatch 这些基础概念放回真实的性能和正确性问题里讲。

第一步:让它当场再死一次

能稳定复现的 bug 是好 bug。我把提交这份数据的运营同事叫来要了原始文件,本地起服务,DevTools 的 Performance 面板先录上,再粘贴、点校验。页面照例卡死,录制里主线程上是一整块几十秒的长任务,火焰图里从头到尾就压着一个函数:我们表单里的 validateCodes

点进去,一行:

1var codeRe = /^(\d+,?)+$/
2
3function validateCodes(text) {
4  return codeRe.test(text.trim())
5}

就这一行 test,跑了几十秒还没跑完。我盯着这条正则看了半天没看出毛病:数字、可选的逗号、重复多次,整串匹配,逻辑上没错啊。

后来我把这份数据拉到编辑器里逐段二分,才发现玄机:那串编码的最后一个字符是个从 Excel 带出来的全角空格。也就是说,这条正则面对的是一个"差一点就能匹配上"的两千多字符长串。匹配得上的时候它飞快,匹配不上的时候它就疯了。

灾难性回溯

正好十月份的周末笔记刚啃过正则引擎那章,这回算是正面撞上了。

JS 的正则引擎是回溯式的:走到某条路走不通,就退回来换一条继续试。/^(\d+,?)+$/ 的问题在于嵌套量词——里层 \d+ 是"一个或多个",外层 (...)+ 又是"一个或多个"。同一串数字 123,可以被拆成 (123),也可以是 (1)(23)(12)(3)(1)(2)(3)……当整体匹配失败、引擎要确认"真的无路可走"时,它会把这些拆法全部试一遍。拆法的数量随字符串长度指数增长,两千个字符,宇宙毁灭它都试不完。

这就是灾难性回溯(catastrophic backtracking)。最阴的地方在于:它平时完全无症状。输入短、或者输入合法能一次匹配成功,都快得很;只有"很长、又差一点点才合法"的输入才会引爆,所以测试环境永远发现不了,非得等运营从 Excel 里粘出一个带全角空格的长串。

修法是把歧义消掉,让每一段数字只有一种拆法:

1// 改写前:/^(\d+,?)+$/  ← \d+ 和外层 + 对同一段数字有 N 种拆法
2// 改写后:一个数字段开头,后面若干个「逗号+数字段」,无歧义
3var codeRe = /^\d+(,\d+)*$/

改完之后同一份数据,test 微秒级返回 false,再把首尾的空白 trim 干净、给出"第 2048 个编码附近有非法字符"的提示,这个问题就算处理完了。写正则时只要看到量词套量词,就该停下来想想回溯——这是我这次记进笔记本第一页的话。

全项目正则大走查

问题修完我没收手。既然这么基础的一条正则都能埋雷,项目里其他正则什么水平?我 grep 了一把 new RegExp 和斜杠字面量,扫出来几十条,然后看到了更让我沉默的东西:光是手机号校验就有三套不同的正则,散在三个文件里——登录页一套、注册页一套、个人资料页又一套。同一个号码在登录页能过、在改资料页死活通不过,客服工单里真有这条投诉。翻 git 记录,每一套都是不同的人在不同时间从某个论坛或某篇博客复制来的,没人能讲清那串字符到底匹配了什么。

还有一条号称符合 RFC 5322 的邮箱正则,四百多个字符,它拦不住用户把公司内网域名写错,倒是把合法的 plus 别名地址(比如 [email protected])给误杀了。能讲清楚业务规则的简短表达式,几乎永远比网上抄来的万能正则更值钱。

于是我干脆把这次走查整理成一份组内的正则笔记,按我日常用的顺序把基础捋一遍,再把真实踩过的坑都摆出来。以下就是那份笔记。

先搞清楚两个 API:test 和 match

写校验之前得先知道工具。JS 里正则最常用的两个入口:

1// RegExp.prototype.test:返回布尔值,校验场景几乎只用它
2/^\d{6}$/.test('123456') // true
3
4// String.prototype.match:返回匹配结果数组,提取信息时用
5'order-2019-1024'.match(/(\d{4})-(\d+)/)
6// ['2019-1024', '2019', '1024', index: 6, ...]

校验表单我基本只用 test,最轻、语义最直接。几个能直接贴控制台跑的例子:

1/\d+/.test('abc123')             // true,字符串里有数字就匹配
2'a1b2c3'.match(/\d/g)            // ['1', '2', '3'],加 g 拿到全部数字
3/^\d{3}-\d{4}$/.test('123-4567') // true,整串就是「3 位-4 位」

需要把匹配到的内容抠出来(比如从一串文本里提取年份、金额)才用 match

走查里我还真逮到一个现行坑:g 标志的正则,test 是有状态的

1var re = /\d+/g
2re.test('a1') // true
3re.test('a1') // false ←同样的输入,结果却不一样!

原因是 g 模式下 lastIndex 会被记住,下次从上次结束的位置继续找。我们有个模块把一条带 g 的正则做成模块级常量到处复用,校验结果时好时坏,之前一直被当成"玄学"。做校验的正则千万别加 g,要么就每次新建。

基础字符

最朴素的就是字面量:

1/abc/.test('xxabcxx') // true,只要"包含"abc就算匹配

注意它是「包含」匹配,不是「等于」,这点后面会反复坑人。

几个预定义字符类是高频选手:

1/\d/  // 一个数字,等价于 [0-9]
2/\D/  // 一个非数字
3/\w/  // 一个单词字符,等价于 [A-Za-z0-9_],注意带下划线
4/\W/  // 一个非单词字符
5/\s/  // 一个空白(空格、Tab、换行等)
6/\S/  // 一个非空白
7/./   // 除换行外的任意字符

\w 这个我去年就吃过亏:它包含下划线,但不包含中文。做「只允许中英文用户名」的校验时顺手写了 \w,测试的中文名全被拒。中文得用 Unicode 范围:

1// 中文常用字大致范围(\p{} 属性转义浏览器支持还不齐,用码点区间最稳)
2/^[-]+$/.test('张三') // true

顺带说一句,ES2018 是给了 \p{Script=Han} 这种属性转义的(要配 u 标志),Chrome 里能跑,但我们要兼容的浏览器面还不敢用,笔记里记一笔,线上继续码点区间。

需要自定义集合就用方括号字符组:

1/[abc]/        // a 或 b 或 c
2/[a-z]/        // 小写字母
3/[^0-9]/       // 取反,非数字
4/[-A-Za-z]/ // 中文或英文字母

[^...] 里的 ^ 是「取反」,和放在正则开头表示「行首」是两回事,别搞混。

锚点:^ 和 $ 决定了你到底在校验什么

这是表单校验里最容易翻车的地方,走查里中招最多的也是它,单独拎出来讲。

1/^abc$/.test('abc')    // true,整串就是 abc
2/^abc$/.test('xabcx')  // false

^ 匹配字符串开头,$ 匹配结尾。校验场景下不加它俩,等于没校验

1// 反例:想校验"6位数字",但忘了锚点
2/\d{6}/.test('abc123456def') // true ←里面有6位数字就过了,根本没拦住
3
4// 正确:必须整串都是 6 位数字
5/^\d{6}$/.test('abc123456def') // false
6/^\d{6}$/.test('123456')       // true

那三套手机号正则里就有一套漏了 $,用户输入 13800138000abc 居然能提交,后端入库后短信发不出去,后端同事排查到一半跑过来问我"你们前端到底校没校验"。只要是「整串必须符合某格式」的校验,第一反应就该是 ^...$ 包起来。

量词:控制次数

1*       0 次或多次
2+       1 次或多次
3?       0 次或 1 次
4{n}     恰好 n 次
5{n,}    至少 n 次
6{n,m}   n 到 m 次

举几个贴业务的例子:

1// 手机号粗校验:1 开头,后面 10 位数字,共 11 位
2/^1\d{10}$/.test('13800138000') // true
3
4// 密码:8~20 位,只允许字母数字和常见符号
5/^[A-Za-z0-9!@#$%^&*]{8,20}$/.test('Abc12345') // true
6
7// 可选的 http/https 前缀
8/^(https?:\/\/)?\w+/.test('example.com') // true,? 让 s 和整个协议头都可选

关于手机号我在组里分享时特意多说了一句。/^1\d{10}$/ 现在够用了,但我不建议把号段写死/^1[34578]\d{9}$/ 这种。运营商号段隔一阵就放新的,166、198、199 都是这两年才陆续出现的,你今天写死的号段,过半年就会误杀真实用户——我们客服真收到过用 199 新号注册不了的投诉。前端只要拦住「明显不是手机号」的输入(长度、纯数字、1 开头)就行,精确性交给后端和短信网关。这是体验和准确之间的取舍——前端宁可放宽,也别误杀

回溯的教训在量词这里也要回响一下:量词本身无罪,量词套量词、且里外能匹配同一段字符才危险。/^\d+(,\d+)*$/ 里外层 * 套着 \d+,但每一段必须由逗号领头,拆法唯一,就安全。

分组与选择

圆括号 () 把若干字符组成一个整体,| 表示「或」:

1/^(jpg|png|gif)$/.test('png') // true
2
3// 文件后缀校验,结尾匹配 + 忽略大小写
4/\.(jpg|png|gif|webp)$/i.test('photo.PNG') // true

i 是忽略大小写的标志。分组除了能配合 |,还能给量词「打包」:

1// 校验形如 2019-12-01 的日期串
2/^\d{4}(-\d{2}){2}$/.test('2019-12-01') // true
3// (-\d{2}) 被当成整体重复 2 次

分组还能用来提取内容,这在 match/replace 里很有用:

1// 把 13800138000 脱敏成 138****8000
2'13800138000'.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
3// '138****8000'

$1$2 对应第一、第二个分组捕获到的内容。这个脱敏小技巧我在好几个页面里都用上了,比手动 substring 拼接清爽多了。换日期格式也是同一招——用 $3/$2/$1 把年月日重排:

1'2019-01-02'.replace(/(\d+)-(\d+)-(\d+)/, '$3/$2/$1')
2// '02/01/2019'

如果只想分组、不想捕获(省点性能、也让 $1 编号不乱),用非捕获分组 (?:...)

1/^(?:https?:\/\/)?(\w+)/.exec('https://abc')[1] // 'abc'
2// 协议头用 (?:) 不占编号,所以 [1] 直接是域名

ES2018 还给了具名分组 (?<year>\d{4}),配 groups.year 取值,可读性好得多。我在 Node 12 的构建脚本里已经用上了,很舒服;但浏览器端 Firefox 还不认,业务代码里暂时忍着编号。

贪婪与非贪婪:走查里的另一桩旧案

默认情况下 *+{n,} 都是贪婪的,会尽量多匹配。

1var text = '<div>hello</div><div>world</div>'
2text.match(/<div>.*<\/div>/)[0]
3// '<div>hello</div><div>world</div>' ←一口吞到最后一个 </div>

加个 ? 变成非贪婪,尽量少匹配:

1text.match(/<div>.*?<\/div>/)[0]
2// '<div>hello</div>' ←这才是想要的

最小的对比例子是这个,一眼看清贪婪和非贪婪的差别:

1'<a><b>'.match(/<.+>/)[0]  // '<a><b>' ←贪婪,.+ 一路吃到最后一个 >
2'<a><b>'.match(/<.+?>/)[0] // '<a>'   ←非贪婪,遇到第一个 > 就收

项目里有个简易模板替换,要把 {{name}} 这种占位符全部替换掉,最初的版本写的是 /\{\{.*\}\}/g,一行里有两个占位符时,它把 {{name}} 和 {{age}} 中间那段普通文字也一起吃进去替换没了。改成非贪婪 /\{\{.*?\}\}/g 才正常:

1'你好 {{name}},今年 {{age}} 岁'
2  .replace(/\{\{.*?\}\}/g, '?')
3// '你好 ?,今年 ? 岁'

但更要紧的教训是:别用正则去解析结构化的 HTML/XML。HTML 可以嵌套、可以有属性、可以不闭合,正则这种「线性扫描」的工具根本表达不了嵌套结构。能拿到 DOM 就用 document.querySelector,要解析字符串就上 DOMParser

1var doc = new DOMParser().parseFromString(text, 'text/html')
2doc.querySelectorAll('div') // 老老实实拿到两个 div

走查时我把仅剩的一处「正则解析 HTML」改成了 DOMParser,一类反复出现的诡异 bug 直接消失。

字面量还是 new RegExp:转义要转两次

走查里还揪出一个隐蔽错误,值得单列。动态拼正则要用 new RegExp,但它接的是字符串,反斜杠得先过一遍字符串转义:

1/\d+/.test('42')              // true,字面量,\d 就是 \d
2new RegExp('\d+').test('42')  // true,但其实匹配的是字母 d!'\d' 在字符串里就是 'd'
3new RegExp('\\d+').test('42') // true,这才是真的 \d

同事那段代码用 new RegExp('\d{6}') 校验验证码,实际在匹配「字母 d 重复 6 次」,纯属巧合地一直没出事。另外,把用户输入拼进正则前必须把特殊字符全部转义,不然用户输个 ( 就让你抛异常:

1function escapeRegExp(str) {
2  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3}
4new RegExp(escapeRegExp(keyword), 'i') // 用于列表搜索高亮,安全

能写字面量就写字面量,非动态不用 new RegExp

邮箱校验:别太执着

网上那些动辄几百字符的「完美邮箱正则」,绝大多数项目根本用不上。前端做邮箱校验,目的是当场提示用户「你这格式不对」,而不是替邮件服务器决定地址是否真实存在。我现在固定用这一条:

1/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test('[email protected]') // true

它的含义很朴素:@ 前后都得有内容、不含空格和第二个 @,且 @ 后面得有个带 . 的域名。够拦住手滑打错的,也不会误杀奇怪但合法的地址。那条四百字符的 RFC 5322 正则,我在这次走查里光荣地删掉了。

地址到底能不能收到邮件,只有发一封验证邮件才知道。前端校验是体验,不是安全边界——这句话我想刻在每个表单上。任何前端正则都能被绕过(直接调接口就行),所以后端必须做同样甚至更严的校验,这一点我和后端同事对过,批量导入接口那边也补上了。

收尾:一个集中管理的校验工具

走查的最终产出是把散落的规则收进一处,再也不要三个文件三套正则:

1var rules = {
2  mobile: /^1\d{10}$/,
3  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
4  // 6 位纯数字验证码
5  code: /^\d{6}$/,
6  // 中英文用户名,2~16 位
7  username: /^[一-龥A-Za-z]{2,16}$/,
8  // 金额:最多两位小数,不允许前导 0(除了 0.x)
9  money: /^(0|[1-9]\d*)(\.\d{1,2})?$/,
10  // 逗号分隔的商品编码,本案主角,无歧义写法
11  codeList: /^\d+(,\d+)*$/
12}
13
14function validate(type, value) {
15  var re = rules[type]
16  if (!re) throw new Error('unknown rule: ' + type)
17  return re.test(String(value).trim())
18}
19
20validate('mobile', '13800138000') // true
21validate('money', '0.5')          // true
22validate('money', '01.5')         // false,前导 0 被拦

集中到一处之后,规则要改只改一个地方,多个页面行为天然一致。每条正则上面留一行注释讲清它在匹配什么——你能读懂的正则,才是你能维护的正则

那位运营同事后来又粘了一次那份两千多编码的数据,校验瞬间给出提示,指出末尾那个全角空格的位置。她说"这不挺快的吗",我笑笑没解释背后那个指数爆炸的故事。

最后沉淀几条:正则适合做「线性的字符串模式匹配」,表达不了嵌套结构,也不该被当成业务安全的最后一道防线;校验类正则务必想清楚要不要 ^...$ 整串匹配;做校验别加 g 标志,免得 lastIndex 状态坑你;贪婪匹配默认尽量多吃,需要时用 ? 收敛;量词套量词先想回溯,拿长的、差一点才合法的输入试一把;手机号、邮箱这类规则贴合业务、宁宽勿误杀,精确判断交给后端。最后,与其抄一条看不懂的长正则,不如自己拆开写明白。