每个程序员的书架上都有几本“宝典“,而《精通正则表达式》就是我的宝典之一。
我经常拿来当工具书,时不时翻阅下,但似乎从来没完整的认真读过。最近,突然来了兴致,把前六章重新完整的读了一遍。
好书就像一部好电影,当你看第二遍时,会关注到很多之前漏掉的细节。为此,我根据自己的实战经验和书中的精华,总结了这篇文章。
先来一段正则给大家压压惊:
/"[^\\"]*(\\.[^\\"]*)*"/
正则的发家史
正则表达式,起源于神经学。有一“科学家”为此专门开发了个 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;
模式修饰符
模式修饰符:i
、g
、m
,是属于编程语言的集成功能,并非是正则的特性。但结合两者后,可以发挥更好的能力。
比如: 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,当你编写复杂的,有多个分组的正则时会很有用。