正则表达式基础:一个正则把商品导入页卡死之后,我把全项目的正则查了一遍
正则最危险的时候,不是写不出来,而是写得出来、跑起来、平时也没出事,直到某一份稍大一点的输入把页面直接卡死。那时你才会意识到,正则不只是“能不能匹配上”,还包括“会不会匹配得太慢”。
这次批量导入页的问题就死在这里。接口还没收到请求,页面已经在本地校验阶段转圈不动,说明瓶颈根本不在后端,而在那条看起来很普通的校验正则上。往下查,果然就是一次典型的灾难性回溯。
下面会从这条把页面卡死的正则往下拆,把锚点、量词、分组、贪婪匹配、test 和 match 这些基础概念放回真实的性能和正确性问题里讲。
第一步:让它当场再死一次
能稳定复现的 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 状态坑你;贪婪匹配默认尽量多吃,需要时用 ? 收敛;量词套量词先想回溯,拿长的、差一点才合法的输入试一把;手机号、邮箱这类规则贴合业务、宁宽勿误杀,精确判断交给后端。最后,与其抄一条看不懂的长正则,不如自己拆开写明白。