每个程序员的书架上都有几本“宝典“,而《精通正则表达式》就是我的宝典之一。

我经常拿来当工具书,时不时翻阅下,但似乎从来没完整的认真读过。最近,突然来了兴致,把前六章重新完整的读了一遍。

好书就像一部好电影,当你看第二遍时,会关注到很多之前漏掉的细节。为此,我根据自己的实战经验和书中的精华,总结了这篇文章。

先来一段正则给大家压压惊:

/"[^\\"]*(\\.[^\\"]*)*"/

正则的发家史

正则表达式,起源于神经学。有一“科学家”为此专门开发了个 qed 编辑器来研究这门学科,后来被集成进了 Unix 的 ed 编辑器中。之后,又从 ed 发展出了 grep。最后,被 Larry Wall 在 Perl 语言中发扬光大。

正则的标准

正则表达式就像 ECMA 和 HTML5 一样,它也有标准,叫 POSIX。它分 2 个门派:

  • Basic Regular Expressions(BREs)
  • Extended Regular Expressions(EREs)

但很不幸,各个编程语言都没能严格遵守它。不过,它有 2 个不同的解析引擎得到了各大编程语言不同程度的支持:

  • DFA
  • NFA

就好比电动车(DFA)和燃油车(NFA),这两引擎有各自的特点。尽管如此,如果你想要尝试驾驶的乐趣,那一定是燃油车。因为,支持 NFA 引擎的正则更适合调校。

好在,大部分的编程语言的正则引擎是 NFA 的,从而,当你正确的调校正则时,也不会感到沮丧。

正则的组成

正则由 2 种字符组成,元字符和文本字符,类似于自然语言中的语法和单词。

元字符

我敢肯定,正则中,这些字符:^$()[]|*+?,大家再熟悉不过了。没错!这就是元字符。

特殊元字符

除了以上元字符外,还有一些常用的特殊元字符,可用来代替一组字符:

  • \s:代表空白符,包括:空格符、制表符、换行符和回车符
  • \S:除 \s 之外的任何字符
  • \w:代表 26 个大、小写字母,或者 10 个 0 到 9 的数字,等同于字符组: [a-zA-Z0-9]
  • \W:除了 \w 之外的任何字符,等同于字符组: [^a-zA-Z0-9]
  • \d:代表 10 个 0 到 9 的数字
  • \D:除了 \d 之外的任何字符

文本字符

而文本字符,就是你要匹配的字符串中出现的特定字符,如果字符串中出现了正则中的元字符,我们需要将它转义。比如:

/nicolas\.zhao/

字符边界

文本行边界

正则中,匹配文本行边界的:^$ 字符,我们一定很熟悉了。^ 匹配的是文本行开头,而 $ 是匹配结尾。

// 匹配字符串文本行,以 nicolas 开始
/^nicolas/

// 匹配字符串文本行,以 zhao 结束
/zhao$/

单词边界

另外,还可以用 \b 元字符来匹配单词的边界,比如:

// 返回数组:["nicolas", "zhao"]
'nicolas zhao'.match(/(\b(\w+)\b)/g);

字符组和分支

字符组 [...] 和分支 | 的功能有点像。当只匹配单个字符时,字符组和分支可以完成同样的功能。

比如:

// 两者都可以匹配 "nicolas",或者 "nicolaz"
/nicola[sz]/
/nicola(s|z)/

但是,差别也就在这儿,分支可以匹配更多的文本。

字符组

字符组表示:匹配列出的字符范围中的 1 个字符。

其中,出现在字符组非起始处的连字符 - 是元字符,只在字符组中生效,代表一组字符范围。比如:

// 匹配 html 的标题标签 h1 ~ h6
/<h[1-6]>/

// 等同于
/<h[123456]>/

字符组中还可以出现多个范围,范围的先后顺序不受匹配影响。

// 可以匹配字母 a ~ f、大写字母 A ~ F,或者 0 ~ 9 之间的任意一个字符
/[a-fA-F0-9]/

但是,如果连字符 - 出现的位置不在字符范围之间,则会被认为是普通字符,而非元字符,比如:

// 匹配:"-","a","b", 或者 "c"
/[-abc]/

另外,字符组外部的元字符,比如:*+?,在字符组内并不会认为是元字符,所以我们不需要转义。

// 匹配:".",或 "*",而不是匹配任意数量的字符
/[.*]/

字符组中,还有个特有的元字符 ^,如果该字符出现在字符组起始位置,表示匹配一个任意的未列出的字符,和 - 一样,出现在其他位置,就认为是普通字符。

// 匹配简单的引号字符串
/"[^"]+"/

分支

也叫多选分支。每个分支,可以认为是正则的子表达式。通常,我们会用捕获分组 (...) 作为界限。

/nicola(s|z)/

但显然,用分支来匹配单个字符实在有点浪费,而且性能也没有字符组好。所以,一般我们用分支来匹配更为复杂的场景,比如:

/(July|Jul)/

另外,分支的排列顺序很重要,我们应该把匹配度高的子表达式放在前面,以减少不必要的回溯。

量词

量词可以用来表示匹配一个,或一组字符的出现次数。

可选量词

可选量词 ? ,表示前面的字符、字符组,或者捕获组,无论它们是否出现,都能匹配成功。比如:

/July?/

很明显,这个正则比之前的 (July|Jul) 简洁了不少。

星号、加号量词

量词 *+ 都可以用来匹配字符出现多次。

但星号量词 *,匹配不了时也可以成功。所以,星号量词 * 可以更好的表达为:匹配尽可能多的次数,即使无法匹配,也不要紧。

// 该正则永远可以匹配成功,即使文本是:"abc"
/\d*/

加号量词 + ,同 * 一样,只不过得至少匹配一次,否则会匹配失败。

所以,如果你的目标文本中,确定需要匹配到字符的话,我通常建议用 + 量词,因为,在引擎匹配不了时,会尽快的报告失败。

区间量词

区间量词 {n,m} 表示匹配的字符可以出现 n 到 m 次,如果 m 没有定义,那么表示固定出现 n 次。

// 只能匹配:"aaa"
/a{3}/

// 匹配:"aaa", "aaaa", 或者 "aaaaa"
/a{3,5}/

捕获分组和反向引用

捕获分组

捕获分组,也可以认为是一个子表达式,用 (...) 表示界限。同时,还可以用上面的量词来限定该子表达式出现的次数。

/nicolas(\.zhao)?/

当捕获分组匹配成功后,会存储匹配的文本,在表达式后面可以用元字符序列 \n 来代替,简化表达式,该特性称为反向引用。

\1\2\3\9 表示之前的捕获分组从左到右依次出现的括号。如果括号嵌套,就以出现的开括号 ( 的顺序为准。我们用反向引用来匹配重复出现的字符串时很有用。

// 匹配重复的字符串:123123
/(123)\1/

在 JavaScript 中,还可以通过构造函数属性 $1$2$3$9 来获取存储的捕获文本,其规则和元字符序列一致。

/([a-z]+)/.test('abc123');

// 返回:"abc"
RegExp.$1; 

非捕获型分组

如果定义了捕获分组,但之后并没有使用反向引用,我们可以选择非捕获型分组:(?:)

非捕获型分组就是在捕获分组的开括号后加上 ?: ,在匹配成功后就不会存储到内存中,反向引用和构造函数属性 RegExp.$1 也无法取到捕获的文本。最重要的是,可以提高匹配性能。

/(?:[a-z]+)/.test('abc123');

// 返回:""
RegExp.$1;

命名捕获

捕获分组中,还有一个命名捕获。有了命名捕获,就可以直接用语义化的变量名来代替构造函数捕获属性了。现在,在 JavaScript 的 ES9(ECMAScript 2018)版本中已经得到了支持。

const match = /(?<last>nicolas)\.(?<first>zhao)/.exec('nicolas.zhao');

// 返回 { first: 'zhao', last: 'nicolas' }
match.groups;

模式修饰符

模式修饰符:igm,是属于编程语言的集成功能,并非是正则的特性。但结合两者后,可以发挥更好的能力。

比如: i 修饰符。在匹配有大、小写的字母的文本时非常有用。

// 功能同字符组:[Nn]icolas
/nicolas/i

而修饰符 g 为全局匹配,表示在一次成功匹配后,引擎可以继续向右传动,匹配更多的文本。

// 在已经匹配了 {0} 后,通过修饰符 g,表达式会继续匹配后面的 {1},
// 最终返回:"nicolas.zhao"
'{0}.{1}'.replace(/\{(\d)\}/g, (m, p) => ['nicolas', 'zhao'][p]);

通常,正则只能在单行文本中匹配,如果需要多行匹配,需要使用修饰符 m

// 返回:true
/^(line2)$/m.test('line1\nline2\nline3');

回溯和性能

回溯就好比用面包屑探路。一条路走不通了,根据之前洒下的面包屑,可以回退到分岔路口,再用面包屑尝试一条新的路,如果不行,就再回退,重新反复尝试,直到找到出口。

回溯又和之前的量词密切相关,*+ 属于匹配优先(贪婪模式)量词。

// 会一直匹配到文本结束,返回:"<p>text</p>"
/<.*>/.exec(`<p>text<\/p>`)[0];

而在这 2 个量词之后增加一个 ? ,又可以变成忽略优先(懒惰模式)量词:*?+?

// 返回:"<p>"
/<.*?>/.exec(`<p>text<\/p>`)[0];

上面的表达式,会先尝试忽略 . 号匹配的字符,然后引擎会继续向右传动,确认接下来文本中的字符是否能够满足正则的匹配,如不能,引擎回退,把之前的 . 号重新匹配,如此循环往复。

假如匹配的文本并不复杂,忽略优先是个不错的选择。但匹配的文本,如果是个 HTML 的 a 标签的话,比如:<a href="https://nicolaszhao.com">,那估计得不断回溯。

所以,我们可以用匹配优先量词,结合排除型字符组 [^...] 来代替忽略优先。

// 同样返回:"<p>",但性能可能比忽略优先快好几倍
/<[^>]*>/.exec(`<p>text<\/p>`)[0];

当然,如今的电脑都那么强大,这 2 种方式,我们的肉眼很难感受到性能差别。但如果你非要比个高下,你需要搞个几百万次的循环才能看出它们的差距。

另外,匹配优先使用不当,一样存在回溯导致的性能问题。比如:

// 文本字符中的 [...] 代表几百、或者几千个字符
/.*!/.test('Hello! [...]');

这段正则首先会以最快的速度匹配完整个文本,但在之后发现,还需要匹配后面的字符: !。然后把 .* 匹配到的文本不断回退,每回退一次,就要为 ! 留出匹配机会,反复尝试,一直回退到文本的第 6 个字符,最终才匹配成功。

所以,选择那种方式,取决于你要匹配的实际的文本。如果可以很清楚的了解目标文本,完全可以简化正则表达式的复杂度。

举个栗子:

// 这里可以用 "." 号满足更多的需求,比如:11/13/09
/(\d{2}).(\d{2}).(\d{2})/

// 如果换成下面这样就比较冗余,也不灵活
/(\d{2})[-/](\d{2})[-/](\d{2})/

// 假如目标字符串变成:11.13.09,我们还得调整正则
/(\d{2})[.-/](\d{2})[.-/](\d{2})/

在前面的匹配优先量词结合排除型字符组的示例中,当匹配的排除字符不止 1 个时,似乎就只能用忽略优先了。

// 返回:"content"
/<div>(.*?)<\/div>/.exec('<div>content</div>')[1];

上面的正则,要获取标签间的内容,后面的 </ 是界限,它由 2 个字符组成,但排除型字符组只能匹配一个。

好在,正则还有另一个秘密武器:环视。

环视

环视,在匹配时只匹配字符的位置,而不匹配字符,比如:

// 返回:"nicolas.zhao"
'nicolaszhao'.replace(/(?=zhao)/, '.');

上面的 (?=zhao),如果在文本 nicolaszhao 中,在某个字符的右侧匹配到了 zhao,就会在匹配的这个(组)字符位置之前插入字符 .,而文本 zhao 并不会被真正匹配。

环视分类

上面的正则,只是环视的一种,环视还分为:

  • 顺序环视
    • 肯定顺序环视:(?=)
    • 否定顺序环视:(?!)
  • 逆序环视
    • 肯定逆序环视:(?<=)
    • 否定逆序环视:(?<!)

所谓顺序环视,就是从字符的位置往右匹配。逆序环视,则往左匹配。然而,两者的否定环视,就像前一节提到的排除匹配的示例,它们类似于排除型字符组,但可以匹配任意长度的字符。

环视使用

来个经典的栗子:

// 返回:"1,234,567,890"
'1234567890'.replace(/(?<=\d)(?=(\d{3})+(?!\d))/g, ',');

这段正则常用于处理金额等数据。它不会匹配任何字符,但会找到环视所匹配的字符的前后位置。

当它开始匹配,引擎从左向右传动,(?<=\d) 会先确认待匹配字符的左侧是否为数字,而 (?=(\d{3})+(?!\d)) 会确认它的右侧为:多个 3 位一组的数字,直到这组数字的右侧遇到非数字字符。而修饰符 g,在成功匹配一次后,正则引擎会继续向前传动。

其中,(?!\d) 的限定很重要,否则会返回:1,2,3,4,5,6,7,890。如果目标字符串只是纯数字,上面的正则还可以简化,直接使用结束符 $ 来代替:

'1234567890'.replace(/(?<=\d)(?=(\d{3})+$/g, ',');

我们再接着聊前面一节提到的栗子:

/<div>(.*?)<\/div>/.exec('<div>content</div>')[1];

我们目的是要取 HTML 标签中的内容,我们来看看用环视如何来优雅的解决这个问题。

// 返回:"content"
/<div>(((?!<\/).)*)<\/div>/.exec('<div>content</div>')[1];

中间的 ((?!<\/).)* 似乎有点复杂,但仔细分析下,还是很好理解的。它意思是,当开始匹配中间的内容时,确认右侧字符不为 </,如果匹配,那么接下来的 . 号可匹配任意字符。外部的捕获分组通过匹配优先量词 * 快速匹配尽可能多的字符,直到遇见 </ 后,中间这段匹配宣告结束。

其中,((?!<\/).)* 还可以进一步优化,因为,除了分组外,其他没有任何价值,所以,我们可以使用非捕获分组来提高性能,以下是最终调整后的正则:

/<div>((?:(?!<\/).)*)<\/div>/.exec('<div>content</div>')[1];

回到开始

在最后,我们再看下文章开始的那段正则:

/"[^\\"]*(\\.[^\\"]*)*"/

结合上面的内容,你是否能猜出它的用途呢?我们来简化下,把这段正则改成:

/"[^"]*"/

现在是不是一下子就明白了?没错!它就是用来匹配引号字符串的,只是,简化前的正则还可以匹配另一种特殊情况:转义引号。

如果是排除型字符组,在遇到 " 时,引擎就停止匹配了,遇到转义引号 \" 也会如此。所以,如果你要解决这个问题,就得另辟蹊径。

还好,正则中有个高级用法,那就是:消除循环。它有一个公式:

// opening normal* (special normal*)* closing

如果拿引号字符串的示例来理解的话,就是:引号开始,接着任意多个的常规字符,然后出现特殊的转义引号和任意多个常规字符,转移引号和常规字符组合在一起,可以出现任意多次,最后引号结束。

回到上面的正则,[^\\"]* 会先匹配比较常见的字符(除 \" 外的字符),因为,常见字符的出现几率较多,这就是 normal。然后,接下来可能会遇到转义引号 \",我们这里用 \\.,这样可以处理更多的转移字符,这里也就是 special 部分了。在转义字符后,可能还会有更多的常见字符出现,和起始处一样,也是 normal,也就是前面的 [^\\"]*,它和 special 部分组合成 \\.[^\\"]*,用捕获分组再加上 *,让它们可以匹配任意多次(即使并没有匹配)。

有了这样的公式,我们就可以方便的处理更多类似的需求,比如 URL 的域名:

/[a-z]+(\.[a-z]+)*/

域名中,. 号属于特殊情况,而常规字符就是字母了,通过这种模式,该正则可以匹配无限多级域名的文本。

最后的最后,推荐大家一个非常实用的、可视化的在线正则验证工具:Regulex,当你编写复杂的,有多个分组的正则时会很有用。