本文记录了正则表达式入门之后(不知道算不算得上进阶😅)的一些用法,主要用于个人随时查阅。

1. 防止过度匹配

正则表达式中 +*{n,} 所能匹配到的字符长度是没有上限,通俗点说就是它们是’贪婪’的,举个例子(文中的例子以 JavaScript 的正则表达式实现为准)。

现在我们想要匹配一串字符里的 <a> 标签及其里面的内容,如果我们写的是这个表达式:

/<a>.*<\/a>/g

如下所示,将会匹配到第一个 <a> 与最后一个 </a> 之间的所有字符,并不是我们想要的结果。

start <a>first</a> 夹在中间好尴尬😂 <a>second</a> end.

解决方案是在 +*{n,} 后面加一个 ?,用来防止过度匹配,如:

/<a>.*?<\/a>/g
start <a>first</a> 这下不尴尬了😁 <a>second</a> end.

2. 回溯引用

现有这样一个需求,用一个正则表达式,找出字符串中所有的 HTML 标签及其包裹内容(不考虑属性及 <img> 等自关闭标签),如:

start <div>first</div> 吃🍉群众 <span>second</span> end.

第一反应我们可能会这样写:

/<\w+>.*?<\/\w+>/g

这个表达式确实可以满足上面的例子,但是请看下面:

start <div>first</span> 吃🍉群众 <span>second</div> end.

可以看到当开闭标签对不上的时候,上面的正则仍然能匹配出来,这是不对的,如果我们能在表达式的后面使用前面匹配到的内容就好了,这时候回溯引用就派上用场了。
在 JavaScript 的实现中使用 \n 表示第 n 个捕获组(它们的序号以’(‘出现的位置为准)所匹配到的内容,使用回溯引用之后的正则及匹配结果如下:

/<(\w+)>.*?<\/\1>/g
start <div>first</span> 吃🍉群众 <span>second</span> end.

匹配结果终于正常了😊。

3. 前后查找

现在又有新的需求了,要求我们写一个新的正则表达式,能够匹配一个 HTML 文档中所有标题的内容,即 h1~h6 标签里的所有内容。在前面回溯引用的基础上,我们很容易写出:

/<(h[1-6])>.*?<\/\1>/g
start <h1>heading1</h1> 继续吃🍉 <h2>heading2</h2> end.

好像看起来还不错,但是注意,我们要的只是标题的文本内容,是不包括标签的,也就是说我们要的是:

start <h1>heading1</h1> 继续吃🍉 <h2>heading2</h2> end.

由于目前大部分浏览器都不支持向后查找,我们先讨论向前查找(注意这里的’前后’指的是方向,而不是位置)。

3.1 向前查找

向前查找分为正向前查找(positive lookahead)和反向前查找(negative lookahead),正向前查找用 (?=) 表示,而反向前查找用 (?!) 表示,用一个简单的例子来描述就是:

正向前查找 - 匹配后面跟着字符 'b' 的字符 'a'
/a(?=b)/g - aa ab ac

反向前查找 - 匹配后面不是跟着字符 'b' 的字符 'a'
/a(?!b)/g - aa ab ac

再看一个具体的例子,比如匹配 url 地址中的协议名:

/^\w+(?=:)/gm
http://test.com
https://test.com
ftp://test.com

3.2 向后查找

向后查找和向前查找一样,分为正向后查找(positive lookbehind)和反向后查找(negative lookbehind)。其中正向后查找用 (?<=) 表示,反向后查找用 (?<!) 表示。

正向后查找 - 匹配前面是字符 'a' 的字符 'b'
/(?<=a)b/g - ab bb ba

反向后查找 - 匹配前面不是字符 'a' 的字符 'b'
/(?<!a)b/g - ab bb ba

3.3 前后结合

把前后查找结合起来可以解决一开始获取标题内容的问题,如下所示:

/(?<=<(h[1-6])>).*?(?=<\/\1>)/g
start <h1>heading1</h1> 继续吃🍉 <h2>heading2</h2> end.

在不支持向后查找的环境中,可以用捕获组满足上面的需求。不过要注意的是,虽然前后查找的操作符也是用 () 包裹起来的,但是它们并不是捕获组,实际上它们应该属于’非捕获组’,下面就开始介绍非捕获组。

4. 非捕获组与命名捕获组

4.1 非捕获组

有时候我们不需要获取某一组(子表达式)的内容,这时候可以用非捕获组(non-capturing group),非捕获组所匹配的内容不会出现在结果中,一般的非捕获组用 (?:) 表示。

举个例子,现在我想要获取一个 url 里面的域名,如果不使用非捕获组:

const re = /(https?|ftp):\/\/([^\/\s]+)/
'http://foo.com/bar'.match(re)  // [http://foo.com, 'http', 'foo.com']
'https://foo.com/bar'.match(re) // [https://foo.com, 'https', 'foo.com']
'ftp://foo.com/bar'.match(re)   // [ftp://foo.com, 'ftp', 'foo.com']

虽然看起来也满足了需求,但是我们要的只是域名,并不关心它是什么协议,所以可以把协议匹配的表达式放在非捕获组里:

const re = /(?:https?|ftp):\/\/([^\/\s]+)/
'http://foo.com/bar'.match(re)  // [http://foo.com, 'foo.com']
'https://foo.com/bar'.match(re) // [https://foo.com, 'foo.com']
'ftp://foo.com/bar'.match(re)   // [ftp://foo.com, 'foo.com']

4.2 命名捕获组

前面我们知道可以使用 \n 来代表第 n 个捕获组匹配的内容,然而对于一个复杂的、有很多嵌套捕获组和回溯引用的正则表达式,如果没有详细的说明的话,会变得难以分析和维护,这时候我们可以试试命名捕获组。

命名捕获组的语法为 (?<name>pattern), 还是上面关于 url 的例子,我们使用命名捕获组来获取 url 的协议名和主机名:

const re = /(?<protocol>https?|ftp):\/\/(?<host>[^\/\s]+)/
'https://foo.com/bar'.match(re)
// [https://foo.com, 'https', 'foo.com']
// { groups: { protocol: 'https', host: 'foo.com' }}

命名捕获组的回溯引用的语法为 \k<name>