8.2 回溯引用匹配
我们在解决匹配 HTML 标题问题之前先来看一个比较简单的例子,这个问题如果不使用回溯引用将无法解决。
假设你有一段文本,你想把这段文本里所有连续重复出现的单词(打字错误,其中有一个单词输了两遍)找出来。显然,在搜索某个单词的第二次出现时,这个单词必须是已知的。回溯引用允许正则表达式模式引用前面的匹配结果(具体到这个例子,就是前面匹配到的单词)。
8.2.1 匹配重复的单词
把这个问题弄明白的最佳办法是看看它到底是如何工作的。下面是一段包含着 3 组重复单词的文本,它们就是我们要找的东西:
[ ]+(\w+)[ ]+\1
This is a block of of text, several words here are are repeated, and and they should not be.
这个模式找到了我们想要的东西,但它是如何做到这一点的呢? [ ]+
匹配一个或多个空格, \w+
匹配一个或多个字母数字字符, [ ]+
匹配随后的空格。注意, \w+
是括在括号里的,它是一个子表达式。这个子表达式不是用来进行重复匹配的,这里根本不涉及重复匹配的问题。这个子表达式只是把整个模式的一部分单独划分出来以便在后面引用。这个模式的最后一部分是 \1
;这是一个回溯引用,而它引用的正是前面划分出来的那个子表达式:当 (\w+)
匹配到单词 "of" 的时候, \1
也匹配单词 "of";当 (\w+)
匹配到单词 "and" 的时候, \1
也匹配单词 "and"。
回溯引用指的是模式的后半部分引用在前半部分中定义的子表达式(如上例所示)。
\1
到底代表着什么?它代表着模式里的第 1 个子表达式, \2
代表着第 2 个子表达式、 \3
代表着第 3 个;依次类推。于是,在上面那个例子里, [ ]+(\w+)[ ]+\1
将匹配同一个单词的连续两次重复出现。
8.2.2 匹配配对的标题标签
看过回溯引用的用法之后,我们再回过头来看看应该如何解决匹配 HTML 标题的问题。利用回溯引用,构造一个模式去匹配任何一级标题的开始标签和与之配对的结束标签(忽略任何不配对的标签组合)对我们来说已经不是什么难题了。看下面的例子:
<h([1-6])>.*?</h\1>
<body> <h1>玩转正则表达式教程</h1> 欢迎来到正则表达式教程 <h2>第一章 正则表达式入门</h2> 正则表达式入门 <h3>1.1 正则表达式的用途</h3> 正则表达式的用途 <h3>1.2 如何使用正则表达式</h3> 如何使用正则表达式 <h2>第二章 匹配单个字符</h2> 匹配单个字符 <h3>2.1 匹配普通文本</h3> 匹配普通文本 <h3>2.2 匹配任意字符</h3> 匹配任意字符 <h2>第三章 匹配一组字符</h2> 匹配一组字符 <h3>3.1 匹配多个字符中的某一个</h3> 匹配多个字符中的某一个 <h3>3.2 利用字符集合区间</h3> 利用字符集合区间 <h2>这是一个错误的标题</h3> </body>
总共找到了 10 个匹配, <h([1-6])>
匹配任何一级标题的开始标签,但我们这次用 (
和 )
把 [1-6]
括了起来,使它成为了一个子表达式。这样一来,我们就可以在用来匹配标题结束标签的 </h\1>
用 \1
来引用这个子表达式了。子表达式 ([1-6])
匹配数字 1~6, \1
只匹配与之相同的数字。这样一来,原始文本里的 <h2>这是一个错误的标题</h3> 就不会被匹配到了。
回溯引用只能用来引用模式里的子表达式(用(和)括起来的正则表达式片段)。
回溯引用匹配通常从 1 开始计数( \1
、 \2
,等等)。在许多实现里,第 0 个匹配( \0
)可以用来代表整个正则表达式。
8.2.3 使用命名捕获
正如看到的那样,子表达式是通过它们的相对位置来引用的: \1
对应着第 1 个子表达式, \5
对应着第 5 个子表达式,等等。
虽然受到普遍的支持,但这种语法存在着一个严重的不足:如果子表达式的相对位置发生了变化,整个模式也许就不能再完成原来的工作,删除或添加子表达式的后果可能更为严重。
为了弥补这一不足,一些比较新的正则表达式实现还支持命名捕获(named capture):给某个子表达式起一个唯一的名字,然后用这个名字(而不是相对位置)来引用这个子表达式。看下面这个例子:
<h(?<level>[1-6])>.*?</h\k<level>>
<body> <h1>玩转正则表达式教程</h1> 欢迎来到正则表达式教程 <h2>第一章 正则表达式入门</h2> 正则表达式入门 <h3>1.1 正则表达式的用途</h3> 正则表达式的用途 <h3>1.2 如何使用正则表达式</h3> 如何使用正则表达式 <h2>第二章 匹配单个字符</h2> 匹配单个字符 <h3>2.1 匹配普通文本</h3> 匹配普通文本 <h3>2.2 匹配任意字符</h3> 匹配任意字符 <h2>第三章 匹配一组字符</h2> 匹配一组字符 <h3>3.1 匹配多个字符中的某一个</h3> 匹配多个字符中的某一个 <h3>3.2 利用字符集合区间</h3> 利用字符集合区间 <h2>这是一个错误的标题</h3> </body>
在这个模式里面,我们把 ([1-6])
改为了 (?<level>[1-6])
、把 \1
改成了 \k<level>
。其中的 ?<level>
表示把子表达式 ([1-6])
命名成 level, \k<level>
表示引用前面定义的 level 子表达式。这个使用命名捕获就不会由于前端的子表达式数量变动而需要改成后面所有的引用下标,又增加的模式的可读性。
命名捕获在一些较老的正则表达式实现里面并不支持。在后端使用该特性前一定要在环境中测试后再使用。在前端使用时,如果你的网站受众可能会使用低版本的浏览器,请不要使用该特性,虽然可以配置 Webpack 等打包工具把 Javascript 转译到低版本,但正则表达式是不会被转译的,且在打包时不会报任何错误,但在低版本浏览器加载时会直接抛出一个语法错误导致应用崩溃。