Regex

引言

正则表达式(Regular Expression)是使用单个字符串来描述、匹配一系列符合某个句法规则的一串字符。我在之前的学习工作中,遇到正则表达式,往往都是直接搜索相关问题的结果,或者尝试用其他方法解决。也尝试过学习相关的规则,但是过一段时间不用之后,往往会生疏乃至忘记。这篇文章尝试着理解并消化正则表达式相关的知识。

字符与位置

正则表达式是匹配模式,不是匹配字符,就是匹配位置

匹配字符

特殊字符

正则表达式除了匹配常规的 a-z,A-Z,0-9 等字符外,还存在着一些特殊字符,如下所示

字符 记忆方法
\t tab
\v vertical tab
\f form feed
\r return
\n new line

字符组

虽然叫字符组,但是只匹配字符组中的某个字符,比如[abc]只匹配 a,b,c 中的一个。对应的如果不想匹配某几个字符,则需要在字符组最前面加上**脱字符(^),形如[^abc],则表示匹配除 a,b,c 之外的其他字符。使用连字符(-)**则可以匹配连续的一段字符,形如 a-z,0-9。另外有如下相关的简写形式来表示对应的字符区间。

正则表达式 匹配区间 记忆方法
\d [0-9] digit
\D [^0-9] opposite to \d
\w [0-9a-zA-Z_] word**(注意有下划线_)**
\W [^0-9a-za-z_] opposite to \w
\s [\t\v\n\r\f] space character
\S [^\t\v\n\r\f] opposite to \s
. [^\n\r\u2028\u2029] 匹配除换行符之外的所有字符

循环与重复

在很多情况下,我们需要匹配零到多个重复的字符,在这个情况下掌握**{}**以及对应的简写方式就显得尤为重要。以下是完整的语法:

1
2
3
4
5
6
{m}   匹配m次
{m,} 匹配>=m次
{m,n} 匹配m到n次
? 匹配01
* 匹配>=0
+ 匹配>=1

贪婪匹配与惰性匹配
正常情况下的字符串匹配都是贪婪匹配的,也就是在当前条件下尽可能多的匹配字符。但是有些情况下贪婪匹配不是一件好事,需要我们使用惰性匹配来规避掉这个问题,处理方法就是在循环语法后加上**?**,这样就可以尽可能少的匹配对应的字符了。所有惰性匹配情况如下:

1
2
3
4
5
{m,n}?
{m,}?
??
+?
*?

多选分支

使用管道符(|)可以实现逻辑,具体形式如(p1|p2|p3)。例如/good|bad/可以匹配 good 或者 bad 中的一个字符。

匹配位置

匹配位置这里我认为是正则里的一个弯,绕过了这个弯,才可以看到更多。正则里的位置代表的是字符串中每个相邻字符中间的位置,我们可以想象成为一个虚拟位置,这个位置中的字符串为空字符串,这样就可以更好的理解位置这个概念。在 ES5 与 ES6 中,一共有 8 个锚字符,分别是:

1
2
3
4
5
6
7
8
^    匹配开头,在多行模式下匹配行开头
$ 匹配结尾,在多行模式下匹配行结尾
\b 单词边界
\B 与\b相反
?=p positive lookahead 先行断言
?!p negative lookahead 先行否定断言
?<=p positive lookbehind 后行断言
?<!p nagative lookbehind 后行否定断言

下面分别介绍这几个锚字符的具体含义

^与$

这两个比较好理解,分别匹配的是开头与结尾

\b 与\B

b 是边界 boundary 单词的首字母缩写。\b 匹配包括的位置是\w 与\W 之间的位置,\w 与^之间的位置,\w 与$之间的位置,例如:

1
2
let str = '[lover] taylor swift album_1';
console.log(str.replace(/\b/g, '#')); // result: '[#lover#] #taylor# #swift# #album_1#'

老规矩,\B 是与\b 相反的,具体匹配的位置是\W 与\W 之间的位置,\w 与\w 之间的位置,\W 与^之间的位置,\W 与$之间的位置,例如:

1
2
let str = '[lover] taylor swift album_1';
console.log(str.replace(/\b/g, '#')); // result: '#[l#o#v#e#r]# t#a#y#l#o#r s#w#i#f#t a#l#b#u#m#_#1'

(?=p)与(?!p)

先行断言与先行否定断言,注意需要搭配括号使用。这里的 p 代表的是一个匹配规则,匹配的是符合该规则的字符前面的位置,比如(?=b)则代表字符串 b 前面的位置,例如:

1
2
let str = 'boy';
console.log(str.replace(/(?=b)/g, 'girl love ')); //result: 'girl love boy'

?!p 则是?=p 的反例,例如:

1
2
let str = 'loop';
console.log(str.replace(/(?!o)/g, '~')); //result: '~loo~p';

(?<=p)与(?<!p)

后行断言与后行否定断言。有了先行断言相关的知识,那么后行断言就比较好理解了。后行断言匹配的是符合规则 p 的字符后面的位置。
需要注意的是浏览器的兼容性问题,后行断言在 ES5 环境下是不支持的,但是可以用:

1
2
3
4
5
6
7
8
9
var str = 'apple people';
str
.split('')
.reverse()
.join('')
.replace(/elp(?=pa)/, 'ylp')
.split('')
.reverse()
.join('');

类似于这种方式进行模拟,但是带来的结果是求得结果的过程显得冗长和啰嗦,在 ES6 中则直接支持该语法。

实际应用

