cover

正则表达式基本上每用到一次就得从头自学一次,用完了写出来了也就忘光了。

前两天在 Twitter 上看到了题图,感觉又是个大坑,趁着手头还有 Caraws 给的书就又双叒叕学习了一遍正则表达式。

本文试图先用最易懂的方式理顺正则表达式的知识点(就不贴一摞一摞的文档截图了,至于正则的使用场景和用处也不啰嗦了),主要介绍正则本身和在 JavaScript 中使用正则的坑。

准备工作

形如题图中的 /abc/ 就是一个最简单的正则表达式(regular expression),一般被称之为模式(pattern)。

更具体一点的定义,正则表达式是用“正则表达式语言”来创建的,用于匹配和处理文本的字符串,它是内置于其它语言中的“迷你语言”。在不同语言中的正则表达式实现中,语法和功能可能会有一定差异(后面我们会详细讲一下)。

https://regex101.com/ 是一个在线练习网站,我们可以在界面上勾选不同的编程语言,也可以看到正则表达式的性能(匹配完成所需时间)以及具体的匹配步骤。

基础

总结了一下,我觉得把正则中的语法符号分为四类比较容易记忆:

  • 字符与字符集
  • 预定义字符类
  • 重复次数
  • 功能字符(最后这种是我概括出来的名字)

字符与字符集

首先要分清字符字符集

/abc/ 中的 a 匹配的是单个字符,这个模式就匹配的是三个字符,当文本是 ‘abcd’ 时会一次性匹配到字符串 ‘abc’

然而用 [] 字符集操作符包裹起来的 /[abc]/ 就是一个字符集,[abc] 匹配的是一个字符,表明匹配为 a 或 b 或 c 的一个字符。因此,当文本是 ‘abcd’ 时执行匹配,每次只能匹配到单个字符,第一次匹配到 ‘a’,第二次匹配到’b’……

区分这两个概念并不难,一般(我自己是)等到了各种表达式嵌套的时候就开始懵逼了。

只能在字符集中使用的操作符有两个,取非操作符^字符区间-

在字符集中我们可以使用取非操作符^/[^abc]/即为匹配 a,b,c 以外的任意字符。

/[a-c]//[abc]/ 相同,通过字符区间我们可以编写 /[A-Za-z0-9]/ 这种简洁易读的正则表达式了。

预定义字符类

正则表达式预定义了一些常用的术语来代表一类字符。

比如 \d 为任意数字,\D 为任意非数字,更多预定义字符可以参看 MDN

重复次数

以下符号跟在字符或者字符集的后面,代表重复次数:

  • +:重复一次或多次
  • *:重复零次或多次
  • ?:重复零次或一次
  • {m, n}:可表示区间,或是至少 m 次

需要注意的是,除了 ? 之外的三种都是贪婪型字符,可能会发生过度匹配的情况。

1
2
3
var str='aacbacbc';
var reg=/a.*b/;
console.log(str.match(reg)); // ["aacbacb", index:0...]

此时执行一次匹配,由于 * 作为贪婪型字符会尽可能匹配更多内容,因此匹配到的是 aacbacb,而不是 aacb。在贪婪型字符后面加上 ? 即变为非贪婪字符。

功能字符

举几个例子:

  • 正则尾部的 /i 表示忽略大小写,/g 表示匹配所有实例,/m 多行匹配

  • 竖线符号 | 表明“或”,a|b 即匹配 a 或 b

  • 小括号 () 可以用来分割子表达式

  • ^字符串$ 代表字符串的前后边界

进阶

以上介绍的都是基础语法与字符匹配规则,正则还有两种较为高级的使用语法。

回溯引用 backreference

回溯引用是指模式的后半部分引用在前半部分中定义的子表达式。

