在 靜下心来–重温正则表达式(一)这篇文章中,我们重点介绍了正则表达式的一些基础概念,以及在 String、RegExp 的原型上涉及到正则表达式常用 4 个的方法( repalce、 match、test、exec),最后介绍了正则表达式的两种匹配方式:贪婪匹配、惰性匹配。
以上这些部分都是针对正则表达式的内容的匹配,并没有涉及到位置的匹配。在这篇文章中,重点介绍下针对位置的匹配:断言匹配,以及正则版本的匹配原理:回溯法,最后介绍一些正则表达式的优化。
1、零宽断言匹配
“零宽断言”听起来很古怪,满足:字你都认识,连起来就不明白是什么意思。
用于查找在某些内容(但并不包括这些内容)之前或之后的东西,也就是说它们像\b,^,$那样用于指定一个位置,这个位置应该满足一定的条件(即断言),因此它们也被称为零宽断言。
断言:可以理解为正则表达式可以指明在指定的内容的前面或后面会出现满足指定规则的内容。
零宽:就是没有宽度,在正则表达式中,断言只是匹配位置,不占字符,也就是说,匹配结果里是不会返回断言本身。
看到这里,有没有这种感觉:断言就是在正则表达式中写 if 判断条件。 由于可以匹配位置的前后,满足匹配内容和不满足匹配内容,交叉组合后共有四种断言。
名称 | 表达式 | 最终结果与位置关系 | 是否满足匹配内容pattern | 兼容性 |
---|---|---|---|---|
正向先行断言 | (?=pattern) | 在断言位置前 | 是 | - |
负向先行断言 | (?!pattern) | 在断言位置前 | 否 | - |
正向后行断言 | (?<=pattern) | 在断言位置后 | 是 | IE、Safari不支持 |
负向后行断言 | (?<!pattern) | 在断言位置后 | 非 | IE、Safari不支持 |
注意:
- 需要重点注意,后行断言存在浏览器兼容性问题,具体可以查阅 RegExp 兼容性
- 零宽断言只作为条件匹配位置,最终的匹配结果中并不返回零宽断言匹配的内容==
1.1、 正向先行断言 (?=pattern)
正向先行断言,英文名称为 positive lookahead。其具体形式如下:
/pattern1(?=pattern2)/
其中 pattern1、 pattern2 都是由元字符、限定符、修饰符、原子组等组成的正则表达式。 pattern1 : 匹配内容、 pattern2 : 匹配位置
通俗的将,正向先行断言:首先满足 pattern2,并获取匹配位置,在此位置之前匹配 pattern1,最终结果返回 pattern1 匹配的内容。
const str = 'abc123abc12345abc1234'
const regx = /abc/g
let result = null
while((result = regx.exec(str)) !== null ){
console.log(result)
}
// ['abc', index: 0, input: 'abc123abc12345abc1234', groups: undefined]
// ['abc', index: 6, input: 'abc123abc12345abc1234', groups: undefined]
// ['abc', index: 14, input: 'abc123abc12345abc1234', groups: undefined]
const regx = /abc(?=12345)/g
let result = null
while((result = regx.exec(str)) !== null ){
console.log(result)
}
// ['abc', index: 6, input: 'abc123abc12345abc1234', groups: undefined]
如果没有增加零宽断言,则在上例中,就会有 3 个 abc 的字符串被匹配成功。加上零宽断言 (?=12345),意味着只能匹配 12345 前边的 abc。上例中,最终返回的结果只有 abc (零宽断言不作为内容返回),匹配的位置 index 是 6, 也说明只有第二个 abc 满足零宽断言的条件。
1.2、 负向先行断言 (?!pattern)
负向先行断言,英文名称为 negative lookahead 。其具体形式如下:
/pattern1(?!pattern2)/
其中 pattern1、 pattern2 都是由元字符、限定符、修饰符、原子组等组成的正则表达式。 pattern1 : 匹配内容、 pattern2 : 匹配位置
正向先行断言是寻找满足 pattern2 的位置,而负向先行断言则相反,寻找的是不满足 pattern 的位置。这是二者唯一的区别。
const str = 'abc123abc12345abc1234'
const regx = /abc(?!12345)/g
let result = null
while((result = regx.exec(str)) !== null ){
console.log(result)
}
// ['abc', index: 0, input: 'abc123abc12345abc1234', groups: undefined]
// ['abc', index: 14, input: 'abc123abc12345abc1234', groups: undefined]
还是相同的例子,如果改为负向先行断言的话,则匹配的结果是:不是 12345 前边的 abc(同样零宽断言不作为内容返回)。
1.3、 正向后行断言 (?<=pattern)
上边讨论的都是先行断言,也就是匹配条件之前的内容。接下来 2 部分来讨论下后行断言,后行断言则匹配的是条件之后的内容。先来看看正向后行断言。
/(?<=pattern2)pattern1/
其中 pattern1、 pattern2 都是由元字符、限定符、修饰符、原子组等组成的正则表达式。 pattern1 : 匹配内容、 pattern2 : 匹配位置
正向后行断言:首先满足 pattern2,并获取匹配位置,在此位置之后匹配 pattern1,最终结果返回 pattern1 匹配的内容。
const str = 'abc123abc12345abc1234'
const regx = /(?<=12345)abc/g
let result = null
while((result = regx.exec(str)) !== null ){
console.log(result)
}
// ['abc', index: 14, input: 'abc123abc12345abc1234', groups: undefined]
还是上边的例子,这次返回的结果是匹配:12345 后边的 abc, 最终返回的是最后一个 abc (同样零宽断言不作为内容返回)
1.4、 负向后行断言 (?<!pattern)
了解到正向后行断言之后,就能很好的理解负向后行断言。
/(?<!pattern2)pattern1/
其中 pattern1、 pattern2 都是由元字符、限定符、修饰符、原子组等组成的正则表达式。 pattern1 : 匹配内容、 pattern2 : 匹配位置
负向后行断言:首先不满足 pattern2,并获取匹配位置,在此位置之后匹配 pattern1,最终结果返回 pattern1 匹配的内容。
const str = 'abc123abc12345abc1234'
const regx = /(?<!12345)abc/g
let result = null
while((result = regx.exec(str)) !== null ){
console.log(result)
}
// ['abc', index: 0, input: 'abc123abc12345abc1234', groups: undefined]
// ['abc', index: 6, input: 'abc123abc12345abc1234', groups: undefined]
同样,还是上边的例子,这次返回的结果是匹配:不是12345 后边的 abc, 最终返回的是前 2 个 abc (同样零宽断言不作为内容返回)。其中第一个 abc 前边没有内容同样满足不是 12345 这个条件。
1.5 小结
零宽断言就是位置和条件的组合:
- 先行断言(?= 、?!)匹配条件之前的内容,后行断言(?<=、 ?<!)匹配条件之后的内容。
- 正向断言(?= 、?<=)匹配的是满足的条件的情况,负向断言(?!、?<!)匹配的时候不满足条件的情况。
另外有2点需要注意:
- 零宽断言仅做位置匹配,不做为内容返回;
- 后行断言存在浏览器兼容性问题。
2、匹配原理 - 回溯法
回溯法(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
2.1、贪婪匹配的回溯
在上一章节中介绍过,正则表达式默认时贪婪匹配,会尽可能多的匹配,这可能出现尽可能多的匹配的时候,会导致后续的匹配无法完成,就需要通过回溯来解决。
const str = '"abc"de'
const regx = /".*"/
【注】:图片来源于JavaScript正则表达式迷你书
在正则表达式中的 . 匹配除换行符以外的任意字符,* 匹配任意次数,贪婪模式下 .* 组合起来会一直匹配到文本的行末尾。如图所示在第 1 步 匹配到 " 之后,从第 2 步到第 8 步,一直匹配到文本结尾,但此时正则表达式无法匹配成功,因此回溯到上一节点, 也就是第 9 步回到第 7 步,发现还是不能匹配成功,继续回溯,直到第 11 步 回溯到第 5 步的状态,然后继续匹配 ", 最终匹配成功。 具体的匹配过程也是查看:https://regex101.com/r/XuBOzX/1。
有上例可知,由于 .* 导致了贪婪匹配,使得正则表达式发生了多次回溯,会非常影响正则表达式的匹配效率。
2.2、惰性匹配的回溯
大部分的回溯是由贪婪匹配带来,但也并不是说惰性匹配不会带来回溯。在上一文章中,介绍惰性匹配的实例如下:
const string = "12345";
const regex = /^(\d{1,3}?)(\d{1,3})$/;
// => ['12345', '12', '345', index: 0, input: '12345', groups: undefined]
//知道你不贪、很知足,但是为了整体匹配成,没办法,也只能给你多塞点了。因此最后 \d{1,3}? 匹配的字
//符是 "12",是两个数字,而不是一个。
【注】:图片来源于JavaScript正则表达式迷你书
首先匹配开始位置(^),由于第一个原子组是惰性匹配,因此只匹配了一个数字,紧接着第二个原子组贪婪匹配了3个数字,此时正则表达式匹配结束位置,数字 5 无法满足。因为第二个原子已经匹配到最多内容,都无法满足正则表达式匹配成功,因此需要回溯第一个原子组,为了满足正则表达式匹配成功,因此只能让第一个原子组多匹配一些内容。具体的匹配过程可以参考: https://regex101.com/r/erceTr/1。
2.3、分支的回溯
分支也是惰性匹配的,比如 /can|candy/,去匹配字符串 “candy”,得到的结果是 “can”,因为分支会一个一个尝试,如果前面的满足了,后面就不会再试验了。分支结构,可能前面的子模式会形成了局部匹配,如果接下来表达式整体不匹配时,仍会继续尝试剩下的分支。这种尝试也可以看成一种回溯。看如下实例:
const string = "candy";
const regex = /^(?:can|candy)$/; // candy
【注】:图片来源于JavaScript正则表达式迷你书
其匹配过程如下,由于第一个分支,只能局部匹配 can ,但不能满足正则表达式匹配成功(因为还需要满足匹配起始位置和结束位置),于是匹配过程就回溯到第二个分支,具体的匹配过程可以查看: https://regex101.com/r/flTRP5/1。
3、正则表达式的优化
日常开发工作中,有2个需要在编码过程中需要考虑的。首先是准确性,也就是能够实现预期的功能;其次就要考虑一下代码执行的效率。正则表达式的编写也同样要考虑这 2 个问题,关于准确性掌握好前边所述内容,基本问题不大,此处不再赘叙。
由于正则表达式通过回溯匹配会引发一些效率问题,因此并不是所有的问题都必须用正则表达式去处理,能够是使用字符串 API 快速解决的问题,就不该正则出马。如果必须使用正则表达式来完成,那么接下来的几点优化建议,或许能排上用场
3.1、常见的优化方法
- 使用具体型字符组来代替通配符,来消除回溯
在 2.1章节(贪婪匹配的回溯)中由于使用了 .* , 使得正则表达式在匹配过程中发生了多次回溯。如果能够改用正则表达式:/“[^”]*"/,就能够消除不必要的回溯。
- 使用非捕获型分组
在前边的章节中多次提到了原子组,可以用于正则表达式在匹配的过程中捕获子表达式的匹配结果,同时通过反向引用可以复用之前的原子组,原子组捕获的数据需要占用内存来保存它们。实际过程中,编写正则表达式时,有时候添加括号并不是为了需要捕获原子组内容,而是为了可读性,此时可以通过: (?:pattern) 的方法来取消原子组的捕获,具体实例可以参考前边章节。
- 提取分支公共部分
在 2.3章节(分支的回溯)中,由于前边分支的局部匹配,使得整个匹配过程回溯到第二个分支,而第二个分支中含有第一个分支的局部内容,这部分内容在第二个分支中需要重新匹配一次,因此可以提取分支公共部分。如上例可以修改为:
/^can(?:dy)?$/
。 又如 /http|https/
可以修改为 /https?/
, /red|read/
可以修改成 /rea?d/
。
- 出现可能性大的放左边
由于正则是从左到右匹配的,把出现概率大的放左边,域名中 .com 的使用是比 .net 多的,所以我们可以写成 .(?:com|net)
,而不是 .(?:net|com)
。
3.2、测试工具
最后给大家推荐 2 个好用的正则表达式网站
第一个是上文中有用到: https://regex101.com/,可以测试正则表达式的匹配性能,匹配结果,以及调试正则表达式的匹配过程。
第二个是: https://regexper.com/,可以通过可视化的方式快速展示你的正则表达式。
4、后记
通过 2 篇文章,我们重温了正则表达式的知识,包括正则表达式的基础概念、匹配方式、断言匹配、匹配原理以及部分优化策略。不同语言使用的正则引擎可能会不同,这个也会带来匹配效率、性能上差异,主要有 DFA (确定型有限自动机)和 NFA (非确定型有限自动机),有兴趣的同学可以自行研究下。也希望这 2 篇文章能够对大家有所帮助,如有不正之处,敬请指正。
参考文献:
- https://github.com/qdlaoyao/js-regex-mini-book/blob/master/JavaScript正则表达式迷你书(1.1版).pdf
- https://juejin.cn/post/6844903677119954958#heading-1
- https://juejin.cn/post/6844903680349585422#heading-0
- https://juejin.cn/post/7021672733213720613#heading-23
- https://www.bilibili.com/video/BV12J41147fC
- https://blog.csdn.net/ybdesire/article/details/78255427
- https://www.jianshu.com/p/fb3afbf8da10