注意:所有示例,基于Javascript语法。
第 1 章 字符组
字符组就是“一组”字符。在正则表达式中,它表示“在同一个位置可能出现的各种字符”。
写法:在一对方括号[和]之间,列出所有可能出现的字符。例如:[abc]、[123]、[#?.]等
1.1 普通字符组
例如:检测字符串中是否存在数字?
/[0123456789]/.test('hello123world'); // true
字符组中字符的排列顺序并不影响字符组的功能,出现重复字符也不会影响。例如:[0123456789]完全等价于[9876543210]、[998876543210]。
为了代码更容易编写、方便阅读,不推荐在字符组中出现重复字符,而且还应该让字符组中的字符排列更符合认知习惯。为此,正则表达式提供了 -范围表示法(range) ,它更直观,能进一步简化字符组。
所谓 -范围表示法 ,就是用[x-y]的形式,表示 x 到 y 整个范围内的字符。[0123456789]就可以表示为[0-9]。
1.1.1 范围表示法
-范围表示法的范围是如何确定的?为什么要写作[0-9],而不写作[9-0]?
在字符组中,-表示的范围,一般是根据字符对应的码值(字符在对应编码表中的编码数值)来确定的。码值小的字符在前,码值大的字符在后。在ASCII编码中,字符 0 的码值是48(十进制),字符 9 的码值是57(十进制)。所以[0-9]等价于[0123456789]。
/[0-9]/.test('2'); // true
在字符组中可以同时并列多个 -范围表示法 。例如:[0-9a-fA-F]可以匹配数字,大小写形式的a ~ f,它可以用来验证十六进制字符。
1.1.2 转义序列\xnum表示ASCII字符
可以使用转义序列
\xnum来表示一个字符。其中
\x是固定前缀,表示转义序列的开头,num是字符对应的码值,是一个两位的十六进制数值。例如:字符A的码值是41(十进制则为65),所以也可以用\x41表示。
字符组中有时会出现这种表示法,它可以表示一些难以输入或者难以显示的字符,比如\x7f。也可以用来方便地表示某个范围,例如:
- 所有标准ASCII字符对应的字符组:
[\x00-\x7f],匹配码值范围[0,127]。8位二进制组成,最左侧位始终为0,其余7位可取0、1。 - 所有扩展ASCII字符对应的字符组:
[\x00-\xff],匹配码值范围[0,255]。8位二进制组成,8位皆可取0、1。可用来匹配非中文字符。
// 匹配是否是非中文字符
/[\x00-\xff]/.test('路'); // false
1.2 元字符与转义
字符组中的横线
-并不能匹配横线字符,而是用来表示范围,这类字符叫元字符。
字符组的开方括号[、闭方括号]、横线-算元字符,在匹配中,有着特殊的意义。但有时候并不需要表示特殊的意义,只需要表示普通字符,此时就必须做特殊处理。
字符组中的-,如果它紧邻着字符组中的开方括号[或闭方括号],那么它就是普通字符,其他情况下都是元字符。
// - 紧邻开方括号[,表示普通 - 字符
/[-9]/.test('-'); // true
// - 紧邻闭方括号[,表示普通 - 字符
/[9-]/.test('-'); // true
而对于其他元字符,取消特殊含义的做法都是转义,也就是在正则表达式中的元字符前加上反斜线\字符。
Javascript语言使用 RegExp 构造器创建正则表达式,转义字符组中的横线-:
new RegExp('[0\\-2]');
在上文里说“在正则表达式中的元字符之前加上反斜线\字符,对元字符转义”,而在代码里写的却不是[0\-2],而是[0\\-2]。因为在这段程序里,正则表达式是以字符串的方式提供的,而字符串本身也有关于转义的规定。上面说的“正则表达式”,是经过“字符串转义处理”之后的字符串的值。因为处理字符串时,反斜线和它之后的字符会被认为是转义序列。因此需要\\ 转义成\。
在Javascript中也可以使用不转义的原生模版字符串,例如:
String.raw`\'"`; // 等价于:`\\'"`
new RegExp(String.raw`[0\-2]`); // 等价于:/[0\-2]/
1.3 排除型字符组
在方括号[...]中列出希望匹配的所有字符,这种字符组叫做普通字符组。它的确非常方便,但也有些问题是普通字符组不能解决的。比如匹配字符串中是否存在非数字字符,不是数字的字符太多了,全部列出几乎不可能,这时就应当使用排除型字符组。
排除型字符组非常类似普通字符串[...],只是在开方括号[之后紧跟一个尖角号^,写作[^...],表示“在当前位置,匹配一个没有列出的字符”。所以[^0-9]就表示“0 ~ 9之外的字符”,也就是“非数字字符”。
注意:排除型字符组必须匹配一个字符。
// 匹配一个除-、0、9之外的字符
/[^-09]/.test('-'); // false
// 匹配一个除0~9之外的字符
/[^0-9]/.test('a'); // true
在排除型字符组中,^是一个元字符,但只有它紧跟在[之后时才是元字符。如果想表示“这个字符组中可以出现^字符”,不要让它紧挨着[即可,否则就要转义。
/[0^9]/.test('^'); // true
1.4 字符组简记法
用
[0-9]、[a-z]等字符组,可以很方便地表示数字字符和小写字母字符。对于这类常用的字符组,正则表达式提供了更简单的记法,这就是字符组简记法。
常见的字符组简记法有 \d、\w、\s。
\d等价于[0-9],其中的 d 代表“数字(digit)”。\w等价于[0-9a-zA-Z_],其中的 w 代表“单词字符(word)”。\s等价于[ \n\r\t\v\f](第一个字符是空格),其中的 s 代表“空白字符(space)”。空白字符,可以是空格字符、新行换行符\n、回车换行符符\r、水平制表符\t、垂直制表符\v、换页符\f。(换行符指的是:\n、\r。)
/\d/.test('2'); // true
/\w/.test('a'); // true
/\w/.test('_'); // true
/\w/.test('2'); // true
/\s/.test(' '); // true
字符组简记法可以单独出现,也可以使用在字符组中。比如[0-9a-zA-Z]可以写成[\da-zA-Z]。
字符组简记法也可以用在排除型字符组中,比如[^0-9]可以写成[^\d]。
相对于 \d、\w、\s 这三个普通字符组简记法,正则表达式也提供了对应排除型字符组的简记法:\D、\W、\S。字母完全一样,只是改为大写。
\D等价于[^\d]、[^0-9]\W等价于[^\w]、[^0-9a-zA-Z_]\S等价于[^\s]、[^ \n\r\t\v\f]
// \d 和 \D
/\d/.test('8'); // true
/\d/.test('a'); // false
/\D/.test('8'); // false
/\D/.test('a'); // true
// \w 和 \W
/\w/.test('c'); // true
/\w/.test('!'); // false
/\W/.test('c'); // false
/\W/.test('!'); // true
// \s 和 \S
/\s/.test('\t'); // true
/\s/.test('0'); // false
/\S/.test('\t'); // false
/\S/.test('0'); // true
注意:[\s\S]、[\w\W]、[\s\S]能匹配任意字符。
关于字符组简记法,最后需要补充两点:
- 如果字符组中出现了字符组简记法,最好不要出现单独的
-,否则可能会引起错误,比如[\d-a]就很让人困惑。 - 以上说的
\d、\w、\s的匹配规则,都是针对ASCII编码而言的,也叫ASCII匹配规则。而在一些语言中的正则表达式已经支持了Unicode字符。那么数字字符、单词字符、空白字符的范围,已经不仅限于ASCII编码中的字符。
第 2 章 量词
2.1 一般形式
验证中国大陆地区的邮政编码(6位数字构成的字符串),比如
201203。
只有同时满足以下两个条件,匹配才成功:
- 长度是 6 个字符。
- 每个字符都是数字。
/^\d\d\d\d\d\d$/.test('201203'); // true
注意:以上代码中的^表示字符串的起始位置,$表示字符的结束位置。(后续会讲解)
\d重复了 6 次,读写都不方便。为此,正则表达式提供了量词。比如上面匹配邮政编码的表达式,就可以简写为\d{6},它使用阿拉伯数字,更简洁直观。
/^\d{6}$/.test('201203'); // true
正则表达式中一般形式量词,如下所示:
| 量词 | 说明 |
|---|---|
{n} | 之前的元素必须出现 n 次 |
{n,m} | 之前的元素最少出现 n 次,最多出现 m 次 |
{n,} | 之前的元素最少出现 n 次,最多无上限(隐式上限 65536,2 的 16 次方) |
{0,n} | 之前的元素可以不出现,也可以出现,最多出现 n 次 |
注意:量词结构中的逗号,之后不能有空格。
以上提到的术语:
- 结构:一般指的是正则表达式所提供功能的记法。比如字符组就是一种结构。
- 元素:指的是具体的正则表达式中的某个部分,比如某个具体表达式中的字符组
[a-z],可以算作一个元素。元素,也叫“子表达式”。
2.2 常用量词
{n,m}是通用形式的量词,正则表达式还有 3 个常用量词,分别是 +、*、?。它们的形态虽然不同于{n,m},功能却是相同的(也可以把它们理解为“量词简记法”)。
正则表达式中常用量词,如下所示:
| 常用量词 | {n,m}等价形式 | 说明 |
|---|---|---|
* | {0,} | 可能不出现,也可能出现,出现次数没有上限 |
+ | {1,} | 至少出现 1 次,出现次数没有上限 |
? | {0,1} | 至多出现 1 次,也可能不出现 |
/^https?$/.test('http'); // true
/^https?$/.test('https'); // true
2.3 .点号
.点号:能匹配除换行符外的任意单个字符。等价于:[^\n\r]。
2.3.1 滥用点号的问题
因为点号能匹配几乎所有的字符,所以实际应用中许多人图省事,随意使用.*或.+,结果却事与愿违,下面以双引号字符串为例来说明。
之前我们使用正则表达式/"[^"]"/来匹配双引号字符串,而“图省事”的做法是/".*"/。通常这么用是没有问题的,但也可能有意外。
'\"quoted string\"'.match(/".*"/g)[0]; // "quoted string"
'"quoted string" and another"'.match(/".*"/g)[0]; // "quoted string" and another"
用/".*"/匹配双引号字符串,不但可以匹配正常的双引号字符串"quoted string",还可以匹配格式错误的字符串"quoted string" and another"。这是为什么呢?
在正则表达式/".*"/中,点号.能匹配除换行符外的任意字符,*表示可以匹配的字符串长度没有限制。所以.*在匹配过程结束之前,每遇到一个字符(除换行符外),.*都可以匹配。但是到底是匹配这个字符,还是忽略它,将其交给之后的"来匹配呢?
具体选择取决于所使用的量词。正则表达式中的量词分为几类,之前介绍的量词都可以归到一类,叫做匹配优先量词(或贪婪量词)。匹配优先量词,就是在拿不准是否要匹配的时候,优先尝试匹配,并且记下这个状态,以备将来“反悔”。
来看表达式/".*"/对字符串"quoted string"的匹配过程。
- 一开始,
"匹配",然后轮到字符q,.*可以匹配它,也可以不匹配。因为使用了匹配优先量词,所以.*先匹配q,并且记录下这个状态【q也可能是.*不应该匹配的】。 - 接下来是字符u,
.*可以匹配它,也可以不匹配。因为使用了匹配优先量词,所以.*先匹配u,并且记录下这个状态【u也可能是.*不应该匹配的】。 - ......
- 现在轮到字符g,
.*可以匹配它,也可以不匹配。因为使用了匹配优先量词,所以.*先匹配g,并且记录下这个状态【g也可能是.*不应该匹配的】。 - 最后是末尾的
",.*可以匹配它,也可以不匹配。因为使用了匹配优先量词,所以.*先匹配",并且记录下状态【"也可能是.*不应该匹配的】。
这时候,字符串之后已经没有字符了,但正则表达式中还有"没有匹配。所以只能查询之前保存备用的状态,看看能不能退回几步,照顾"的匹配。查询到最近保存的状态是:【"也可能是.*不应该匹配的】。于是让.*“反悔”对"的匹配,把"交给",测试发现正好能匹配,所以整个匹配宣告成功。这个“反悔”的过程,专业术语叫做回溯。
如果我们把字符串换成"quoted string" and another",.*会首先匹配第一个双引号之后的所有字符,再进行回溯,表达式中的"匹配了字符串结尾的字符",整个匹配宣告完成。
如果要准确匹配双引号字符串,就不能图省事使用/".*"/,而要使用/"\[^"]\*"/。
2.4 忽略优先量词
有些时候,确实需要用到.*(或者[\s\S]*),比如匹配HTML代码中的Javascript示例就是如此。
// 因为点号.不能匹配换行符,所以必须使用[\s\S](或者[\d\D]、[\w\W])
/<script[\s>][\s\S]*<\/script>/
如果遇到更复杂的情况会出错,比如针对下面这段字符串。
// 假设上面的Javascript字符串保存在变量htmlSource中
htmlsource.match(/<script[\s>][\s\S]*<\/script>/g);
用/<script[\s>][\s\S]*<\/script>/来匹配,会一次性匹配两段Javascript代码,甚至包含之间不是Javascript的代码。
按照匹配原理,[\s\S]*先匹配所有的文本,回溯时交还最后的</script>,整个表达式匹配就成功了,逻辑就是如此,无可改进。这个问题也不能模仿之前双引号字符串匹配,用[^"]匹配<script...>和</script>之间的代码,因为排除型字符组只能排除单个字符,[^</script>]不能表示“不是<script>的字符串”。
换个角度,通过改变[\s\S]*的匹配策略来解决问题:在不确定是否要匹配的场合,先尝试不匹配的选择,测试正则表达式中后面的元素。如果失败,再退回来尝试[\s\S]*匹配,如此就没有问题了。
循着这个思路,正则表达式中还提供了忽略优先量词(或懒惰量词)。如果不确定是否要匹配,忽略优先量词会选择“不匹配”的状态,在尝试表达式中之后的元素。如果尝试失败,再回溯,选择之前保存的“不匹配”的状态。
对于[\s\S]*来说,把*改为*?就是使用了忽略优先量词,*?限定的元素出现次数范围与*完全一样,都表示“可能不出现,也可能出现,出现次数没有上限”。区别在于,在实际匹配过程中,遇到[\s\S]能匹配的字符,先尝试“忽略”,如果后面的元素不能匹配,再尝试“匹配”,这样就保证了结果的正确性。
htmlsource.match(/<script[>\s][\s\S]*?<\/script>/g);
正则表达式中匹配优先量词与忽略优先量词,如下:
| 匹配优先量词 | 忽略优先量词 | 限定次数 |
|---|---|---|
* | *? | 可能不出现,也可能出现,出现次数没有上限 |
+ | +? | 至少出现 1 次,出现次数没有上限 |
? | ?? | 至多出现 1 次,也可能不出现 |
{n,m} | {n,m}? | 出现次数最少为 n 次,至多为 m 次 |
{n,} | {n,}? | 出现次数最少为 n 次,没有上限 |
{0,n} | {0,n}? | 可能不出现,也可能出现,最多出现 n 次 |
2.4.1 应用:提取C语言中注释
C语言的注释有两种:
- 单行注释,以
//开头。 - 多行注释,以
/*开头,以*/结尾。
// 匹配单行注释正则表达式
/\/\/.*/
// 匹配多行注释正则表达式
/\/\*[\s\S]*?\*\//
2.4.2 应用:提取网页中的超链接
常见的超链接形似<a href="http://somehost/somepath">text</a>。它是以<a开头,以</a>结束,href属性是超链接的地址。
// 正则表达式
/<a\s[\s\S]+?<\/a>/
2.4.3 应用:拆解Linux/Unix/MacOS/Windows的路径
Linux/Unix/MacOS下的文件名类似/usr/local/bin/python这样,它包含两个部分:路径/usr/local/bin/,文件名python。
// 提取路径
/^.*\//
// 提取文件名
/[^/]+$/
Windows下路径的分隔符是\,比如C:\Program Files\Python 2.7.1\python.exe。
// 提取路径
/^.*\\/
// 提取文件名
/[^\\]+$/
2.5 量词转义
在正则表达式中,
+、*、?等字符作为量词,具有特殊意义。但有些情况下,我们需要的就是这些字符本身,此时就必须使用转义,也就是在它们之前添加反斜线\。
常用量词所使用的字符 +、*、? 。如果希望表示这三个字符本身,直接添加反斜线,变为 \+、\*、\? 即可。
一般形式的量词 ,比如{n,m},虽然具有特殊含义的字符不止一个,转义时却只需要给第一个{添加反斜线即可。如果希望匹配字符串{n,m},正则表达式必须写成\{n,m}。
忽略优先量词 字符串中也包含不止一个特殊含义的字符,在转义时却不像一般形式的量词那样,,只转义第一个字符即可,而需要将两个量词全部转义。例如:如果要匹配字符串*?,则正则表达式就必须写成\*\?。
正则表达式中各种量词字符串的转义,如下:
| 量词字符/字符串 | 转义形式 |
|---|---|
{n} | \{n} |
{n,m} | \{n,m} |
{n,} | \{n,} |
{0,n} | \{0,n} |
+ | \+ |
* | \* |
? | \? |
+? | \+\? |
*? | \*\? |
?? | \?\? |
{n}? | \{n}\? |
{n,m}? | \{n,m}\? |
{n,}? | \{n,}\? |
{0,n}? | \{0,n}\? |
点号 . 也是一个元字符,它可以匹配除换行符外的任意字符。如果要匹配点号本身,必须将它转义为\.。
/^\+$/.test('+'); // true
/^\*$/.test('*'); // true
/^\?$/.test('?'); // true
/^\{6}$/.test('{6}'); // true
/^\{6,8}$/.test('{6,8}'); // true
/^\{0,8}$/.test('{0,8}'); // true
/^\{0,8}\?$/.test('{0,8}?'); // true
/^\{6,8}\?$/.test('{6,8}?'); // true
/^\{6}\?$/.test('{6}?'); // true
/^\+\?$/.test('+?'); // true
/^\*\?$/.test('*?'); // true
/^\?\?$/.test('??'); // true
/^\.$/.test('.'); // true
第 3 章 括号
3.1 分组
用正则表达式匹配身份证号码,依靠字符组和量词能不能做到呢?
身份证号码是一个长度值为 15 或 18 个字符的字符串。
- 如果是 15 位,则全部由数字组成,首位不能为0。
- 如果是 18 位,则前 17 位全部是数字,首位同样不能是0,末位可能是数字,也可能是字母x。
只要以 15 位身份证号码的匹配为基础,末尾加上可能出现的
\d{2}[\dx]即可。最后的\d{2}[\dx]必须作为一个整体,或许不出现(15位号码),或许出现(18位号码)。量词?可以表示“不出现,或者出现 1 次”。
正则表达式\d{2}[\dx]?是不行的,因为量词?只能限定[\dx]的出现,而正则表达式\d{2}?[\dx]?同样不行。
使用括号(),把正则表达式改写为/^[1-9]\d{14}(\d{2}[\dx])?$/。
量词限定之前元素的出现,这个元素可能是一个字符,也可能是一个字符组,还可能是一个表达式。如果把一个表达式用括号包裹起来,这个元素就是括号里的表达式,括号里的表达式通常称为子表达式。
括号的这种功能叫做分组。如果用量词限定出现次数的元素不是字符或字符组,而是连续的几个字符甚至子表达式,就应该用括号将它们“编为一组”。比如,希望字符串ab重复出现一次以上,就应该写作(ab)+,此时(ab)成为一个整体。
有了分组,就可以准确表示“长度只能是m 或 n”。
关于括号的分组功能,最后来看E-mail地址的匹配:E-mail地址以@分隔成两段,@之前的是用户名,之后的是主机名。用户名@主机名
用户名的匹配非常简单,其中能出现的字符主要有大写字母[A-Z]、小写字母[a-z]、阿拉伯数字[0-9]、下划线_、点号.,所以总的字符组就是[A-Za-z0-9_.],又可以简化为[\w.]。用户名的最大长度是 64 个字符,所以匹配用户名的正则表达式就是[\w.]{1,64}。
主机名匹配的情况则要麻烦一些,简单的情况比如somehost.net,复杂的情况则还包括子域名,比如mail.somehost.net,而且子域名可能不止一级,比如mail.sub.somehost.net。查阅规范可知,主机名被点号分隔成为若干段,叫做域名字段,每个域名字段中能出现的字符是字母字符、数字字符和横线字符,长度必须在1~63之间。
最后的域名字段是顶级域名,之前的部分可以看做某种模式的重复:该模式由域名字段和点号组成,域名字段在前,点号在后。匹配域名字段的表达式是[A-Za-z0-9-]{1,63},匹配点号的表达式是\.。使用括号的分组功能,把两个表达式分为一组,用量词*限定表示“不出现,或出现多次”,就得到匹配主机名的表达式([A-Za-z0-9-]{1,63}\.)*[A-Za-z0-9-]{1,63}。(顶级域名也是一个域名字段,所以即便主机名是localhost,也可以由最后那个匹配域名字段的表达式匹配)
将匹配用户名的表达式、@符号、匹配主机名的表达式组合起来,就得到了完整的匹配E-mail地址的表达式:
/^[\w.]{1,64}@([A-Za-z0-9-]{1,63}\.)*[A-Za-z0-9-]{1,63}$/
3.2 多选结构
之前用表达式[1-9]\d{14}(\d{2}[\dx])?匹配身份证号,思路是把 18 位号码多出的 3 位“合并”到匹配 15 位号码的表达式中。
能不能直接分情况处理呢?15位身份证号就是[1-9]开头,之后是 14 位数字。18 位身份证号就是[1-9]开头,之后是 16 位数字,最后是[0-9x]。只要两个表达式中的一个能够匹配,就是合法的身份证号。
答案是可以的,而且仍然使用括号解决问题,只是要用到括号的另一个功能:多选结构。
多选结构的形式是(...|...),在括号内以竖线 | 分隔开多个子表达式,这些子表达式也叫多选分支。在一个多选结构内,多选分支的数目没有限制。在匹配时,整个多选结构被视为单个元素,只要其中某个子表达式能够匹配,整个多选结构的匹配就能成功。如果所有子表达式都不能匹配,则整个多选结构匹配失败。
身份证号码既然可以区分15位和18位两种情况,就可以将每种情况对应的表达式作为一个分支,“合并”为多选结构。
// 正则表达式
/^([1-9]\d{14}|[1-9]\d{16}[\dx])$/
多选结构在实际中经常用到,匹配IP地址就是如此:IP地址(暂不考虑IPv6)分为4段(4字节),每段都是8位二进制,换算成十进制,取值在0 ~ 255之间,中间以点号.分隔。
| 多选分支描述 | 正则表达式 |
|---|---|
| 如果是 1 位数,那么对数字没有限制 | [0-9] |
| 如果是 2 位数,那么第 1 位数字只能是1 ~ 9,第 2 位数字没有限制 | [1-9][0-9] |
| 如果是 3 位数,且第 1 位数字是1,那么第 2、3 位数字没有限制 | 1[0-9][0-9] |
| 如果是 3 位数,且第 1 位数字是2,第 2 位数字是0 ~ 4,那么第 3 位数字没有限制 | 2[0-4][0-9] |
| 如果是 3 位数,且第 1 位数字是2,第 2 位数字是5,那么第 3 位数字只能是0 ~ 5 | 25[0-5] |
// 精确匹配 0 ~ 255 之间的字符串
/^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$/
如果要更完善一点,能识别03、030、005这样的数值,可以修改对应的子表达式,为一位数和两位数的情况在前面增加可能出现0的匹配。
/^(0{0,2}?\d|0?[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$/
多选结构是正则表达式匹配数值,在某个范围内的通用模式。例如:匹配月(1 ~ 12)、日(1 ~ 31)、小时(0 ~ 23)、分钟(0~59)的正则表达式。
| 多选分支描述 | 正则表达式 |
|---|---|
| 月 | (0?[1-9]|1[0-2]) |
| 日 | 0?[1-9]|[12][0-9]|3[01] |
| 小时 | 0?[0-9]|1[0-9]|2[0-3] |
| 分钟 | 0?[0-9]|[1-5][0-9] |
关于多选结构,最后还要补充三点:
第一,多选结构的一般表示法是(option1|option2)(其中option1和option2是两个作为多选分支的正则表达式),在多选结构中一般会同时使用括号()和竖线|。但是如果没有括号(),只出现竖线|,仍然是多选结构。例如:ab|cd既可以匹配ab,也可以匹配cd。
/ab|cd/.test('ab'); // true
/ab|cd/.test('cd'); // true
在多选结构中,竖线|用来分隔多选结构,而括号()用来规定整个多选结构的范围。如果没有出现括号,则将整个表达式视为一个多选结构。所以ab|cd等价于(ab|cd)。
推荐明确写出括号,这样更形象,也能避免一些错误。
/^ab|cd$/.test('bcab'); // false
因为竖线|的优先级很低,所以^ab|cd$等价于(^ab|cd$),而不是^(ab|cd)$。它的真正意思是匹配“字符串开头的ab或者字符串结尾的cd”,而不是匹配“只包含ab或cd的字符串”。
第二,多选结构不等于字符组。多选分支看起来类似字符组,如[abc]能匹配的字符串和a|b|c一样。从理论上说,可以完全用多选结构替换字符组,但这种做法并不推荐。理由在于:首先,[abc]比a|b|c要简洁许多,在多选结构中的每个分支都必须明确写出,不能使用-范围表示法。其次,在大多数情况下,[abc]比a|b|c的效率要高很多。所以能用字符组解决的问题,最好不要用多选结构。
反过来,字符组不一定能对应到多选结构。因为字符组的每个“分支”的长度相同,而且只能是单个字符。而多选结构的每个“分支”的长度没有限制,甚至可以是复杂的表达式,比如(abc|b+c*ab),字符组完全无能为力。
多选结构和字符组的另一点重要区别是:多选结构无法表示排除型字符组。比如[^abc]表示“匹配除a、b、c之外的任意字符”,而(^a|b|c)表示“匹配以a开头的字符串,或匹配字符b,或匹配字符c”。
第三,多选结构的排列是有讲究的。比如这个表达式(jeff|jeffrey),用它匹配字符串jeffrey。结果到底是jeff还是jeffrey呢?这个问题没有标准答案,取决于编程语言。一般多选结果都会优先选择最左侧的分支。正则表达式(jeff|jeffrey)还是(jeffrey|jeff),结果是不一样的。
'jeffrey'.match(/jeff|jeffrey/); // 'jeff'
'jeffrey'.match(/jeffrey|jeff/); // 'jeffrey'
在平时使用中,如果出现多选结构应当尽量避免多选分支中存在重复匹配。因为这样会大大增加回溯的计算量。也就是说,应当避免这样的情况:针对多选结构(option1|option2),某段文本既可以由option1匹配,也可以由option2匹配。如果出现了这样的多选结构,效率可能会受到极大影响,尤其在受量词限定的多选结构中更是如此。([0-9]|\w)之类的一不留神就会遇到。
3.3 引用分组
括号不仅能把有联系的元素归拢起来并分组,还有其他的作用———使用括号之后,正则表达式会保存每个分组真正匹配的文本,等到匹配完成后,通过编号num“引用”分组在匹配时捕获的内容。
其中,num表示对应括号的编号,括号分组的编号规则是从左向右计数,从 1 开始。
因为正则表达式的分组“捕获”了文本,并保存下来,所以这种功能叫做捕获分组。
我们经常遇到诸如2010-12-22、2011-01-03这类表示日期的字符串,希望从中提取出年、月、日之类的信息,就可借助捕获分组来实现。在正则表达式中,每个捕获分组都有一个编号,具体情况如下所示:
一般来说,正则表达式匹配完成之后,会得到一个表示“匹配结果”的对象。通过分组编号num,可以得到对应分组匹配的文本。如果匹配成功,会返回一个列表(或数组)。如果匹配失败,会返回空值。
'2023-10-13'.match(/(\d{4})-(\d{2})-(\d{2})/)[1]; // '2023'
'2023-10-13'.match(/(\d{4})-(\d{2})-(\d{2})/)[2]; // '10'
'2023-10-13'.match(/(\d{4})-(\d{2})-(\d{2})/)[3]; // '13'
分组的编号从 1 开始。不过,也有编号为 0 的存在,对应整个表达式匹配的文本。
'2023-10-13'.match(/(\d{4})-(\d{2})-(\d{2})/)[0]; // '2023-10-13'
有些正则表达式里可能包含嵌套的括号。但无论括号如何嵌套,分组的编号都是根据括号出现顺序来计数的。开括号是从左向右数起第多少个括号,整个括号分组的编号就是多少。如图所示:
'2023-10-13'.match(/((\d{4})-(\d{2})-(\d{2}))/)[0]; // '2023-10-13'
'2023-10-13'.match(/((\d{4})-(\d{2})-(\d{2}))/)[1]; // '2023-10-13'
'2023-10-13'.match(/((\d{4})-(\d{2})-(\d{2}))/)[2]; // '2023'
'2023-10-13'.match(/((\d{4})-(\d{2})-(\d{2}))/)[3]; // '10'
'2023-10-13'.match(/((\d{4})-(\d{2})-(\d{2}))/)[4]; // '13'
需要注意:引用分组时,引用的是分组对应括号内,表达式捕获的文本。而非表达式。例如:
'2023-10-13'.match(/(\d{4})-(\d{2})-(\d{2})/)[1]; // '2023'
'2023-10-13'.match(/(\d){4}-(\d{2})-(\d{2})/)[1]; // '3'
在第一个表达式中,编号为1的分组对应的括号是(\d{4}),其中\d{4}是“匹配4个数字字符”的子表达式。第二个表达式中,编号为1的分组对应的括号是(\d),其中的\d是“匹配1个数字字符”的子表达式。因为之后有量词{4},所以要匹配 4 次数字字符,而且分组编号都是1。于是每次匹配数字字符,就要重新保存分组编号1匹配的结果。所以在匹配过程中,编号为1的分组匹配文本的值,依次是2、0、2、3,最后的结果是3。
引用分组捕获的文本,不仅仅用于数据提取,也可以用于替换。比如希望将YYYY-MM-DD格式的日期变为MM/DD/YYYY格式,就可以使用正则表达式替换。
在Javascript语言中进行正则表达式替换的方式是str.replace(pattern, replacement),其中str是要进行替换操作的字符串,pattern是用来匹配被替换文本的表达式,replacement是要替换成的文本。例如:
'1a2b3c'.replace(/[a-z]/, ' '); // '1 2 3 '
在replacement中也可以引用分组。形式是$num,其中的num是对应分组的编号。replacement并不是一个正则表达式,而是一个普通字符串。
'2023-10-13'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$2/$3/$1'); // '10/13/2023'
如果想在replacement中引用整个表达式匹配的文本,不能使用$0,需使用$&。例如:
'2023-10-13'.replace(/(\d{4})-(\d{2})-(\d{2})/, '[$&]'); // '[2023-10-13]'
3.3.1 反向引用
英文的不少单词中都有重叠出现的字母,比如shoot。如果希望检查某个单词是否包含重叠出现的字母呢?
“重叠出现”的字母(不考虑大写),取决于第一个[a-z]在运行时的匹配结果,而不能预先设定。也就是说后面的部分必须“知道”前面部分匹配的内容。如果前面的[a-z]匹配的是e,后面就只能匹配e。
引用分组,能引用正则表达式中某个分组内子表达式匹配的文本,但引用都是在匹配完成后进行的,能不能在正则表达式中引用呢?是可以的,这种功能被称为反向引用。
反向引用,它允许在正则表达式内部引用之前捕获分组匹配的文本。其形式也是\num,其中num表示所引用分组的编号。例如:
/^(a-z)\1$/.test('aa'); // true
需要注意:反向引用,引用的是对应捕获分组匹配的文本,而不是之前的表达式,它本身并不规定文本的特征。例如:
/^([a-z]\1+)$/.test('aa'); // true
/^([a-z]\1+)$/.test('aaa'); // true
/^([a-z]\1+)$/.test('aab'); // false
对分组的引用可能出现在三种场合:
- 在匹配完成后,用num从匹配结果中提取数据。
0表示整个正则表达式匹配的结果。从1开始,表示分组编号。 - 在正则表达式替换时,在Javascript语言中,用
$num提取捕获分组匹配的文本,num从1开始,表示分组编号。$&表示整个正则表达式匹配的结果。 - 在正则表达式内部,用
\num引用之前捕获分组匹配的文本,num从1开始目标是分组编号。
3.3.2 命名分组
无论是在正则表达式替换时用$num还是在正则表达式内部用\num,都可能遇到二义性的问题:如果出现了\10(或者$10,这里以\num为例),它到底表示第 10 个捕获分组\10。还是第 1 个捕获分组\1,之后跟着一个字符0呢?
Javascript中对\num中的num是这样规定的:如果是一位数,则引用对应的捕获分组。如果是两位数且存在对应捕获分组时,引用对应的捕获分组。如果不存在对应的捕获分组,则引用一位编号的捕获分组。
也就是说,如果确实存在编号为10的捕获分组,则\10引用此捕获分组匹配的文本。否则\10表示“第一个捕获分组匹配的文本” 和 “字符0”。
// 存在10分组
'0123456789'.replace(/^(\d)(\d)(\d)(\d)(\d)(\d)(\d)(\d)(\d)(\d)$/, '$10'); // '9'
// 不存在10分组
'012345678'.replace(/^(\d)(\d)(\d)(\d)(\d)(\d)(\d)(\d)(\d)$/, '$10'); // '00'
有一个问题无法解决:如果存在编号为10的捕获分组,无法用\10表示“编号为1的捕获分组和字符0”,因为此时\10表示的必然是编号为10的捕获分组。
同时捕获分组用数字编号来标识,不够直观,虽然规则是“从左向右按照开括号出现的顺序计数”,但括号多了难免混淆。
为了解决这类问题,一些语言和工具提供了命名分组。命名分组的标识是容易记忆和辨别的名字,而不是数字编号。
在Javascript中用(?<name>regex)来分组的,其中name是赋予这个分组的名字,regex则是分组内的正则表达式。例如:
// 给年、月、日的分组分别命名
/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
'2023-10-13'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
因为数字编号分组的历史更长,为保证向后兼容性,即便使用了命名分组,每个命名分组同时具有数字编号,其编号规则没有变化。在全部使用命名分组的情况下,仍然可以使用数字编号来引用分组。
在Javascript中,如果使用了命名分组,在表达式中反向引用时,必须使用\k<name>的记法。而要进行正则表达式替换,则需要写作$<name>,其中的name是分组的名字。例如:
'aabc'.replace(/(?<char>[a-z])\k<char>/, '$<char>'); // 'abc'
3.4 非捕获分组
到目前为止,总共介绍了括号的三种用途:
- 分组,将相关的元素归拢到一起,构成单个元素。
- 多选结构,规定可能出现的多个子表达式。
- 引用分组,将子表达式匹配的文本存储起来,供之后引用。
这三种用途并不是彼此独立的,而是相互重叠的:单纯的分组可以视为“只包含一个多选分支的多选结构”。整个多选结构也会被视为单个元素,可以由单个量词限定。
最重要的是,无论是否需要引用分组,只要出现了括号,正则表达式在匹配时就会把括号内的子表达式存储起来,提供引用。如果并不需要引用,保存这些信息无疑会影响正则表达式的性能。如果表达式比较复杂,要处理的文本又很多,更可能严重影响性能。
为解决这种问题,正则表达式提供了非捕获分组,非捕获分组类似普通的捕获分组,只是在开括号(后紧跟一个问号和冒号 (?:...),这样的括号叫做非捕获型括号。它只能限定量词的作用范围,不捕获任何文本。
在引用分组时,分组的编号同样会按开括号出现的顺序从左到右递增,只是必须以捕获分组为准,会略过非捕获分组。例如:
'2023-10-13'.match(/((\d{4})-(\d{2}))-(\d{2})/)[1]; // '2023-10'
'2023-10-13'.match(/(?:(\d{4})-(\d{2}))-(\d{2})/)[1]; // '2023'
非捕获分组不需要保存匹配的文本,整个表达式的效率也因此提高。如果只需要使用括号的分组或者多选结构的功能,而没有用到引用分组,则应当尽量使用非捕获型括号。
3.5 补充
3.5.1 转义
之前讲到,如果元字符是单个出现的,直接添加反斜线字符
\转义即可。所以*、+、?的转义形式分别是\*、\+、\?。如果元字符是成对出现的,则有可能只对第一个字符转义,比如{6}和[a-z]的转义分别是\{6}和\[a-z]。
括号的转义与它们都不同,与括号有关的所有三个元字符 (、)、| 都必须转义。因为括号非常重要,所以无论是开括号还是闭括号,只要出现,正则表达式就会尝试寻找整个括号。如果只转义了开括号而没有转义闭括号,一般会报告“括号不匹配”的错误。另一方面,多选结构中的|也必须转义(多选结构可以不用括号只出现|),所以,也不要忘记对|的转义。例如:
/\(a|b\)/.test('(a|b)'); // false
/\(a\|b\)/.test('(a|b)'); // true
3.5.2 URL Rewrite
提到括号的分组和引用功能,就不能不提到URL Rewrite。URL Rewrite是常见Web服务器中都具备的功能,它用来进行网址的转发。
下面是一个转发的例子:
// 外部访问 URL
http://www.example.com/blog/2006/12
// 内部实现
http://www.example.com/blog/posts.php?year=2006&month=12
一般来说,URL Rewrite都是使用转发规则实现的,每条转发规则对应一类URL。以正则表达式解析并提取所需要的信息,重组之后再转发。比如上面的转发,就需要先提取年、月的信息再进行重组。
对转发的URL而言,能接收的都是path部分,如果URL是http://www.example.com/blog/2006/12,则path 就是blog/2006/12。正则表达式如下:
/^blog\/(\d{4})\/(\d{2})\/?$/
因为path最后可能出现blog/2006/12/格式,所以使用了量词\/?。
以Nginx服务器配置为例:
在Nginx.conf配置文件中找到对应虚拟主机的配置字段,在其中添加下面的规则:
rewrite ^blog\/(\d{4})\/(\d{2})\/?$ blog/posts.php?year=$1&month=$2 last;
在Nginx中,使用$num的记法引用分组,其中num为分组对应的编号。
3.5.3 经典例题
'aaa.bbb.ccc'.match(/(\w+\.?)+/);
num为0时,表示整个正则表达式匹配的结果。num为1时,表示正则表达式中第一个捕获分组匹配的结果。是否符合你的预期呢?
正则表达式/(\w+\.?)+/中存在量词+,所以在整个正则表达式的匹配过程中,括号内的\w+\.?会多次匹配,每次匹配,都会更新第 1 个捕获分组的保存结果。
- 第 1 次匹配:
aaa.,第 1 个捕获分组的保存结果为aaa.。 - 第 2 次匹配:
bbb.,第 1 个捕获分组的保存结果为bbb.。 - 第 2 次匹配:
ccc,第 1 个捕获分组的保存结果为ccc。
因此整个表达式匹配结果为aaa.bbb.ccc,第一个捕获分组的匹配结果为ccc。
如果字符串是aaa.bbb,或者aaa.bbb.ccc.ddd。如何能用一个表达式,逐个拆分aaa.、bbb.之类的子串呢?(需要注意的是,子串的个数是变化的,并且不能预先知道。)
要搞清楚这个问题,需要记住:捕获分组的个数是不能动态变化的———单个正则表达式里有多少个捕获分组,一次匹配成功之后,结果中就必然存在多少个对应的元素(捕获分组匹配的文本)。
如果不能预先知道匹配结果中元素的个数,就不能使用捕获分组。如果要匹配数目不定的多段文本,必须通过改变每次匹配的起始位置,多次匹配完成。
具体到以上的问题,使用\w+\.?正则表达式,进行重复匹配。
aaa.bbb.ccc字符串,整个正则表达式重复成功匹配 3 次,成功得到 3 个子串。aaa.bbb.ccc.ddd字符串,整个正则表达式重复成功匹配 4 次,成功得到 4 个子串。
'aaa.bbb.ccc'.match(/\w+\.?/g);
'aaa.bbb.ccc.ddd'.match(/\w+\.?/g);
字符 g 是正则匹配的模式,表示全局匹配。第一次匹配,从字符串索引为0的位置开始匹配。若是匹配成功,则第二次开始匹配的索引位置等于第一次匹配的索引位置加上匹配文本的长度。依次往下匹配,直到匹配不成功,结束匹配。开始匹配的起始索引再度设置为0。
以'aaa.bbb.ccc'.match(/\w+\.?/g)例,描述整个匹配过程:
| 匹配次数 | 匹配起始索引 | 匹配成功 | 匹配结果 | 匹配位置 | 下次匹配索引 |
|---|---|---|---|---|---|
| 第 1 次 | 0 | 成功 | aaa. | 0 | 0 + 4 = 4 |
| 第 2 次 | 4 | 成功 | bbb. | 4 | 4 + 4 = 8 |
| 第 3 次 | 8 | 成功 | ccc | 8 | 8 + 3 = 11 |
| 第 4 次 | 11 | 失败 | 0 |
第 4 章 断言
正则表达式中的大多数结构,匹配的文本会出现在最终的匹配结果中。但是也有些结构并不真正匹配文本,而只负责判断在某个位置左/右侧的文本是否符合要求,这种结构被称为断言。
常见的断言有三类:
- 单词边界
- 行起始/结束位置
- 环视
4.1 单词边界
在文本处理中经常可能进行单词替换,比如把一段文本中的 row 都替换成 line。一般想到的是调用字符串的替换方法,直接替换row。
替换前:The row we are looking for is row 10.
替换后:The line we are looking for is line 10.
不过,这样替换也可能会造成意想不到的后果。
替换前:…tomorrow I will wear in brown standing in row 10 next to the rowdy guy…
替换后:…tomorline I will wear in blinen standing in line 10 next to the linedy guy…
不仅所有单词 row 都被替换成了 line, tomorrow 和 rowdy 两个单词内部的 row 也被替换成了 line,这显然不是我们想要的结果。
要解决这个问题,必须有办法确定单词 row,而不是字符串 row。
为解决这类问题,正则表达式提供了专用的单词边界,记为 \b。
\b匹配的是“单词边界”位置,而不是字符。也就是说 \b 能够匹配这样的位置:一侧是单词字符,另一侧不是单词字符。
row 配合单词边界 \b 之后的匹配情况,如下所示:
- 单词边界并不区分左右,可能只有左侧是单词字符,也可能只有右侧是单词字符。总的来说,单词字符只能出现在一侧。
- 单词边界要求“另一侧不是单词字符”。也就是说,一侧必须出现单词字符,另一侧可以出现非单词字符,也可能没有任何字符。
单词边界要求一侧必须出现单词字符,到底什么是单词字符呢?
一般情况下,“单词字符”的解释是 \w 能匹配的字符。\w 只能匹配 [0-9a-zA-Z_] 。所以 \b\w+\b 能准确匹配英文单词。
// 将所有的单词提取出来
'a sentence\tcontains\na lot of words'.match(/\b\w+\b/g);
在Web开发中,经常需要对某些单词标记高亮,一般的做法是在单词的前后加上tag。这个功能可以由单词边界配合正则表达式替换完成。例如:
// 'brown <mark class="hl">row</mark> the rowdy'
"brown row the rowdy".replace(/\brow\b/, '<mark class="hl">$&</mark>');
有些单词,\b\w+\b 是无法匹配的,比如 e-mail 和 M.I.I。因为连字符-和点号.都不能由\w匹配。如果确实希望匹配 e-mail 和 M.I.I 之类的单词,可以把表达式改为 \b[-.\w]+\b。
'e-mail M.I.I'.match(/\b[-.\w]+\b/g); // ['e-mail', 'M.I.I']
单词边界基本符合以下四种类型:
- 非单词字符
\b单词字符\b非单词字符 - 非单词字符
\b单词字符\b没有任何单词 - 没有任何字符
\b单词字符\b没有任何字符 - 没有任何字符
\b单词字符\b非单词字符
与单词边界\b对应的还有非单词边界\B,两者的关系类似\s和\S、\w和\W、\d和\D。\B左右两侧都是单词字符。
在同一种语言中,不管\b是如何规定的:
\b能匹配的位置,\B就不能匹配。\B能匹配的位置,\b就不能匹配。
4.2 行起始/结束位置
单词边界 匹配的是某个位置而不是文本。在正则表达式中,这类匹配位置的元素叫做 锚点,它用来“定位”到某个位置。
除了刚才介绍的
\b,常用的锚点还有^和$。它们分别匹配字符串的 开始位置 和 结束位置,所以可以用来判断“ 整个字符串能否由表达式匹配 ”。
在编辑文本时,敲回车键就输入 行终止符,结束当前行,新起一行。不同平台下的行终止符:
| 平台 | 行终止符 |
|---|---|
| Unix/Linux | \n |
| Windows | \r\n |
| Mac OS | \n |
为了让换行符“可见”,用 [NL] 表示。字符串多行模式如下:
4.2.1 行起始位置
一般情况下,
^匹配整个字符串的起始位置。
依靠 ^ ,就可以用正则表达式^Some准确验证字符串“是否以Some开头”,因为^会把整个表达式的匹配“定位”在字符串的开始位置。
| 字符串 | Some sample text first line |
|---|---|
^能匹配的位置 | ↑ |
在某些情况下, ^ 也可以匹配字符串内部的“行起始位置”。
如果把匹配模式设定为多行模式, ^就既可以匹配整个字符串的起始位置,也可以匹配换行符之后的位置。
| 字符串 | first line[NL] middle line[NL] last line[NL] |
|---|---|
^能匹配的位置 | ↑-----------↑---------------↑-------------↑ |
如果字符串的末尾出现了行终止符,^也会匹配这个行终止符之后的位置。
// 提取每行的第一个单词
'first line\nsecond line\r\n\rlast line'.match(/^\b\w+\b/gm); // ['first', 'second', 'last']
// 在每行的开头位置标注$字符
'first line\nsecond line\r\n'.replace(/^/mg, '$'); // '$first line\n$second line\r$\n$'
4.2.2 行结束位置
一般情况下,
$匹配整个字符串的结束位置。
依靠 $ ,就可以用正则表达式line$准确验证字符串“是否以line结尾”,因为$会把整个表达式的匹配“定位”在字符串的结尾位置。
| 字符串 | Some sample text first line |
|---|---|
$能匹配的位置 | --------------------------- ↑ |
在某些情况下, $ 也可以匹配字符串内部的“行结束位置”。
如果把匹配模式设定为多行模式, $就既可以匹配整个字符串的结束位置,也可以匹配换行符之前的位置。
| 字符串 | first line[NL]middle line[NL]last line[NL] |
|---|---|
^能匹配的位置 | -------- ↑ --------------↑ -----------↑ --↑ |
如果字符串的末尾出现了行终止符,$既会匹配这个行终止符之前的位置,也会匹配整个字符串的结束位置。
// 提取每行的最后一个单词
'first line\nsecond line\r\n\rlast line'.match(/\b\w+\b$/gm); // ['line', 'line', 'line']
// 在每行的结束位置标注$字符
'first line\nsecond line\r\n'.replace(/$/mg, '$'); // 'first line$\nsecond line$\r$\n$'
4.2.3 ^和$应用
非多行模式匹配:
^匹配整个字符串的起始位置。$匹配整个字符串的结束位置。多行模式匹配:
^不仅匹配整个字符串的起始位置,还匹配换行符之后的位置。$不仅匹配整个字符串的结束位置,还匹配换行符之前的位置。
借助^和$完成数据验证
/^\d{6}$/.test('012345'); // true
/^\d{6}$/.test('a012345b'); // false
最常用到数据验证的场合就是对用户提交的数据进行验证。
在^和$位置插入字符串内容
'line1\nline2\nline3'.replace(/^/mg, '<p>').replace(/$/mg, '</p>');
借助^和$去除行首尾多余空白字符(为方便识别空白,在行的首尾分别用「和」标识)
如果要整理格式,需要删掉不必要的空白字符,但又不能把所有空白字符都删除(单词与单词之间的空白字符应当保留)。所以要做的其实是删除行首和行尾的空白字符。先删除行首的空白字符,使用的正则表达式是
/^\s+/ 。
这里必须使用多行模式,否则就只能删除整个字符串首尾的空白字符。另一方面,此处使用了量词+而不是*,因为/^\s*/可以不匹配任何字符,这样的“删除”没有意义。将/^\s+/匹配的文本替换为空字符串,就执行了删除操作。(正则表达式应用中没有单独的“删除”操作,删除操作都是通过将文本替换为空字符串实现的)
// 去除行首的空白字符
' begin\n between\t\n\nend '.replace(/^\s+/mg, ''); // 'begin\nbetween\t\nend '
现在来删行尾的空格,使用表达式/\s+$/,同样要使用多行模式。
// 去除行尾的空白字符
'begin\nbetween\t\nend '.replace(/\s+$/mg, ''); // 'begin\nbetween\nend'
能不能用多选结构 /(^\s+|\s+$)/ 并列两个表达式,一步完成呢?是不能的。
' begin\n between\t\n\nend '.replace(/^\s+|\s+$/mg, ''); // 'begin\nbetweenend'
多选结构合并多个表达式时,一定要小心未曾预期的后果。有时候,分几步进行反而能省去许多麻烦。
4.3 环视
单词边界匹配的是这样的位置:一侧是单词字符,另一侧不是单词字符(可能是非单词字符,也可能没有任何字符)。从另一个角度来看,它能进行这样的判断:在某个位置向左/向右看,必须出现或不是某类字符。
用表达式<[^/>][^>]*>匹配 open tag,它保证了<之后不会出现/,这样就排除了</img>之类的close tag,但它也可以匹配 self-closing tag,比如<br/>。
如果将表达式改为<[^/][^>]*[^/]>,又会有一个问题,在<和>中的[^/][^>]*[^/],能匹配的文本至少包含两个字符,所以它无法匹配<u>。
用正则表达式
<[^/]([^>]*[^/])?>解决了这个问题。
也可以从另外一个角度描述:
- 在开始位置匹配
<,同时要求这个<之后不能是/。 - 然后匹配中间的文本,除非在属性(也就是引号字符串)中,否则不能出现
>,且长度必须大于 1(<>是不合法的 tag)。 - 最后匹配
>,同时要求这个>之前不能是/。
可用三个子表达式分别匹配这三部分:开头的<、中间的内容、结尾的>。但必须解决一个问题:匹配开头的<时,除去找到<字符,还必须向后(向右)看看,确认字符不能是/,同时又不能真正匹配这个字符,因为<和>中间的文本是有单独的子表达式匹配的。同样,结尾>的匹配也是如此。
针对这种要求,正则表达式专门提供了环视用来“停在原地,四处张望”。环视类似单词边界,在它傍边的文本需要满足某种条件,而且本身不匹配任何字符。
比如正则表达式<(?!/),其中的(?!/)是一个环视结构,(?!...)是这个结构的标识,/才是真正的表达式,整个结构的意思是“在当前位置之后(右侧),不允许出现/能匹配的文本”。看起来它和<[^/]类似,其实大不相同:如果<(?!/)匹配成功,正则表达式真正匹配完成的只有<,而不包括>之后的那个字符。这样就能准确表示“匹配<,同时这个<之后不能是/”。
再来看表达式(?<!/)>,其中(?<!/)也是一个环视结构,(?<!...)是这个结构的标识,/才是真正的表达式,整个结构的意思是“在当前位置之前(左侧),不允许出现/能匹配的文本”。这样,就能准确地表示“匹配>,同时>之前不能是/”。
至于<和>之间的文本,可以用('[^']*'|"[^"]*"|[^'">])+准确匹配。
最后,把这三个部分结合起来,得到正则表达式<(?!/)('[^']*'|"[^"]*"|[^'">])+(?<!/)>,它能准确匹配open tag。
const openTagReg = /^`<(?!/)('[^']*'|"[^"]*"|[^'">])+(?<!/)>`$/;
openTagReg.test('<u>'); // true
openTagReg.test('<br/>'); // false
环视一共分为 4 种:肯定顺序环视、否定顺序环视、肯定逆序环视、否定逆序环视。
| 名字 | 记法 | 判断方向 | 结构内表达式匹配成功的返回值 |
|---|---|---|---|
| 肯定顺序环视 | (?=...) | 向右 | true |
| 否定顺序环视 | (?!...) | 向右 | false |
| 肯定逆序环视 | (?<=...) | 向左 | true |
| 否定逆序环视 | (?<!...) | 向左 | false |
在当前位置:
- 如果是顺时针判断,则是顺序环视。
- 如果是逆时针判断,则是逆序环视。
- 如果要求子表达式能匹配的字符串必须出现,则为肯定环视。
- 如果要求子表达式能匹配的字符串不能出现,则为否定环视。
对于字符串 12345,以\d{3}为表达式的四种环视能匹配的位置分别是:右侧必须出现三个数字字符,右侧不能出现三个数字字符,左侧必须出现三个数字字符,左侧不能出现三个数字字符。如下所示:
环视的最大特点是“匹配完成之后还停在原地”,之前已经看到<(?!/)匹配的其实只有一个<字符,(?<!/)>匹配的也只有一个>字符。
4.3.1 案例:格式化数字字符串
有时候确实需要用到“原地”的判断,因为要寻找的确实只是位置,而不是真正匹配任何字符,比如格式化数字字符串的格式。
英文中的数字更习惯用逗号分隔以方便阅读,比如 12345 应该写作 12,345。如果用正则表达式来完成任务,就是“把逗号添加到这样的位置:右侧的数字字符串的长度是 3 的 倍数”。看起来只需要使用肯定顺序环视就足够了,用正则表达式找到这样的位置(?=(\d{3})+),将它“替换”为逗号。
'12345'.replace(/(?=(\d{3})+)/g, ','); // ,1,2,345
结果却不是想象的那样。因为“右侧数字字符串”严格说应该是“当前位置右侧,所有数字字符构成的字符串”。但是(?=(\d{3})+)并不能表达这个意思,比如第一个字符 1 之前的位置,右侧数字字符串长度为5,但其中存在长度为 3 的子串,所以这个位置可以匹配。同样2、3之前的位置都是如此。
解决这个问题必须配合否定顺序环视,让(\d{3})+能匹配右侧的整个数字字符串,而不能只匹配其中的一个子串。也就是说,要一直匹配到“右侧不再有数字字符的位置”为止。所以,必须将表达式改写为(?=(\d{3})+(?!\d))。
'12345'.replace(/(?=(\d{3})+(?!\d))/g, ','); // 12,345
'123456'.replace(/(?=(\d{3})+(?!\d))/g, ','); // ,123,456
如果字符串的长度正好是 3 的倍数,还是有问题。
字符串的开头多出了一个逗号,因为这个位置右侧的数字字符串长度为 6。更严格地说,要加入逗号的位置其实是这样的:右侧的数字字符串的长度是 3 的倍数,且左侧也是数字字符。所以还需要加上肯定逆序环视,将正则表达式修改为(?<=\d)(?=(\d{3})+(?!\d))。
'123456'.replace(/(?<=\d)(?=(\d{3})+(?!\d))/g, ','); // 123,456
4.3.2 案例:去除中英文混排多余空白字符
我们经常会遇到中英文混排的文本,英文文本需要用空白字符来区分单词,中文文本中则很少出现空白字符。但是在转帖或格式转换时,经常会产生一些多余的空白字符。
为了整理格式,需要删除这些空白字符。正则表达式匹配空白字符很容易,直接用\s+即可。但如果直接删除\s+能匹配的所有文本。
const mixedStr = '中 英文混排,some English word,有多余的空 白字符';
mixedStr.replace(/\s+/g, ''); // '中英文混排,someEnglishword,有多余的空白字符'
所以真正要找的,其实是这样的\s+:从它向左看,不能出现英文字母,从它向右看,也不能出现英文字母。所以需要在\s+的两端分别添加否定逆序环视和否定顺序环视,得到(?<![a-zA-Z])\s+(?![a-zA-Z])。
const mixedStr = '中 英文混排,some English word,有多余的空 白字符';
mixedStr.replace(/(?<![a-zA-Z])\s+(?![a-zA-Z])/g, ''); // '中英文混排,some English word,有多余的空白字符'
这个表达式能不能改一改,比如左侧的否定逆序环视(?<![a-zA-Z]),能不能改为肯定环视,指定出现一个非英文字符(?<=[^a-zA-Z]),右侧的否定顺序环视也改为肯定顺序环视(?=[^a-zA-Z])?
这个问题其实涉及肯定环视和否定环视的一大根本不同:肯定环视要判断成功,字符串中必须有字符由环视结构中的表达式匹配。而否定环视要判断成功,却有两种情况:字符串中出现了字符,但这些字符不能由环视结构中的表达式匹配。或者字符串中不再有任何字符,也就是说,这个位置是字符串的起始位置或结束位置。
const mixedStr = ' 中 英文混排,some English word,有多余的空 白字符 ';
mixedStr.replace(/(?<=[^a-zA-Z])\s+(?=[^a-zA-Z])/g, ''); // ' 中英文混排,some English word,有多余的空白字符 '
如果使用肯定环视,则无法去掉字符串首尾的空白。因为在字符串的开头,\s+虽然能匹配空白字符,但其左侧并没有任何字符,所以(?<=[^a-zA-Z])无法匹配成功。字符串末尾的(?=[^a-zA-Z])也是如此。
4.3.3 案例:准确匹配电子邮件主机名
在电子邮件地址中,更准确地进行主机名验证。
根据规范,主机名以点号分隔为多个域名字段,每个域名字段可以包含大小写字母、数字、横线,但是横线不能出现在开头位置。关于长度,每个域名字段的长度最多为 63 个字符,整个主机名的长度最多为 255 个字符。通常用的表达式是([-a-zA-Z0-9]{1,63}.)*[-a-zA-Z0-9]{1,63},这个表达式有两个问题:第一,它允许域名字段的第一个字符是横线-。第二,它没有限定整个主机名的长度最长为255个字符。为准确匹配主机名,就必须解决这两个问题。
为保证域名字段第一个字符不能是横线,在表达式开始加上否定顺序环视,即(?!-)[-a-zA-Z0-9]{1,63}
为保证整个主机名字符串长度小于 255 个字符,主机名中全部可能出现的字符都用[-a-zA-Z0-9.]表示,所以对应的肯定顺序环视就是(?=[-a-zA-Z0-9.]{0,255})。但是并不能直接把它添加到匹配主机名的整个表达式的开头,因为这个表达式只要求匹配一个长度在 255 个字符以内的字符串,并不能保证“之后的整个字符串长度在255个字符以内”。如果是单独给出一个字符串,验证它是否是合法的主机名,那么可以在这个环视中的表达式末尾添加$。如果是要从一长段文本中提取出某个主机名,那么主机名之后还有其他字符,只是这些字符不能是[-a-zA-Z0-9.](可能是空白字符,也可能在字符串的末尾,之后没有任何字符),使用否定顺序环视(?![-a-zA-Z0-9.])就可以兼顾这两种情况。
/(?=[-a-zA-Z0-9.]{0,255}(?![-a-zA-Z0-9.]))((?!-)[-a-zA-Z0-9]{1,63}.)*(?!-)[-a-zA-Z0-9]{1,63}/
4.4 补充
4.4.1 环视价值
环视有一个很重要的用途,就是避免编写正则表达式时“牵一发动全身”的尴尬——既可以集中关注某个部分,添加复杂的限制,又不会干扰其他部分的匹配。有些时候,为添加某些限制而真正匹配文本,反而会影响整个表达式的匹配。
环视的另一点价值在于,提取数据时杜绝错误的匹配。比如匹配邮政编码,直接的想法是找到 6 位数字构成的字符串,但仅仅用\d{6}提取,很可能在手机号码 13812345678、电话号码 28812506 等其他数据中找到 6 位数字构成的字符串。如果在表达式首尾添加环视,改为(?<!\d)\d{6}(?!\d),就可以保证准确匹配 6 位数字构成的字符串。一般来说,凡是从文本中提取“有长度特征的数据”,都需要用到环视。
还有些时候,可以在匹配的同时以环视施加限制,达到“双管齐下”的效果。比如匹配所有的辅音字母,使用环视写作(?![aeiou])[a-z],[a-z]真正匹配的是一个小写字母,但环视(?![aeiou])同时要求这个字母不能由[aeiou]匹配,最终效果就是“从 26 个字母中减去 5 个辅音字母”。
4.4.2 环视与分组编号
环视结构也要用到括号,这种括号是否会影响到分组编号呢?
分组的编号只与捕获型括号有关,而不受其他任何类型括号的影响。环视结构虽然必须用到括号字符,但这里的括号只是结构需要,并不影响捕获分组。
单纯的环视结构并不影响引用分组
'abcd'.match(/(?!ab)(cd)/);
括号有多种用途,比如表示多选结构。即便括号只表示多选结构,如果没有显式指定为非捕获型括号(?:...),也会被视为捕获型括号。
环视结构中出现了捕获型括号,会影响分组
'abcd'.match(/^(?=(ab|cd))/);
环视结构中指定使用非捕获型括号
'abcd'.match(/^(?=(?:ab|cd))/);
4.4.3 环视的支持程度
常用的语言大都支持环视,但语言不同,支持的程度也不同。一般来说,所有语言都支持两种顺序环视,而且没有限制。也就是说,无论你使用肯定顺序环视,还是否定顺序环视,都可以在其中使用各种复杂的表达式。
Javascript对环视的支持比较复杂。“经典”(ES3~ES6)的Javascript只支持顺序环视,不支持逆序环视。直到ES2017,Javascript中的正则表达式也可以使用逆序环视了。
语言不同,对逆序环视的限制也不相同。逆序环视之所以麻烦,是因为其机制与正常的匹配机制完全不同:它从当前位置开始,由右向左“倒过来”查找可能得匹配。实际的操作过程更像每次从右向左截取一段文本,再判断它能不能由表达式匹配,不行再尝试......这样的过程可能要重复尝试很多次。如果表达式能匹配的文本长度确定,处理的代价很小,否则代价可能很大。所以,比较好的做法是尽量避免在逆序环视中使用复杂的表达式。
4.4.4 环视的组合
环视匹配的并不是字符,而是位置。在正则表达式匹配时,环视结构匹配成功,并不会更改“当前位置”。所以多个环视可以组合在一起,实现在同一个位置的多重判断。
环视中包含环视
最常见的组合是环视中包含环视,比如之前在匹配主机名时,我们限定主机名的长度不能超过 255 个 字符,使用表达式(?=[-a-zA-Z0-9.]{0,255}(?![-a-zA-Z0-9.]))。其中(?![-a-zA-Z0-9.])是包含在外层的环视中的,它要求在这个位置(也就是主机名字符串之后)不能再出现属于主机名字符串的字符,也就是保证之前的表达式匹配整个主机名字符串,而不是“可能的主机名字符串的一部分”。综合起来(?=[-a-zA-Z0-9.]{0,255}(?![-a-zA-Z0-9.]))保证的是“整个主机名字符串的长度在 255 个字符以内”。
并列多个环视
并列多个环视,它要求在当前位置,所有环视的判断都必须成功。比如要找到这样的位置:它之后是一个数字字符串,但不能是 999 开头的数字。这时候,就必须并列两个环视。表示数字字符串的表达式是\d+,对应的环视结构是(?=\d+)。表示“不是 999 开头”的表达式的环视结构是(?!999)。现在要做的是把两个环视并列起来,得到(?=\d+)(?!999)。
因为环视结构不会更改当前位置,所以先后顺序无所谓,无论是(?=\d+)(?!999)还是(?!999)(?=\d+),效果是相同的,都要求同时满足下面两个条件:在当前位置,之后必须出现数字字符串。在当前位置,之后不能出现 999。最终的结果都是对两个环视做 “与运算”,也就说,两个条件必须同时满足才算匹配成功,否则宣告当前位置匹配失败。
/(?=\d+)(?!999)/.test('12334'); // true
最后一种组合是将若干个环视作为多选分支排列在多选结构中。比如要找到这样的位置:它之后要么不是数字字符,要么是一个数字字符和一个非数字字符。“不是数字字符”对应的环视是(?!\d)。而“一个数字字符和一个非数字字符”对应的环视是(?=\d\D)。所以总的环视就是((?!\d))|(?=\d\D)。使用多选结构时,列出的多个环视只要有一个成立,整个判断就成功。不使用多选结构时,所有列出的环视都必须成立,整个判断才成功。
/^((?!\d)|(?=\d\D))/.test('1d'); // true
/^((?!\d)|(?=\d\D))/.test('ab'); // true
4.4.5 断言和反向引用之间的关系
断言不匹配任何字符,只匹配位置。而反向引用只引用之前捕获分组匹配的文本,之前捕获分组中锚点表示的位置信息,在反向引用时并不会保留下来。
反向引用时不会保留断言的判断
/(\bcat\b).*?\1/.test('cat cate'); // true
/(\bcat\b)\s+\b\1\b/.test('cat cate'); // false
/(\bcat\b)\s+\b\1\b/.test('cat cat'); // true
用反向引用匹配重复单词,应该使用(\b\w+\b)\s+\b\1\b。
需要注意:反向引用,仅引用之前捕获分组匹配的文本内容,而捕获分组中的断言都会被忽略。
第 5 章 匹配模式
所谓匹配模式,指的是匹配时遵循的规则。
设置特定的模式,可能会改变对正则表达式的识别,也可能会改变正则表达式中字符的匹配规定。
常用的匹配模式一共有三种:
- 不区分大小写模式
- 单行模式
- 多行模式
5.1 不区分大小写模式 与 模式的指定方式
匹配一段文本中所有的the,不区分大小写?
可以使用表达式/[tT][hH][eE]/进行匹配。但这样写不够直观,也难以阅读。若是匹配的英文单词足够长,那写起来够麻烦的。
为解决这类问题,正则表达式提供了不区分大小写的匹配模式。指定此模式后,在正则表达式中可以直接写the,就可以匹配the、The、THE等各种大小写形式的the。
模式的指定方式,通常有两种:
- 以模式修饰符指定。
- 以预定义的常量作为特殊参数传入来指定。
模式修饰符即模式名称对应的单个字符,使用时将其填入特定结构(?modifier)中(其中modifier为模式修饰符)。几乎所有的语言支持(?modifier),但Javascript不支持模式修饰符(?modifier)。
另一种指定模式的方式是使用预定义的常量作为参数,传入正则函数。 在Python中不区分大小写的预定义常量是re模块的静态成员 re.IGNORECASE。但Javascript和PHP不支持,它们的做法是在正则表达式末尾的分隔符之后加上模式对应的字母(比如不区分大小写模式对应的字母是i,则添加字母i)。
// Javascript中指定不区分大小写的匹配模式
/the/i
常用语言中不区分大小写模式的预定义常量
两种指定匹配模式的形式,模式修饰符较为通用,因为在各种语言中写法基本相同,而预定义常量在不同语言中写法不同。无论以哪种方式,只要指定了不区分大小写模式,正则表达式在匹配时,就不会区分同一个字母的大小写形式。
/the/i.test('The'); // true
5.2 单行模式
元字符点号
.能匹配除换行符外的任何字符,等价于:[^\r\n]。但有时候确实需要匹配“任意字符”,比如处理HTML源代码,经常会遇到跨多行的脚本代码。
这段 Javascript代码中出现了换行符,所以.*?的匹配最多只能延伸到第一行末尾。可以用[\s\S]之类的字符组匹配“任意字符”。所以正则表达式<script\s*[\s\S]*?<\/script>能解决问题。
不过对大多数人来说,点号更自然,也更简洁。所以正则表达式提供了 单行模式。在这种模式下,所有文本似乎只在一行里,换行符也是这一行中的“普通字符”,所以可以由点号.匹配。
单行模式对应的模式修饰符是 s。
单行模式在不同的语言中称呼也不一样,有叫点号通配(dotAll)的,但约定俗成的称呼是“单行模式”。
在Javascript中指定单行模式的匹配:
/./s.test('\n'); // true
5.3 多行模式
“多行模式” 与 “单行模式”没有任何联系,除了听起来是对应的。
单行模式影响的是点号的匹配规则:在默认模式下,点号
.可以匹配除换行符之外的任意字符。但在单行模式下,点号.可以匹配包括换行符在内的任意字符。多行模式影响的是
^和$的匹配规则:在默认模式下,^和$匹配的是整个字符串的起始位置和结束位置。但在多行模式下,它们也能匹配字符串内部换行符之前和之后的位置。
假设,需要找到下面文本中所有数字字符开头的行:
要解决这个问题,需要定位到每行的起始位置,尝试匹配一个数字字符。如果成功,则匹配之后的整行文本。
多行模式的模式修饰符是 m。
'1 line\nNot digit\n2 line'.match(/^\d.*$/gm);
第 6 章 常见问题
6.1 转义
6.1.1 字符串转义 与 正则转义
理解转义的基础是,明白字符串与正则表达式的关系。
通常说的string中,string称为字符串文字,它是某个字符串的值在源代码中的表现形式。比如字符串文字\n,它包含``和n两个字符,意义(或者说它的值)是一个换行符(为方便观察,表示为[NL])。在生成字符串时,应当进行“字符串转义”,才能准确识别字符串文本中\n的意思。
// 字符串值在源代码中表现形式
'hello\nworld'
转义过程:字符串文本(源码) ==> “字符串转义” ==> 字符串(显示效果)
常见字符串转义
| 字符串文字 | 字符串 | 说明 |
|---|---|---|
\n | [NL] | 换行符 |
\t | Tab | 制表符 |
\ | `` | 反斜线字符 |
在源代码中看到的“正则表达式”regex,其中的regex称为正则表达式文本(以下简称正则文字),是正则表达式的表现形式。比如正则表达式\d,其正则文字包含``和d两个字符,它的意义(或者说值)是匹配数字字符的字符组简记法。在生成正则表达式时需要进行“正则转义”,才能将正则文字中的\d识别为字符组简记法。
不少正则表达式都是以字符串形式提供的,所以必须经过从字符串文字到正则表达式的转换。根据上面的介绍,字符串文字必须首先经过“字符串转义”,才能得到真正的字符串。接下来,这个字符串作为正则文字,经过“正则转义”,最终得到正则表达式。字符串转义与正则转义,如下所示:
常见几个字符的字符串转义和正则转义
| 字符串文字 | 字符串/正则文字 | 正则表达式 | 说明 |
|---|---|---|---|
\n | \n | [NL] | 换行符 |
\t | \t | Tab | 制表符 |
\\ | \ | `` | 反斜线字符 |
正则文本是\n和\t,也就是字符串的值。但是从字符串文字必须经过字符串转义才能得到正则文字的\n和\t,根据字符串转义的规则,反斜线字符``必须写成\。所以字符串文字必须写成\n和\t。
如果要表示正则表达式中的,必须使用正则文字`\`,这样在正则转义时才能正确识别。同时,正则文字中的每个都必须用字符串文字\表示,所以正则表达式``对应的字符串文字就是\\。
new RegExp('\\').test('\'); // true
有时候情况更复杂:正则表达式中的换行符或者制表符,在字符串文字中必须写成\n或\t。但是,用\n或\t也没有问题。原因在于,在处理字符串转义时,它们已经被解释为换行符或制表符,所以传递给正则表达式的字符串中就包含了换行符或者制表符。
| 字符串文字 | 字符串/正则文字 | 正则表达式 | 说明 |
|---|---|---|---|
\n | \n | [NL] | 换行符 |
\n | [NL] | [NL] | 换行符 |
\t | \t | Tab | 制表符 |
\t | Tab | Tab | 制表符 |
\\ | \ | `` | 反斜线字符 |
new RegExp('\n').test('\n'); // true
new RegExp('\n').test('\n'); // true
new RegExp('\t').test('\t'); // true
new RegExp('\t').test('\t'); // true
要特别注意的是\b。在一般字符串中,\b是预定义的转义序列,表示退格符(backspace,为方便观察,表示为[BS])。但是在正则表达式中,它表示单词边界(记为\b)。如果在字符串文字中写\b,字符串转义为退格符,作为正则文本转义为正则表达式,正则表达式真正得到的就是退格符,而不是单词边界。所以,如果用到了单词边界,在字符串文字中一定要写成\b。所以,最保险的办法是:正则表达式中的每个``,在字符串文字中都要写成\。
\b的转义
| 字符串文字 | 字符串/正则文字 | 正则表达式 | 说明 |
|---|---|---|---|
\b | [BS] | [BS] | 退格符 |
\b | \b | \b | 单词边界 |
new RegExp('\ba\b').test('a'); // false
new RegExp('\ba\b').test('a'); // true
这样看来,使用字符串形式的正则表达式,转义的处理确定比较复杂。最好是能省去这些麻烦——正则表达式是怎样,正则文字中就是怎样写。要做到这一点,有两种办法:
第一,使用原生字符串,也就是完全忽略字符串转义的特殊字符串。例如,Javascript中提供的原生模版字符串:
new RegExp(String.raw`\n`).test('\n'); // true
new RegExp(String.raw`\`).test('\'); // true
第二,直接使用正则文字。例如,Javascript正则字面量:(推荐做法)
/\n\/.test('\n\'); // true
如果必须使用字符串文字,请尽量坚持这条原则:正则表达式中的每个反斜线``,在字符串文字中都必须写成\,只有\n、\t中的反斜线例外(但是\n和\t也不难理解)。
6.1.2 元字符的转义
元字符是有特殊含义的字符,如果要匹配“元字符”自身则必须转义,也就是在元字符之前添加反斜线``。比如元字符点号
.,可以匹配除换行符外的任何字符,如果要准确匹配字符串中的点号.,正则表达式中就必须写成.。
也有些时候,匹配元字符自身并非一定要转义。
| 结构 | 记法 | 转义 | 说明 | |
|---|---|---|---|---|
| 字符组 | [] | [] | 只对开方括号转义 | |
| 字符组 | . | . | ||
| 字符组 | - | - | ||
| 量词 | * 、 + 、 ? | * 、+ 、? | ||
| 量词 | *? 、 +? 、 ?? | *? 、+? 、?? | ||
| 量词 | {n,m}} | {n,m}} | 只对开花括号转义 | |
| 括号 | (...) | (...) | 开、闭括号都需要转义 | |
| 多选结构 | **` | `** | | | 竖线需要转义 |
| 括号和多选结构 | **`(... | ...)`** | (...|...) | 开、闭括号和竖线都要转义 |
| 断言 | ^ 、 $ | ^ 、$ | ||
| 替换引用 | $num | $ 或 $$ | 在替换的 replacement 字符串中转义 |
字符组只要转义开方括号
字符组内部的闭方括号]在任何情况下都要转义* *,否则类似[]]的正则表达式会出现二义性,造成识别错误。所以能匹配字符a、字符b、字符]的字符组,应当写为[ab]]。同样,括号内部的任何闭括号)都要转义,比如包含ab和b)的多选结构的正则表达式就应当写为(ab|b))。
在进行正则表达式替换时,replacement 字符串中也可能出现转义。在Javascript中,replacement里通过$num引用对应捕获分组,$&引用整个匹配结果 。 如果想在替换时使用一个单独的$符号,而不是引用分组(比如生成价格字符串),则会报错。必须做转义才可以解决问题,使用$$。
'1234'.replace(/(?<!\d)\d+(?!\d)/, '$$$&'); // '$1234'
6.1.3 字符组中的转义
特殊的是,常见的元字符出现在字符组内部基本都不算元字符,也就是说,它们在字符组内部出现时,不需要转义。
字符组有自己的元字符规定,也有相应的转义规定:在字符组内部,只有三个字符需要转义。
- 一个是闭方括号
]。如果不是作为字符组结束标志的闭方括号,则必须写成]。比如[0]9],它可以匹配的字符是0、]、9。 - 一个是横线
-。如果不是用于范围表示法(比如[0-9]),必须写成-。比如[0-9],它可以匹配的字符是0、-、9。当然如果它紧跟在开方括号或闭方括号之后,也可以不用转义,[-09]或[09-]。 - 一个是字符
^。如果它不是用于排除型字符组([^ab]),则应当写成^。比如[^ab],它可以匹配的字符^、a、b。如果它不是紧跟在开方括号之后,也不用转义,[a^b]或[ab^]。
/[0]9]/.test(']'); // true
/[0-9]/.test('-'); // false
/[-09]/.test('-'); // true
/[^09]/.test('0'); // false
/[0^9]/.test('0'); // true
/[0^9]/.test('^'); // true
6.2 表达式中的优先级
正则表达式的元素之间的组合关系只有 4 种。
注:“普通拼接”可能是最常见的组合关系,正则表达式 abc 就是a、b和c的普通拼接,a(bc)则可以看作 a 和 (bc) 的拼接。
正则表达式的优先级
正则表达式中的优先级举例
表达式ab*(cd|e+)?|fg优先级:
专注于原创短更,便于碎片化涉猎知识。希望我走过的路,留下的痕迹,能对你有所启发和帮助。
转发请注明原处,平台投稿请私信。