现在有如下需求,将诸如 1234567 的一串数字按照每三位进行隔开,中间加上逗号,变成如 1,234,567 这种形式。
比较常见且简单的做法是用Number('1234567').toLocaleString('en-US')这种方法直接可以得到结果,那么用正则如何做到呢?

首先我们尝试给最后三位字符前添加一个逗号,对应的正则比较容易写出来:

1
2
let str = '1234567';
console.log(str.replace(/(?=\d{3}$)/g, ',')); //result: 1234,567

之后考虑每对应三个数字之前的逗号,则\d{3}对应的加上一个加号就可以匹配到

1
2
let str = '1234567';
console.log(str.replace(/(?=(\d{3})+$)/g, ',')); //result: 1,234,567

但是这样会出现一个问题,在三的倍数的数量的数字字符下,会在字符串的开头多加一个逗号,所以需要把对应的这个开头的逗号去掉,即不匹配开头的位置(?!^)

1
2
let str = '123456789';
console.log(str.replace(/(?!^)(?=(\d{3})+$)/g, ',')); //result: 123,456,789

这样就可以写出符合需求的正则了。

JavaScript 中正则表达式的相关 API

RegExp.prototype.test()

返回一个布尔值,表示当前的正则是否能匹配参数中的字符串
这个方法比较简单,需要注意的是,当正则表达式使用了 g 修饰符时,表示全局搜索,会匹配多个结果。这时每次使用 test 方法时都会从上次结束的位置开始向后进行匹配

1
2
3
4
5
6
let regex = /x/g;
let str = 'xxxx';
console.log(regex.lastIndex); //result: 0;
console.log(regex.test(str)); //result: true
console.log(regex.lastIndex); //result: 1
console.log(regex.test('xxxx')); //result: true

RegExp.portotype.exec()

返回对应参数字符串匹配正则之后的结果,返回值类型为 Array 或者 null
注意:如果正则表达式中包含圆括号(组匹配),那么返回的数组会包含多个成员,例如:

1
2
3
let regex = /\b(\d+)\b/;
let str = '2019.10.1';
console.log(regex.exec(str)); // result: [ '2019', '2019', index: 0, input: '2019.10.1' ]

返回的结果中,第一个值代表匹配的整体结果,第二个是组匹配的结果,如果正则中有多个组匹配,则在数组之后追加之后的组进行组匹配后的结果。此外,返回的数字还包括两个属性 index 与 input,其中 index 代表整体匹配成功时的开始位置,input 则代表输入的原始字符串。
另外,和 test 方法一样,exex 方法在全局模式下(加上 g 修饰符),会匹配多个结果,每次调用 exec 方法也会从上次结束的位置开始向后进行匹配。还是上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
let regex = /\b(\d+)\b/g;
let str = '2019.10.1';
while (true) {
let result = regex.exec(str);
if (!result) break;
console.log(result, regex.lastIndex);
/* result:
* [ '2019', '2019', index: 0, input: '2019.10.1' ] 4
* [ '10', '10', index: 5, input: '2019.10.1' ] 7
* [ '1', '1', index: 8, input: '2019.10.1' ] 9
*/
}

由上述代码可以看出,在使用 exec 时,经常需要配合使用 while 循环

String.prototype.match()

返回一个数组,数组包含所有匹配的子字符串,没有匹配结果时返回 null

1
2
3
let regex = /x/g;
let str = 'xyzx';
console.log(str.match(regex)); //result: ['x', 'x']

返回第一个满足正则的匹配结果在整个字符串中的位置,如果不能匹配则返回-1

1
2
3
let regex = /y/g;
let str = 'yellow';
console.log(str.search(regex)); // result: 0

String.prototype.replace()

替换匹配正则的值。接受两个参数,第一个为相应的正则,第二个为需要替换的内容

1
2
3
let regex = /\d+/g;
let str = '080808world';
console.log(str.replace(regex, 'hello ')); //result: hello world

replace 方法的第二个参数可以使用美元符号$,用来指代所替换的内容,例如:

1
console.log('xbrave blog'.replace(/(\w+)\s(\w+)/, '$2 $1')); //result: 'blog xbrave'

replace 方法的第二个参数也可以是一个函数,返回的值会替换为对应的匹配内容,例如:

1
2
3
'x and y'.replace(/x|y/g, function (str) {
return str.toUpperCase();
}); //resulte: 'X and Y'

作为 replace 方法第二个参数的替换函数,可以接受多个参数。其中,第一个参数是捕捉到的内容,第二个参数是捕捉到的组匹配(有多少个组匹配,就有多少个对应的参数)。此外,最后还可以添加两个参数,倒数第二个参数是捕捉到的内容在整个字符串中的位置,最后一个参数是原字符串。

String.prototype.split()

按照对应的正则分割目标字符串,返回一个分割结果组成的数组

1
'x189y'.split(/\d+/); //result: ['x','y']

总结

正则表达式算是一种学习曲线比较陡峭,但是理解贯通之后收益较大的知识。在实际运用中,诸如 ejs 之类的涉及到字符串处理的方面用处比较多,也可以很方便的抽象出对应的字符串模板。因此对于一些复杂字符串的分割与匹配,我们可以使用正则表达式这一利器帮助我们实现相应的功能。但是需要注意的是,由于正则表达式的性能问题与多解性,在比较复杂的字符串匹配的情况下,写好一个准确性与效率达到平衡的正则表达式不是一件简单的事情,需要我们多思考,多实践,这样才能逐渐掌握这一知识。

参考资料

Learn Regex The Easy Way
JS 正则表达式完整教程
MDN-Regex

Comments