语法:

  1. (x) 子表达式
  2. \ 标识回溯引用,\n 即代表第 n 个子表达式所匹配到的内容(在 replace 操作中使用 $

举一个典型例子,匹配 HTML 中的标题标签,HTML 可能如下:

1
2
<h1>111</h1>
<h4>lalala</h3>

通过 /<[Hh][1-5]>.*?<\/[Hh][1-5]>/ 我们会将 <h4>lalala\</h3> 这种非法标签也匹配到。回溯引用就适用于这种场景,它可以实现前后一致匹配

/<([hH][1-5])>.*?<\/\1>/ 中的 \1 只匹配第一个子表达式 ([hH][1-5]) 所匹配到的内容,从而避免匹配到对应不上的标签组合。

前后查找 lookaround

还是引用上面的标题标签匹配例子,如果我们想只匹配到 <h1>111</h1> 中的标题内容要怎么写正则呢?

这里涉及到两个新语法:

  • ?=:向前查找
  • ?<=:向后查找

以向前、向后查找开头的子表达式就是前后查找。

因此,正则可以为:/(?<=<[Hh][1-5]>).*(?=<\/[Hh][1-5]>)/

结合回溯引用与前后查找,还可以实现条件式的正则表达式,威力爆炸,只是这种形式的正则太难读了,有兴趣可以 Google 学习一下,这里不讲了。

高级

以上是正则表达式的知识点,现在讲讲坑。首先说说 JavaScript 中的正则。

字面量 VS RegExp()

在 JavaScript 中创建正则表达式有两种方式:

1
2
3
4
5
// 正则字面量
var pattern1 = /\d+/;

// 构造 RegExp 实例,以字符串形式传入正则
var pattern2 = new RegExp('\\d+');

两种方式创建出的正则没有任何差别。从创建方式上看,正则字面量可读性更优,因为正则中经常使用 \ 反斜杠在字符串中是一个转义字符,想以字符串中表示反斜杠的话,需要使用 \\ 两个反斜杠。

但是,需要注意,每个正则表达式都有一个独立的对象表示,每次创建正则表达式,都会为其创建一个新的正则表达式对象,这和其它类型(字符串、数组)不同

我们可以通过让正则表达式只编译一次并将其保存在一个变量中以供后续使用来实现优化。

因此,第一段代码将创建三个正则表达式对象,并进行了三次编译,虽然表达式是相同的。而第二段代码则性能更高。

1
2
3
4
5
6
7
8
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

这里我们来解释下题图中的情况是怎么回事。

cover

这其实是全局匹配的坑,也就是正则后的 /g 符号。

1
2
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() 返回的结果都不一样。

不仅如此,看看下面这段代码,能看出来有什么问题吗?

1
2
var count = 0;
while (/a/g.test('ababc')) count++;

不要轻易拷贝到控制台中尝试,会把浏览器卡死的。

由于每个循环中 /a/g.test('ababc') 都创建了新的正则表达式对象,每次匹配都是重新开始,这一操作会无限执行下去,形成死循环。

正确的写法是:

1
2
3
var count = 0;
var regex = /a/g;
while (regex.test('ababc')) count++;

这样,每次循环中操作的都是同一个正则表达式对象,随着每次匹配后 lastIndex 的增加,等到将整个字符串匹配完成后,就跳出循环了。

给以上知识点画个重点

  1. 将正则表达式保存到变量中,只在逻辑中使用这个变量,不仅性能更高,还安全。
  2. 谨慎使用全局匹配,RegExp.prototype.exec()RegExp.prototype.test()这两个方法的执行结果可能每次都不同。
  3. 做到了以上两点后,还要谨慎在循环中使用正则匹配。

回溯陷阱 Catastrophic Backtracking

回溯陷阱是正则表达式本身的一个坑了,会导致非常严重的性能问题,事故现场可以参看《一个正则表达式引发的血案,让线上 CPU100% 异常!》

简单介绍一下回溯陷阱的问题源头,正则引擎分为 NFA(确定型有穷自动机)DFA(不确定型有穷自动机)DFA 是从匹配文本入手,同一个字符不会匹配两次(可以理解为手里捏着文本,挨个字符拿去匹配正则),时间复杂度是线性的,它的功能有限,不支持回溯。大多数编程语言选用的都是 NFA,相当于手里拿着正则表达式,去匹配文本。

/(a(bdc|cbd|bcd)/ 中已经有三种匹配路径,在 NFA 中,以文本 ‘abcd’ 为例,将花费 7 步才能匹配成功:

regex101
(图中还包括了字符边界的匹配步骤,因此多了三步)

  1. 正则中的第一个字符 a 匹配到 ‘abcd’ 中的第一个字母 ‘a’,匹配成功。
  2. 此时遇到了匹配路径的分叉口,bdc 或 cbd 或 bcd,先使用 bdc 来匹配。
  3. bdc 中的第一个字符 b 匹配到了 ‘abcd’ 中的第二个字母 ‘b’,匹配成功。
  4. bdc 中的第二个字符 d 与 ‘abcd’ 中的第三个字母 ‘c’ 不匹配,这条路径匹配失败,此时将发生回溯(backtrack),把 ‘b’ 还回去。选择第二条路径 cbd 进行匹配。
  5. cbd 的第一个字符 ‘c’ 就与 ‘b’ 匹配失败。开始第三条路径 bcd 的匹配。
  6. bcd 的第一个字符 ‘b’ 与文本 ‘b’ 匹配成功。
  7. bcd 的第一个字符 ‘c’ 与文本 ‘c’ 匹配成功。
  8. bcd 的第一个字符 ‘d’ 与文本 ‘d’ 匹配成功。

至此匹配完成。

可想而知,如果正则中再多一些匹配路径或者匹配本文再长一点,匹配步骤将多到难以控制。

比如用 /(a*)*bc/ 来匹配 ‘aaaaaaaaaaaabc’ 都会导致性能问题,匹配文本中每增加一个 ‘a’,都会导致执行时间翻倍。

禁止这种回溯陷阱的方法有两种:

  1. 占有优先量词(Possessive Quantifiers)
  2. 原子分组(Atomic Grouping)

可惜 JavaScript 不支持这两种语法,有兴趣可以 Google 自行了解下。

在 JavaScript 中我们没有方法可以直接禁止回溯陷阱,我们只能:

  1. 避免量词嵌套 (a*)* => a*
  2. 减少匹配路径

除此之外,我们也可以把正则匹配放到 Service Worker 中进行,从而避免影响页面性能。

查资料的时候发现,回溯陷阱不仅会导致性能问题,也有安全问题,有兴趣可以看看先知白帽大会上的《WAF是时候跟正则表达式说再见》分享。

参考资料