原文地址: 又双叒叕学习了一遍正则表达式
前两天在 Twitter 上看到了题图,感觉又是个大坑,在此介绍正则本身和在 JavaScript 中使用正则的坑。如有错误,烦请指正。
首先说说 JavaScript 中正则的坑。
字面量 VS RegExp()
在 JavaScript 中创建正则表达式有两种方式:
// 正则字面量
var pattern1 = /\d+/;
// 构造 RegExp 实例,以字符串形式传入正则
var pattern2 = new RegExp('\\d+');
复制代码
两种方式创建出的正则没有任何差别。从创建方式上看,正则字面量可读性更优,因为正则中经常使用 \
反斜杠在字符串中是一个转义字符,想以字符串中表示反斜杠的话,需要使用 \\
两个反斜杠。
但是,需要注意,每个正则表达式都有一个独立的对象表示,每次创建正则表达式,都会为其创建一个新的正则表达式对象,这和其它类型(字符串、数组)不同。
我们可以通过让正则表达式只编译一次并将其保存在一个变量中以供后续使用来实现优化。
因此,第一段代码将创建三个正则表达式对象,并进行了三次编译,虽然表达式是相同的。而第二段代码则性能更高。
console.log(/abc/.test('a'));
console.log(/abc/.test('ab'));
console.log(/abc/.test('abc'));
var pattern = /abc/;
console.log(pattern.test('a'));
console.log(pattern.test('ab'));
console.log(pattern.test('abc'));
复制代码
这其中有性能隐患。先记住这一点,我们继续往下看。
冷知识 lastIndex
这里我们来解释下题图中的情况是怎么回事。
这其实是全局匹配的坑,也就是正则后的 /g
符号。
var pattern = /abc/g;
console.log(pattern.global) // true
复制代码
用 /g
标识的正则作为全局匹配,也就拥有了 global 属性并导致了题图中呈现的异常行为。
全局正则表达式的另一个属性 lastIndex 用于存放上一次匹配文本之后的第一个字符的位置。
RegExp.prototype.exec()
和 RegExp.prototype.test()
方法都以 lastIndex
属性中所存储的位置作为下次正则匹配检索的起点。连续调用这两个方法就可以遍历字符串中的所有匹配文本。
lastIndex
属性可读写,当 RegExp.prototype.exec()
或 RegExp.prototype.test()
再也找不到可以匹配的文本时,会自动把 lastIndex 属性重置为 0。因此使用这两个方法来检索文本,是可以无限执行下去的。我们也就明白了题图中为何每次执行 RegExp.prototype.test()
返回的结果都不一样。
不仅如此,看看下面这段代码,能看出来有什么问题吗?
var count = 0;
while (/a/g.test('ababc')) count++;
复制代码
不要轻易拷贝到控制台中尝试,会把浏览器卡死的。
由于每个循环中 /a/g.test('ababc')
都创建了新的正则表达式对象,每次匹配都是重新开始,这一操作会无限执行下去,形成死循环。
正确的写法是:
var count = 0;
var regex = /a/g;
while (regex.test('ababc')) count++;
复制代码
这样,每次循环中操作的都是同一个正则表达式对象,随着每次匹配后 lastIndex
的增加,等到将整个字符串匹配完成后,就跳出循环了。
给以上知识点画个重点:
- 将正则表达式保存到变量中,只在逻辑中使用这个变量,不仅性能更高,还安全。
- 谨慎使用全局匹配,
RegExp.prototype.exec()
或RegExp.prototype.test()
这两个方法的执行结果可能每次都不同。 - 做到了以上两点后,还要谨慎在循环中使用正则匹配。
回溯陷阱 Catastrophic Backtracking
回溯陷阱是正则表达式本身的一个坑了,会导致非常严重的性能问题,事故现场可以参看《一个正则表达式引发的血案,让线上 CPU100% 异常!》。
简单介绍一下回溯陷阱的问题源头,正则引擎分为 NFA(确定型有穷自动机)
和 DFA(不确定型有穷自动机)
,DFA
是从匹配文本入手,同一个字符不会匹配两次(可以理解为手里捏着文本,挨个字符拿去匹配正则),时间复杂度是线性的,它的功能有限,不支持回溯。大多数编程语言选用的都是 NFA
,相当于手里拿着正则表达式,去匹配文本。
/(a(bdc|cbd|bcd)/
中已经有三种匹配路径,在 NFA
中,以文本 'abcd' 为例,将花费 7 步才能匹配成功:
- 正则中的第一个字符 a 匹配到 'abcd' 中的第一个字母 'a',匹配成功。
- 此时遇到了匹配路径的分叉口,bdc 或 cbd 或 bcd,先使用 bdc 来匹配。
- bdc 中的第一个字符 b 匹配到了 'abcd' 中的第二个字母 'b',匹配成功。
- bdc 中的第二个字符 d 与 'abcd' 中的第三个字母 'c' 不匹配,这条路径匹配失败,此时将发生回溯(backtrack),把 'b' 还回去。选择第二条路径 cbd 进行匹配。
- cbd 的第一个字符 'c' 就与 'b' 匹配失败。开始第三条路径 bcd 的匹配。
- bcd 的第一个字符 'b' 与文本 'b' 匹配成功。
- bcd 的第一个字符 'c' 与文本 'c' 匹配成功。
- bcd 的第一个字符 'd' 与文本 'd' 匹配成功。
至此匹配完成。
可想而知,如果正则中再多一些匹配路径或者匹配本文再长一点,匹配步骤将多到难以控制。
比如用 /(a*)*bc/
来匹配 'aaaaaaaaaaaabc' 都会导致性能问题,匹配文本中每增加一个 'a',都会导致执行时间翻倍。
禁止这种回溯陷阱的方法有两种:
- 占有优先量词(Possessive Quantifiers)
- 原子分组(Atomic Grouping)
可惜 JavaScript 不支持这两种语法,有兴趣可以 Google 自行了解下。
在 JavaScript 中我们没有方法可以直接禁止回溯陷阱,我们只能:
- 避免量词嵌套
(a*)* => a*
- 减少匹配路径
除此之外,我们也可以把正则匹配放到 Service Worker 中进行,从而避免影响页面性能。
查资料的时候发现,回溯陷阱不仅会导致性能问题,也有安全问题,有兴趣可以看看先知白帽大会上的《WAF是时候跟正则表达式说再见》分享。