正则表达式系列总结:
前言
最近看到项目中,有些正则表达式中包含了一些没啥用的捕获组, 所以就想研究一下当其他条件相同时,使用捕获组和非捕获组在程序执行时有什么样的差距。
基本语法
JavaScript语法 MDN文档指路: Groups and Ranges
捕获组 Capturing group
正则表达式中可以用一对小括号(x)
来对文本进行分组和捕获,
捕获组的使用场景:
- 提取字符串
- 反向引用
- 使括号内的部分成为一个整体,规定多选结构或作用在量词上
一个简单的例子:
var r = /a(.*)e/
var s = 'abcde fghi'
console.log(s.match(r))
结果如图:
加上这对括号,可以提取出字母a和e之间的字符。
命名捕获 Named capture group
为捕获组命名,可以更方便地读取提取到的内容,javascript中可以使用(?<name>x)
的方式命名捕获组,
一个简单的例子:
假如要从字符串<div>hello regexp</div>
中提取出div
标签之间的内容,即hello regexp
,
可以使用表达式/<div>(?<message>.*)<\/div>/
,其中message就是为提取这部分内容取的分组名称。
var s = '<div>hello regexp</div>';
var r = /<div>(?<message>.*)<\/div>/
console.log(s.match(r))
结果如下图所示:
String的match()方法会返回一个匹配的结果数组:
- 数组下标为0的元素为根据正则表达式匹配到的完整字符
- 如果设置了捕获组,那么从下标1开始,往后的数组项为捕获组提取出的字符,左括号出现的顺序即为结果排列的顺序
- 如果还设置了捕获组的名字,那在结果数组的groups字段下,会按照分组名列出提取结果
反向引用 Back reference
使用捕获括号匹配到的内容,可以在程序中进行引用,分为以下几种场景:
- 在正则表达式中引用
- 在替换字符串时引用,如javascript中String的replace()方法的第二个参数中
- 通过Regexp对象来引用
引用方式:通过捕获组的序号,或根据捕获组的名称引用
在表达式中引用:
// 按照序号引用 使用\n形式 n为从左到右的捕获组序号
var r = /<(\w+)[^>]*>(.*?<\/\1>)?/g
// 按照分组名称引用 使用\k<name>
// var r = /<(?<tag>\w+)[^>]*>(.*?<\/\k<tag>>)?/g
var s = '<div>div content</div> some words <script src="..."></script>'
var s1 = s.replace(r, '') // 可以过滤html标签
上面的正则摘自在上一篇文章中提到的any-rule,用于宽松匹配HTML中的标签,
这个表达式中包含两对捕获括号:
- 第二对捕获括号中有一个
\1
,就是对第一个捕获组内容的引用,表示前后匹配成对的标签,第一对括号说我现在要匹配script
标签,那</\1>
组合起来就跟着匹配它对应的闭合部分</script>
- 第二对捕获括号的作用,是为了使它内部的字符成为一个整体,使后面的
?
量词能作用在这个整体上,表示在匹配HTML标签时,可以有闭合标签,也可以没有。
在替换字符时引用:
通过String的replace方法进行字符串替换时,如果replace()的第一个参数为正则表达式,第二个参数可以是一个字符串或一个函数。
当字符串作为第二个参数时(表示要替换成的文本),可以包含几种特殊变量:
- $n: 表示插入表达式中第n个捕获括号匹配的字符,没有捕获组时是空字符串,n的值为1-99,小于10的时候用01,02...也可以
- $: 表示插入表达式中指定名称的捕获组匹配的字符,没有指定捕获组名称时是空字符串
- $$: 单纯地替换为$符号
- $&: 匹配整个表达式的子字符串
- $`: 匹配的子字符串之后的子串
- $': 匹配的子字符串之前的子串
var r = /(?<text>hello regexp)/
var s = '<div>hello regexp<\/div>'
var s1 = s.replace(r, '<em>$<text></em>')
// "<div><em>hello regexp</em></div>"
上面例子使用捕获组名称进行引用和替换
通过Regexp对象来引用:
使用Regexp.$n
的形式也可以访问到捕获内容,例如:Regexp.$1
var r = /(hello regexp)/
var s = '<div>hello regexp<\/div>'
var s1 = s.match(r)
console.log(Regexp.$1) // "hello regexp"
非捕获组 Non-capturing group
非捕获括号(?:x)
不能用来提取文本,《精通正则表达式》书中说:
非捕获括号有助于提高效率,如果正则引擎不需要记录捕获型括号匹配的内容,速度会更快,所用的内存也更少。
var r = /<(\w+)[^>]*>(.*?<\/\1>)?/g
var s = '<div>div content</div> some words <script src="..."></script>'
var s1 = s.replace(r, '') // 可以过滤html标签
还是前面这个匹配HTML标签的例子,其实第二个分组就是不需要被反向引用的,
仅仅是为了能让括号内的部分作为被?
量词作用的整体,因此完全可以用非捕获括号代替,改写后如下:
var r = /<(\w+)[^>]*>(?:.*?<\/\1>)?/g
性能测试
总结了上述用法,其实最想验证的是捕获组和非捕获组在性能上的差异到底是怎样的,下面是我的一丢丢测试和总结。
Round 1
第一轮测试,我找了一段大小约63kb的文本,分别比较同一个正则表达式中含有3个非捕获组和3个捕获组的程序执行情况:
文本大小 | 捕获组个数 | 程序执行耗时 | 程序执行次数/秒 |
---|---|---|---|
63kb | 0 | 0.02ms左右 | 2.8万 ops/s 左右 |
63kb | 3 | 0.03ms左右 | 2.7万 ops/s 左右 |
大概尝试了10次左右,粗略肉眼估计了一下,没有计算精确值。
结论是:文本内容不算特别大时,捕获组和非捕获组在执行效率上差异不是很明显,直接执行了几次大多数情况相差0.01ms左右。
Round 2
第二轮测试,找了一段大小约297kb的字典数据,用正则表达式批量随便提取四个字段。
分别比较正则表达式中含有4个非捕获组、2个捕获组+2个非捕获组、4个捕获组的程序执行情况:
console.time('0 capture groups')
var r3 = /"code":(?:.*?),"name":"(?:.*?)","parentCode":(?:.*?),"shortName":(?:.*?)/g
var list3 = [...s.matchAll(r3)]
console.timeEnd('0 capture groups')
console.time('2 capture groups')
var r2 = /"code":(?<code>.*?),"name":"(?<name>.*?)","parentCode":(?:.*?),"shortName":(?:.*?)/g
var list2 = [...s.matchAll(r2)]
console.timeEnd('2 capture groups')
console.time('4 capture groups')
var r = /"code":(?<code>.*?),"name":"(?<name>.*?)","parentCode":(?<parentCode>.*?),"shortName":(?<shortName>.*?)/g
var list = [...s.matchAll(r)]
console.timeEnd('4 capture groups')
直接执行JS并打印了5组执行时间的统计:
文本大小 | 4个非捕获组 | 2捕获组 + 2非捕获组 | 4个命名捕获组 |
---|---|---|---|
297kb | 2.94ms | 4.76ms | 7.74ms |
297kb | 0.92ms | 1.81ms | 8.48ms |
297kb | 3.02ms | 3.64ms | 19.12ms |
297kb | 0.57ms | 2.61ms | 12.53ms |
297kb | 0.53ms | 4.73ms | 9.78ms |
使用JSBench进行的10次性能测试结果:
文本大小 | 4个非捕获组 | 2个捕获组+2非捕获组 | 4个命名捕获组 |
---|---|---|---|
297kb | 1398.44ops/s | 954.12ops/s (慢31.77%) | 796.08ops/s (慢43.07%) |
297kb | 1438.64ops/s | 946.43ops/s (慢34.33%) | 744.85ops/s (慢48.32%) |
297kb | 1457.66ops/s | 887.82ops/s (慢29.52%) | 777.46ops/s (慢38.28%) |
297kb | 1318.97ops/s | 930.34ops/s (慢29.46%) | 734.85ops/s (慢44.29%) |
297kb | 1468.66ops/s | 950.73ops/s (慢35.27%) | 749.94ops/s (慢48.94%) |
297kb | 1431.64ops/s | 959.95ops/s (慢32.95%) | 758.47ops/s (慢45.13%) |
297kb | 1355.06ops/s | 946.32ops/s (慢30.16%) | 752.23ops/s (慢44.49%) |
297kb | 1428.12ops/s | 925.76ops/s (慢35.18%) | 695.8ops/s (慢51.28%) |
结论:
综合两次简单的测试,结论就是:书上说得对!
虽然在文本不大的情况下,使用捕获组和非捕获组看起来在程序执行速度上差异不大, 但随着文本越来越大,表达式越来越复杂,使用越多的捕获组,程序执行速度越慢。
因此,在实际开发的时候,还是要明确正则表达式中的分组内容是否需要被捕获, 如果没有这个需要,那就直接用非捕获括号呗~
结束语
优化一小步,文明一大步,虽然正则表达式的优化重点是在减少回溯上,但我觉得正确使用捕获组和非捕获组也是有必要的。
回过头来看看自己曾经写过的正则表达式,关于捕获组和非捕获组的部分,是不是还有一些可优化的空间~
最后,如果文中有任何错漏之处,欢迎指正,鞠躬。