本文属于我在前端团队的第二次分享,由于正则篇幅比较长,全文大概3W字左右,所以分为了上下篇,本文总体上来说属于我正则学习专栏的汇总,文章很大程度上借鉴了老姚《JavaScript正则迷你书》,并在其基础上做了拓展,以下是分享原文。
引、我为什么学正则?
19年年底,我入行前端正好两年半,不会篮球也不会正则,无奈的是当时有几个需求正好与正则挂钩,某个困难我请教了前后端组长,结果发现没一个人能解答我的问题,也因此我决定与正则结缘,2019年12月13日,我在第一篇正则博客文章中写到,公司前端组没一个人懂正则,等我学会我将是组里第一个会正则的人!
我在当时的要求也不算高,让自己能看懂别人所写的正则,以及能写出已知规则的正则,在半个月学习后,我确实达到了目的,而本次分享,我也希望帮助大家达到这个水平。
但需要记住,不要尝试把正则的所有概念都背下来,理解概念就好,等有需要脑子里立马知道使用哪部分知识可以帮助自己解决问题,再对应复习即可。
一、前置工具篇
正则觉得难无非难在两点,看不懂(长正则易读性都很差)与不会写(不敢保证写的对),针对这两点我先分享几个工具,两个专门用于看,两个专门用于在线写在线测,算是学习正则前的前置知识。
1.1正则图解神器 Regulex
Regulex 这个工具也是我在阅读正则迷你书时,作者所推荐的一个工具。长正则因为分组多正则表达式长,我们单看代码可能从到到哪是一个组都看的费劲,但通过图解工具,你的分组,每个分组起到什么作用都非常清晰。
比如我们现在要实现一个正则,匹配任意三个连续且完全相同的数字,比如:
const regex = /^(\d)\1{2}$/g;
regex.test(123);// false
regex.test(111);// true
regex.test(555);// true
/^(\d)\1{2}$/g
是一个结构相对简单的正则,但对于了解基本概念但读正则有点费劲的同学,心里肯定会想,这是个啥玩意?我们将这段正则复制到 Regulex 中,图解图下:
Group #1 代表分组1,对应正则也就是(\d)
这一段,而Digit也解释了含义,表示匹配一个任意数字。
Backref 表示反向引用,引用谁?紧接着一个 #1 表示反向引用分组1,而 Backref #1 被一个2 times 连通,代表这一段匹配 2 次。
那么总结来说,(\d)
匹配一个任意精确的数字,反向引用再匹配这个数字两次,加起来就是一个数字重复匹配3次,所以这也是为什么123匹配失败,因为当匹配到 1 时,\1{2}
此时已经被确定成再反向引用匹配 1 两次了。
1.2 图解可视化工具 regex-vis
与 Regulex 类似,regex-vis 也是一款用于图解正则的工具,比如正则:
const regex = /23{2,}/
表示匹配 2 开头,以及 3 结尾,且 3 出现最少 2 次。
1.3正则在线调试工具regexr
如果说 Regulex 与 regex-vis 主要用来帮助我们读,那么 regexr 能非常方便的帮助我们在线写正则,比如在日常开发中我们写了一个正则,看上去似乎满足了需求,又担心它会不会出乎意料的匹配到我们不想要的内容,regexr就能起到一个很好的测试作用。
regexr 界面非常简单,一共分为三个区域,上方 Expression 用来写你的正则,下方 Text 用于写你的测试用例,而最下方的工具栏Tools能让你写一个正则做更多事。
比如在上图中,我定义了一个正则 /\d([a-z]+)/
,以及一个例子 123abc12 ,于是被匹配的区域成功高亮;在更下方工具栏,我们选择了Replace,我们希望将匹配内容替换成 ❀ ,于是在下面我们看到了替换完成的结果 12❀12。
你完全可以将一个正则写好贴上去,然后把很多个正则匹配边界情况的例子统统加入到Text中,用于检验你的正则是否符合你的预期。而在 Expression 右侧,我们还能切换变成语言,以及选用更多修饰符,比如是否启用全局匹配,是否多行,是否区分大小写等等。
我在给前同事解释分组与反向引用的过程中,他问了我一个这个问题,为什么下面这段代码输出是 true:
new RegExp(/(?:[0-9]){1}-([a-z])\1{3}/).test('111111-bbbb')// true
对于他而言,他的理解是(?:[0-9]){1}
这一段明明限定了只匹配1个任意数字,那为什么后面用例这么多个1还匹配成功了,单看这代代码,好像真是这么回事,但只要你把这个正则贴到 regexr 中你完全不会有这个疑虑:
因为这个正则并没有限定从什么地方开始匹配数字1,他给的这个例子中确实有一小段符合要求,当然返回是true。若想达到他的预期,只需要在正则前加一个^即可。
new RegExp(/^(?:[0-9]){1}-([a-z])\1{3}/).test('111111-bbbb')// false
1.4 vscode 插件 Regex Previewer
除了在线网站,有时候我们写代码过程中也想立刻测试正则是否生效,Regex Previewer 就是一个这样的插件。在安装之后,代码中所有正则代码的上角都会出现一个 test 按钮,点击后它会帮你新开一个 tab 并提供部分用例,且匹配的内容会帮你高亮。需要注意的是,目前不支持正则构造器的写法。
二、正则的两种模糊匹配
正则表达式是一种匹配模式,要么匹配字符(符合规则的字符),要么匹配位置(符合规则字符所在的位置)。
正则之所以强大,是因为正则能实现模糊匹配;在实际开发中,我们往往需要匹配某一范围的数据。举个贴切的例子,当验证用户输入邮箱格式是否正确,除了 @ 等固定字符以外,用户输入的具体字符我们是无法估量和统计的,精准匹配显得无能为力,也只有模糊匹配能巧妙解决这个问题。
正则表达式的模糊匹配分为横向模糊与纵向模糊两种:
2.1 横向模糊
不难理解,横向模糊表示正则匹配的字符长度是不确定的,我们可以通过正则的量词实现横向匹配。不知道大家有没有在B站看到过 233 的弹幕,233 是一个网络用语,表示大笑的意思。但因为个人输入随心的习惯,可能打出2333,233333 等不定长度的弹幕,那么我们匹配弹幕中有多少 233 大笑可以用正则这么写:
const regex = /23{2,}/;
这里量词 {2,}
表示前面的3会出现2次或者更多次,量词后面会专门介绍,我们来试试这个正则:
const regex = /23{2,}/g;
const str = '223 233 2323 2333';
const result = str.match(regex);
//["233", "2333"]
注意正则后面有个小写的字母 g,这是正则修饰符之一,g 为 global 的简写,表示全局匹配。若不加 g ,match 方法只会匹配第一个符合条件的字符,关于修饰符后文会详细介绍。
2.2 纵向模糊匹配
纵向模糊匹配是指具体某一位置可能有多种字符的情况,横向模糊可以用量词实现,而纵向模糊匹配可以使用字符组实现,比如:
const regex = /[abc]/;
这段正则表示可匹配字母 a b c 其中一个,我们来看一个简单的例子:
const regex = /a[1-3]c/g;
const str = "a0c a1c a2c a3c a4c";
const result = str.match(regex);
result //["a1c", "a2c", "a3c"]
在这个例子中我们使用了字符组 [1-3]
,它本质上与 [123]
效果相同,但因为是连贯数字所以支持范围简写。下面介绍具体介绍正则字符组。
三、正则字符组
在上一个例子中我们已经了解到字符组[123]
可用范围表示法写成[1-3]
,这是非常有用的,设想一想,我们现在想匹配数字1-9
,字母a-f
,要写全的话就得这样[123456789abcdef]
,但通过范围表示法只用短短的[1-9a-f],是不是很奈斯:
现在知道了连字符 -
的作用,那么现在我们就是要匹配1 - 3
其中任意字符怎么做呢?有三种写法可解决这个问题,写成[-13]
、[13-]
或者使用转义符 \
表示 [1\-3]
即可。
3.1.排除字符组
纵向模糊匹配还存在一种情况,就是某个位置可以是除了某几个字符之外的任意字符,比如我希望是除了1-3
之外的任意字符,那么我们可以使用[^1-3]
表示,注意这里使用了脱字符 ^
。
3.2.常用简写
了解了字符组范围表示法,那么想匹配数字 0 到 9 可以写成 [0-9]
,其实它还有一种更简单的写法\d
,估计这部分是很多人常忘记的知识,我们来做个整理:
字符组 | 含义 |
---|---|
\d | [0-9]表示是一位数字,digit数字。 |
\D | [^0-9]表示除数字以外的任意字符。 |
\w | [0-9a-zA-Z_]表示数字,大小写字母和下划线,word简写,又称单词字符。 |
\W | [^0-9a-zA-Z_],非单词字符。 |
\s | [ \t\v\n\r\f]表示空白符。包含空格,水平制表符,垂直制表符,换行符,回车符,换页符。 |
\S | [^ \t\v\n\r\f],非空白符。 |
. | [^\n\r\u2028\u2029],通配符,表示除了换行符,回车符,行分隔符和段分隔符之外任意字符。 |
空格:顾名思义,就是我们理解的空格
水平制表符\t:类似于tab键缩进的效果,一般系统中水平制表符占8列,所以根据你按的次数占据8*N列。
垂直制表符\v:让文本从下一行开始输出,且开始的列数为\v前字符的后一列。
换行符\n:从下一行开头开始输出。
回车符\r:这里的回车不是我们理解的 enter 回车另起一行开始输出,而是回到当前行开头输出,还可能将已输入文本替换,替换这一点根据环境不同表现不同。
换页符\f:在输出\f后面文本之前,会先将当前屏幕清空,类似于先清除再输出。
行分隔符和段分隔符,找了一圈也没看到好的解释,这里还望有缘人指点。
那么如果我们想匹配任意字符,有这几种写法[/d/D]
、[/w/W]
、[/s/S]
、[^]
,其实不难理解,以[/d/D]
为例,就是匹配数字以及除了数字以外的所有字符,这不就是所有字符了吗。
四、正则量词
在讲述正则横向模糊匹配时已有使用量词的例子,量词表示某个字符的重复次数,我们也将常用量词做个整理:
量词 | 含义 |
---|---|
{m,n} | 至少出现m次,最多出现n次。 |
{m,} | 至少出现m次,没有上限。 |
{m} | 等价于{m,m},固定出现m次 |
? | 等价于{0,1},要么不出现,要么出现一次。 |
+ | 等价于{1,},至少出现1次,没有上限。 |
* | 等价于{0,},表示出现任意次数,可以不出现,也可以任意次,包容性比?和+大。 |
4.1.贪婪匹配和惰性匹配
正则默认就是贪婪匹配,贪婪就是在量词匹配规则范围内最大限度的去匹配字符,我们来看个简单的例子:
const str = "ab abb abbb abbbb abbbbb";
const regex = /ab{2,4}/g;
const result = str.match(regex);
result //["abb", "abbb", "abbbb", "abbbb"]
在这个例子中,我们匹配 2-4 个字母b,你给 2 个我要,给 3 个我要,哪怕给 5 个我也要尽我所能拿 4 个,是不是很贪心。
惰性与贪婪相反,惰性匹配就是在量词匹配范围内以最小限度去匹配字符,无欲无求做人本分,我们只需要在量词后接个 ? 即是惰性匹配,看个例子:
const str = "ab abb abbb abbbb abbbbb";
const regex = /ab{2,4}?/g;
const result = str.match(regex);
result //["abb", "abb", "abb", "abb"]
大家会不会觉得惰性匹配情况下这个次数4是不是没意义了呢?其实并不是没意义,尽管惰性匹配是以最小2次为匹配规则,但被匹配的字符前提条件是满足 2-4 之间,4还是起到了限制条件,我们改改例子再看:
const str = "abc abbc abbbc abbbbc abbbbbc";
const regex = /ab{2,4}?c/g;
const result = str.match(regex);
result //["abbc", "abbbc", "abbbbc"]
上述例子中当匹配到字段 abbbbbc 时因为字母b已经超过范围,所以不在匹配范围内。惰性可以理解为,在匹配范围内拿最少的东西,我可以过的无欲无求,但也得过的温饱活得下去才行啊。
五、 正则多选分支
如果说横向模糊匹配和纵向模糊匹配都是一种匹配模式,那如果需要同时使用多种模式怎么办呢,这里我们就可以使用管道符 | 实现这一点,来看个简单的例子:
const str = "a0c a1c a2c a3c abc abbc abbbc abbbbc";
const regex = /a[1-3]c|ab{1,3}c/g;
const result = str.match(regex);
result //["a1c", "a2c", "a3c", "abc", "abbc", "abbbc"]
在这个例子中,我们使用了纵向模糊匹配和横向模糊匹配两种模式。
需要注意的是,分支匹配也是惰性匹配,即前面的匹配模式能满足,后面就不匹配了,来看个例子:
const str = "userName";
const regex = /user|userName/g;
const result = str.match(regex);
result //["user"]
这非常类似于js短路运算符中的 ||
,以 a || b
为例,倘若 a 为真那么 b 就不判断了。
function fn1() {
console.log(1);
return true;
};
function fn2() {
console.log(2);
return true;
};
fn1() || fn2(); //1
我们再来个反转,前文虽说条件匹配是惰性,但这个前提也是一开始第一个条件能匹配上,但事实上,匹配角度正则还是期望能拿到更多字符串,我们再看个例子:
const str = "userName";
const regex = /Name|userName/g;
const result = str.match(regex);
result //["userName"]
哎?怎么不是匹配 Name 字段,其实站在匹配角度,正则就是拿条件一个个试,字符串的匹配规则就是从左往右的匹配,当左侧一开始无法匹配成功(user 和 Name 对应不上),正则就会想会不会其它条件能匹配上,那我就继续先尝试,结果第二个条件 useName 完美契合,所以第一个 Name 反而匹配不上了。
那假设除了第一个条件,其余条件都匹配不上呢?再比如这个例子:
const str = "userName";
const regex = /Name|userAge/g;
const result = str.match(regex);
result //["Name"]
同样还是从左往右匹配,一开始 Name 和 userName 开头无法匹配,正则同样还是继续尝试分支其它情况,结果userAge 这个条件直接匹配不上,没条件可以走了,正则这时候就会回溯,完整的拿不到吧,那继续退而求其次,匹配回溯再从第一个条件开始,拿 Name 进行部分匹配,哎,这下发现 userName 里面确实有一部分可以匹配上,于是得到了 Name。
你看,一个小小的条件匹配,里面其实藏着不少细节,我们总结下:
- 条件匹配是惰性的,如果第一个条件能跟字符串第一个字符开始匹配上,那后续条件就不用匹配了。
- 条件匹配也是贪婪的,如果第一个字符匹配不上,那就先放弃局部匹配,继续试试其它条件,除非都完成匹配失败,再回溯所有条件进行局部匹配。
那么到这里,我们先做个总结,大家可以看着思维导图回顾下知识点:
让我们来两个练习,尝试写出匹配 24 小时制的正则匹配(只包含小时分钟即可),以及匹配16进制颜色值的正则,注意,16进制颜色是支持 #dddddd
与 #ddd
两种。
我们先来解决24小时制时间匹配的正则,首先二十四小时制的时间一般是 09:30 或者 23:59 这样,小时的第一位数字可能是[0-2]
三种情况之一,当为 0,1 时,第二位数字可以是[0-9]
任意数字,当为 2 时第二位数字只能是 0-3 之间的数字。第三位数字只能是 0-5 之间的数字,最后一位数字只能是 0-9 之间。
整理下信息:
- 当第一位数字为 0 1 时,第二位数字可以是
[0-9]
任意数字,比如 00、09、19。 - 当第二位数字为 2 时,第二位只能是 21 22 23,固定的。
- 第三位数字只能是
[0-5]
,逢五进一,比如 01 59,不可能大于 5。 - 第四位数字范围
[0-9]
,结合第三位很好理解 00 09 50,逢九进一。
综上,我们只用对于小时的两种情况做个分支,分钟固定的范围即可,所以正则可以写成这样:
const regex = /^([01][0-9]|[2][0-3]):[0-5][0-9]$/;
regex.test("00:07"); //true
regex.test("23:59"); //true
注意,匹配小时的分支我们使用了一对圆括号包裹,表示这是一个组,而组内包含了两个分支情况,如果不加圆括号正则解析时会将管道符 |
左右两侧理解成两个分支,如下图,很明显这不是我们想要的规则:
其次,在正则内部开头和结尾我们分别使用了^$
两个符号,这表示正则匹配时严格以字符串开头和结尾中间的内容为匹配对象,如果不加效果就是这样:
const regex = /([01][0-9]|[2][0-3]):[0-5][0-9]/;
regex.test("0000:0709");//true
上面这代正则匹配为 true 是因为字符串中间有一部分是符合规则,所以如果我们想匹配一个字段从头到尾是否符合规则,一定得记得加上 ^$
符号限制从头到尾整个字符都得符合规则。
我们再来分析16进制颜色,提前查了下,每个字母范围均为[0-9a-fA-F]
,但由于颜色值可以简写,比如 #ffffff
可以简写成 #fff
,所以存在 6 位与 3 位的情况,结合分组,正则可以这么写:
const regex = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;
regex.test("#e4393c"); //true
regex.test("#2b99ff"); //true
六 、 正则中的位置
注意,这里所说的位置并不是我们遍历数组时所使用的索引概念,正则匹配的位置又称为锚,是指相邻字符之间的位置,比如下图一个字符 hello 中,每个箭头就是一个位置:
正则表达式中,匹配位置的字符又称为锚,在文章开头我们已经见过了 ^$
两个锚,其实你已经能猜到这两个代表了开头和结尾的两个箭头的位置,我们来验证下位置的概念,看个简单的例子:
const str = '听风是风';
const regex = /^|$/g;
const result = str.replace(regex, '❀'); //❀听风是风❀
可以看到两个位置被替换成了花朵,此时字符串的开头位置与结尾位置发生了变化,开头变成了花朵左边,结尾位置变为第二朵花的右边。
七、理解正则的锚
除了常用的 ^$
,还有其它正则提前定义的锚,我们一一细说。
7.1 ^ 脱字符
**^**
脱字符:匹配开头,在多行中匹配行开头,比如:
const str = '听风\n是风';
// 这里的修饰符m表示匹配多行
const regex = /^/mg;
const result = str.replace(regex, '❀');
注意,正则结尾添加了一个 mg ,g(global)前面有解释表示全局匹配,表示一行从左到右完整匹配一遍;而m(more)表示多行匹配,mg就是多行全局匹配,每行不管文本多长,都完全匹配一遍。
7.2 $ 美元符号
**$**
美元符号:匹配结尾,在多行中匹配行尾。
7.3 \b 单词边界
**\b**
单词边界:表示\w
(单词字符)与\W
(非单词字符)之间,\w
(单词字符)与 ^
(脱字符)之间,以及\w
(单词字符)与 $
之间的位置,有点难理解,先看个例子:
const str = '[echo].123';
const regex = /\b/g;
const result = str.replace(regex, '❀'); //[❀echo❀].❀123❀
上面解析有点长,我们缩短点,\b
表示\w
与\W
、^
、$
之间的位置,而\w范围是[0-9a-zA-Z_]
,那么我们再看上面的例子,为了方便理解,我们拆分细说:
从左往右看,首先 ^
与 [
之间不满足,再到 [
与 e
之间,[
是非单词符而 e
是单词符,满足条件。
echo
由于四个字母都是单词符,直接跳过,o
与 ]
又满足了条件。
]
与 .
之间很明显不符合,再看 .
与 1
又满足了条件。
123都是单词符,跳过,直接到了尾部 3
与 $
,满足条件。
7.4 \B 非单词边界
**\B**
非单词边界,意思与 \b
相反,匹配 \w
与 \w
、\W
与 \W
、^
与 \W
,\W
与 $
之间的位置,还是上面的例子,我们改改匹配条件:
const str = '[echo].123';
const regex = /\B/g;
const result = str.replace(regex, '❀'); //❀[e❀c❀h❀o]❀.1❀2❀3
可以看到 ^
与 [ 之间,以及单词符与单词符之间都满足了条件。
7.5 正向先行断言 (?=p)
**(?=p)**
正向先行断言:p表示一个匹配模式,即匹配所有满足条件p的字段的前面位置,有点绕口,看个简单的例子:
const str = 'hello';
const regex = /(?=l)/g;
const result = "hello".replace(regex, '❀'); //he❀l❀lo
这里就是先在字符串中找到字母 l,然后再找到 l 前面的位置就是目标位置。为了方便,直接利用前面位置理解的图,也就是这两个红框了:
7.6 负向先行断言 (?!p)
那么(?!p)
与(?=p)
就是反过来的表示负(反)向先行断言,还是上面的例子,我们改改条件,也就是下图中绿框中的位置:
const str = 'hello';
const regex = /(?!l)/g;
const result = "hello".replace(regex, '❀'); //❀h❀ell❀o❀
如果不看这个图,我不知道大家有没有这样的疑惑,不对啊,前面解释 \b
单词边界时,是从 ^
脱字符 开始判断的,脱字符也不满足条件前面也应该加朵❀,最终输出难道不应该是 ❀❀h❀ell❀o❀ 这样吗?o后面有❀ 是因为o后面还有个 不满足条件所以才这样啊。
记住,^和主动理解成两个隐藏字符,我们现在是在匹配位置。
所以 /(?=l)/g 就是在找 l 前面的位置,而 /(?!l)/g 本质上来说就是找不是字母 l 前面的其它所有位置。
那为什么 \b
单词边界还能从 ^
开始判断呢,因为概念就包含了判断\w
与^
之间的位置,在判断单词边界时,这两个特殊位置就像两个隐藏字符一样,也成了判断位置的条件。而在判断(?!p)
与(?=p)
时,主要p不是^
,那么此时的 ^$
单纯作为两个位置,不会主动作为判断条件参与判断,这一点千万不要弄混了!!!
7.7 正向后发断言 (?<=p)
正向后发断言**(?<=p)**
:与正向先行断言类似都是匹配位置,区别在于正向先行断言是匹配符合条件前的位置,而正向后发断言是匹配符合条件后面的位置:
const str = 'hello';
const regex = /(?<=l)/g;
const result = "hello".replace(regex, '❀'); //'hel❀l❀o'
总结来说,先行就是位置在匹配结果的前面,后发就是位置在匹配结果的后面。
7.8 负向后发断言 (?<!p)
这里大家应该能秒懂呢,反正就是匹配与正向后发断言完全相反的位置就对了(除了两个l之后的所有位置),不好理解的概念,就基于好理解的概念进行取反。
const str = 'hello';
const regex = /(?<!l)/g;
const result = "hello".replace(regex, '❀'); //'❀h❀e❀llo❀'
八、位置的特性
到这里你也许有点迷糊,本来就是找位置,结果 ^$
作为位置应该是被找的对象,怎么还反客为主成了找位置的条件了,位置和位置之间难道还有位置?正则里还真是这样。
我们可以将位置理解成一个空字符" ",就像上图的箭头,一个hello可以写成这样:
"hello" = "" + "h" + "" + "e" + "" + "l" + "" + "l" + "" + "o" + "";
它甚至还能写成这样,站在位置的角度,位置能是无限个:
"hello" = "" + "" + "hello"
以正则的角度,我们测试一个单词是否为hello甚至可以写成这样:
const str = 'hello';
const regex = /^^^^^hello$$$$$$$$$$$$/g;
const result = regex.test(str); //true
当然这是我们站在匹配正则位置的角度抽象理解成这样的,毕竟真的给字符串加空格,字符串就真的变样了,\b
单词边界会拿^$
这两个特殊位置作为判断其它位置的条件,记住这一点就好了。
到这里我们整理下位置(锚)的知识点:
九、分组和分支结构
9.1.分组基础
在正则中,圆括号 ()
表示一个分组,即括号内的正则是一个整体,表示一个子表达式。
我们知道 /ab+/
表示匹配a加上一个或多个b
的组合,那如果我们想匹配ab
的多次组合呢?这里就可以使用()包裹ab:
const str = 'abab ababab aabbaa';
const regex = /(ab)+/g;
const result = str.match(regex); //["abab", "ababab", "ab"]
在分支中使用括号也是非常常见的,比如这个例子:
const str1 = 'helloEcho';
const str2 = 'helloKetty';
const regex = /^hello(Echo|Ketty)$/;
const result1 = regex.test(str1); //true
const result2 = regex.test(str2); //true
若我们不给分组加括号,此时的分支就变成了helloEcho和Ketty,很明显这就是不是我们想要的。(TODO 注意正则尾部未加全局匹配 g,如果加了第二个验证为false,原因参考)。
9.2.分组引用
不知道大家在以往看正则表达式时有没有留意到$1
,$2
类似的字符,这类字符表示正则分组引用,对于正则使用是非常重要的概念。我们来看一个简单的例子:
写一个匹配 yyyy-mm-dd 的正则:
const regex = /(\d{4})-(\d{2})-(\d{2})/;
通过图解我们能发现每个分组上面多了类似Group #1
的分组编号,是不是已经联想到1相关的字符了呢?没错,这里1相关的字符了呢?没错,这里1,$2正是对应的分组编号。
这里我们提前科普两个方法,一个是字符串的match
方法,一个是正则的exec
方法,它们都用于匹配正则相符字段,看个例子:
const result1 = '2019-12-19'.match(regex);
const result2 = regex.exec('2019-12-19');
console.log(result1);
console.log(result2);
可以看到虽然方法写法不同,但结果一模一样,我们来解释下匹配的结果。
2019-12-19为正则最终匹配到的结果,"2019", "12", "19"这三个分别为group1,group2,group3三个分组匹配的结果,index: 0 为匹配结果的开始位置,input: "2019-12-19"为被匹配的输入字段,groups表示捕获组的匹配结果,如果该字段的值为undefined,则说明当前正则表达式没有定义任何捕获组。
我们可以通过1,1,2直接访问上面例子中各分组匹配到的结果。这里我们展示一个完整的例子,在使用过一次正则后输出RegExp
对象,可以看到此对象上有众多属性,再通过 RegExp.$1
我们能直接拿到分组1的匹配结果:
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const string = "2019-12-19";
//注意,这里你得先使用一次正则,match test,replace等方法都行
regex.exec(string);
console.dir(RegExp);
console.log(RegExp.$1); // "2019"
console.log(RegExp.$2); // "02"
console.log(RegExp.$3); // "119"
现在我们要明白一个概念,$1
表示的就是Group #1
的匹配结果,它就像一个变量,保存了匹配到的实际值。那么知道了这一点我们能做什么呢?比如我们将 yyyy-mm-dd 修改为 dd/mm/yyy 格式。
const result = string.replace(regex, '$3/$2/$1'); // 19/12/2019
console.log(result);
这段代码等价于:
const result = string.replace(regex, function () {
return RegExp.$3 + "/" + RegExp.$2 + "/" + RegExp.$1; // 19/12/2019
});
同时也等价于:
const result = string.replace(regex, function (match, year, month, day) {
console.log(match, year, month, day);//2019-12-19 2019 12 19
return day + "/" + month + "/" + year;//19/12/2019
});
所以看到这,大家也不要纠结第一个修改中'$3/$2/$1'
字段如何关联上的分组匹配结果,知道是正则底层实现这么去用就对了。
9.3 groups的bug
上文提到如果你的正则有定义分组,那么匹配结果中的groups
字段将展示你分组以及对应的结果,但这其实会有bug:
const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = regex.exec('2022-03-11');
console.log(match.groups);// undefined
这段代码我们其实定义了3个分组,我们预期的groups
字段输出应该是如下:
{
"1": "2022",
"2": "03",
"3": "11"
}
这是因为,在ES6之前,JavaScript并没有原生支持groups
字段,只有通过第三方库或者自己手动解析正则表达式的分组才能得到捕获组的匹配结果。
从ECMAScript 2018(ES9)开始,JavaScript引入了具名捕获组和 groups
属性,可以通过实现具名捕获组来访问匹配的结果:
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = regex.exec('2022-03-11');
console.log(match.groups);
// 输出结果
{
year: '2022',
month: '03',
day: '11'
}
十、反向引用
10.1 基本概念
除了像在上文API中那样使用分组一样,还有一个比较常见的就是在正则自身中使用分组,即代指之前已经出现过的分组,又称为反向引用。我们通过一个例子来了解反向引用。
现在我们需要一个正则能同时匹配 2019-12-19 2016/12/19 2016.12.19 这三种字段,正则我们可以这么写:
const regex = /\d{4}[-\/\.]\d{2}[-\/\.]\d{2}/;
regex.test('2019-12-19'); //true
regex.test('2019/12/19'); //true
regex.test('2019.12.19'); //true
通过图解我们也知道这个正则其实有个问题,它甚至能匹配 2019-12.19 格式的字段
regex.test('2019-12.19'); //true
那现在我们要求前后两个分隔符一定相同时才能匹配成功怎么做呢,这里就需要使用反向引用,像这样:
const regex = /\d{4}([-\/\.])\d{2}\1\d{2}/;
regex.test('2019-12-19'); //true
regex.test('2019/12/19'); //true
regex.test('2019.12.19'); //true
regex.test('2019-12.19'); //false
regex.test('2019/12-19'); //false
这里的 \1
就是反向引用,除了代指前面出现过的分组([-/.])
以外,在匹配时它的分支选择也会与前者分组同步,说直白点,当前面分组选择的是 - 时,后者也会选择 - 然后才去匹配字段。
10.2 引用嵌套
有个问题,括号也会存在嵌套的情况,如果多层嵌套反向引用会有什么规则呢?我们来看个例子:
const regex = /^((\d)(\d(\d)))\1\2\3\4$/;
'1231231233'.match(regex); // true
console.log( RegExp.$1 ); // 123
console.log( RegExp.$2 ); // 1
console.log( RegExp.$3 ); // 23
console.log( RegExp.$4 ); // 3
通过例子与图解应该不难理解,当存在多个括号嵌套时,从1−1−9的顺序对应括号嵌套就是从外到内,从左到右的顺序。
$1
对应的是((\d)(\d(\d)))
,$2
对应的是第一个 (\d)
,$3
对应的是 (\d(\d))
,$4
对应的是 $3
中的 (\d)
。
虽然我们在前面说的是$1-$9
,准确来说,只要你的分组够多,我们甚至能使用$1000
都行,比如:
const regex = /(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)\12+/;
const string = "abcdefghijkllll";
regex.test(string);//true
console.log(RegExp.$12);//undefined
可以看到 \12
确实指向了前面的(l)
分组,但由于RegExp对象只提供了 $1-$9
的属性,所以这里我们输出RegExp.$12
是undefined
。
还有一个问题,如果我们反向引用了不存在的分组会怎么样呢?很好理解,直接看个例子:
const regex = /\1\2\3/;
const string = "\1\2\3";
regex.test(string);//true
console.log(RegExp.$1);//为空
由于在\1前面不存在任何分组,所以这里的\1\2\3
就单纯变成转义符\和三个数字 123 了,不会代指任何分组。
最后一点,分组后面如果有量词,分组会记录匹配的最后一次的数据,看个例子:
const regex = /(\w)+/;
const string = "abcde";
console.log(regex.exec(string));// ["abcde", "e", index: 0, input: "abcde", groups: undefined]
可以看到分组匹配的结果为e,也就是最后捕获的数据,但index还是为 0,表示捕获结果的开始位置。
所以在分组有量词的情况下使用反向引用,它也会指向捕获最大次数最后一次的结果。
const regex = /(\w)+\1/;
regex.test('abcdea');//false
regex.test('abcdee');//true
const regex1 = /(\w)+\1/;
regex1.test('abcdee');
console.log(RegExp.$1);//e
十一、非捕获括号
在前面讲述分组匹配以及反向引用时,我们都知道正则其实将分组匹配的结果都储存起来了,不然也不会有反向引用这个功能,那么如果我们不需要使用反向引用,说直白点就是不希望分组去记录那些数据,怎么办呢?这里就可以使用非捕获括号了。
写法很简单,就是在正则条件加上 ?:
即可,例如(?:p)
和 (?:p1|p2|p3)
,我们来做个试验,看看最终match 输出结果:
const regex = /(ab)+/;
const string = "ababa aab ababab";
string.match(regex);
console.log(RegExp.$1);//ab
javascript
const regex = /(?:ab)+/;
const string = "ababa aab ababab";
string.match(regex);
console.log(RegExp.$1);//空
我们分别在正则分组 ab前面加或不加 ?:
,再分别输出 RegExp.$1
,可以看到普通分组记录了最后一次的匹配结果,而非捕获括号单纯起到了匹配作用,并没有去记录匹配结果。
那么到这里,第三章知识全部解释完毕,我们来做一个技术总结,大家可以参照下方思维导图回顾知识点,看看是否还熟记于心头。
最后留两个思考题,请模拟实现 trim
方法,即使用正则去除字符串开头与结尾的空白符。第二个,请将my name is echo每个单词首字母转为大写。