祝大家狗年旺旺

今天是新年上班的第二天了,首先在这辞鸡迎狗时刻,祝各位看到这里的朋友,新年快乐,财源滚滚。

新的一年,希望我们每个人都把握时间共同进步,争取在辞狗赢猪的时候回头看会觉得没有枉费和辜负这珍贵的时光。

前言(废话,可跳过)

鲁迅曾说过,程序员有两种,一种是会正则的,还有一种也是会正则的。如果完全不懂正则,我管你继承有几种写法,你也配做程序员?

本文就主要是记录一下javascript里RegExp对象的使用。对于正则的详细语法,可以在该页面中了解:正则可视化(没错,这是一个广告)

正则可视化

通过该工具可以方便的分析正则,并且包含简单的语法列表,常用正则,以及文本测试。快点开看看吧!另外该工具的目标是和javascript支持的正则语法保持一致。

RegExp()签名

正则表达式(Regular Expression),顾名思义,就是用来描述文本的正规有规律的表达式(此定义无出处是我个人瞎说的)。先放上函数签名:

new RegExp(pattern:string , flags?:string)

对于前端童靴来说,最习以为常的一件事就是,为什么要写new?好吧,满足你,构造函数又是一个工厂函数:

RegExp(pattern:string , flags?:string)

不过最最常用的,就是字面量声明的形式了,例如: /(Reg).+(Exp).+/i

flags

对于flags,目前支持5种标志: g , i , m , u , y 。默认为空。

分别对应5个只读实例属性,global , ignoreCase , multiline , unicode , sticky 。 如果设置了相关的标志,则对应的属性为true。

这里,好奇心强的童靴一定要回来问我了,只读个屁,我可以重新赋值啊。

那为什么说是只读呢?可以通过该代码了解一下: Object.getOwnPropertyDescriptor(reg.__proto__,'global'),美好又危险的弱类型。

另外还有一个只读的字符串属性 flags ,代表当前正则实例的标志。 这个你尽管试,你要是能重新赋值算我输。

lastIndex属性

对于RegExp实例,正经属于自身的属性,只有lastIndex,其他属性全在原型链上: Object.getOwnPropertyNames(/test/)

globalsticky为true时,lastIndex属性会影响正则的匹配行为,并且匹配结束后会更改实例的lastIndex属性。

其他情况下,会忽略lastIndex属性。即:没有g和y标志时,可以当做lastIndex不存在。

常用方法

常用的使用正则的方法有: String#search() , String#split() , String#match() , String#replace() 以及 RegExp#exec() , RegExp#test()

其中 RegExp#exec() 是使用正则最强大的一个接口,返回一个匹配结果的数组,其中索引0是匹配到的子字符串,之后是匹配到的捕获组字符串。如果匹配失败返回null。
RegExp#test()RegExp#exec()类似,只是返回值是布尔值。

String#search() , String#split() , String#match() , String#replace() 该4个方法都接受正则作为第一个参数,具体的使用就不详细说了。

对于ignoreCase,multiline,unicode标志,所有方法的匹配行为和没有标志的情况是类似的,并且不受lastIndex属性影响,按照预期返回结果。

对于global和sticky,匹配行为会略有不同,并且和lastIndex属性相互影响,具体情况在下文指出。

ignoreCase/multiline标志

为什么先介绍这两个,因为他娘的简单啊。这两个标志最好理解了,故名思议,就是忽略大小写和多行匹配。

var str='wwl\nabc';
/ABC/.test(str);    //false
/ABC/i.test(str);   //true
/^ABC/i.test(str);  //false
/^ABC/im.test(str); //true

unicode标志

在ES6中,新增了两个标志,分别是sticky和unicode。

JS中处理字符串使用的是UTF-16的编码形式,一个字符串可以理解为一个16位无符号整数序列,其中的每个整数代表一个utf-16字符。而对于Unicode code point 大于0xffff的字符,会被当做32位处理,即2个字符。 (个人对于编码的理解也十分浅显)。

'🤗'.length === 2;          //true
'🤗' === '\ud83e\udd17';    //true

ES6开始,添加了JS对unicode更友好的支持。例如上面这个emoji还可以这么写:

'🤗' === '\u{1f917}';        //true

对应正则,也可以使用相应的语法:

/\u{1f917}/.test('🤗');     //false
/\u{1f917}/u.test('🤗');    //true
/\u1f917/.test('🤗');       //false
/\u1f917/u.test('🤗');      //false

而在es6之前,只能这么写:

/\ud83e\udd17/.test('🤗');  //true

并且,正则表达式中的"."语法也可以正确识别出单个字符:

'🤗'.match(/./g).length;    //2
'🤗'.match(/./gu).length;   //1

global标志

global标志,即是否支持全局匹配。听起来很好理解,但是逻辑上怎么实现全局匹配呢?简单的说,就是和lastIndex属性相互影响。

当存在global标志时,每次正则匹配都会从lastIndex指定的索引处进行。
并且匹配结束后,会更新lastIndex属性为匹配结果的下一个字符的位置,如果匹配失败则重置为0。

例如:

var reg1=/#/g, reg2=/#/;

console.log(reg1.test('reg#exp'),reg1.lastIndex);   //true  4
console.log(reg1.test('reg#exp'),reg1.lastIndex);   //true  0

console.log(reg2.test('reg#exp'),reg1.lastIndex);   //true  0
console.log(reg2.test('reg#exp'),reg1.lastIndex);   //true  0

reg1第一次test()完成后,lastIndex属性为4,即匹配结果"#"的下一个字符"e"的索引,第二次test()从索引4开始匹配,则匹配失败,lastIndex重置为0。

所以借助lastIndex属性,global标志以及while就可以完成全局匹配了,例如:

var str = 'wwl,Wwl,wwL,how many', reg = /wwl/ig, total = 0;
while (reg.exec(str)) total++;
console.log(total); //3

在以上列出的常用方法中,对于存在global标志时的情况,简单的列个表格:

方法匹配起始位置匹配成功lastIndex值匹配失败lastIndex值匹配成功返回值匹配失败返回值
RegExp#exec()lastIndex指定处匹配结果的索引+10单个匹配结果的数组NULL
RegExp#test()lastIndex指定处匹配结果的索引+10truefalse
String#replace()000替换掉所有匹配的字符串原字符串
String#match()000所有匹配结果的数组NULL
String#search()0不改变不改变第一个匹配的索引位置-1
String#split()0不改变不改变拆分后的数组拆分后的数组

为了对比,再在放一个表格,当没有设置标志或只存在ignoreCase,multiline,unicode标志时:

方法匹配起始位置匹配成功lastIndex值匹配失败lastIndex值匹配成功返回值匹配失败返回值
RegExp#exec()0不改变不改变单个匹配结果的数组NULL
RegExp#test()0不改变不改变truefalse
String#replace()0不改变不改变只替换掉第一个匹配的字符串原字符串
String#match()0不改变不改变单个匹配结果的数组NULL
String#search()0不改变不改变第一个匹配的索引位置-1
String#split()0不改变不改变拆分后的数组拆分后的数组

可以看出:

简单点说,总共6个方法,设置global对比未设置global:

  1. String#search(), String#split()行为都一样,
  2. 其余4个方法,都会在匹配结束后更改lastIndex属性
  3. 其中String#replace()String#match(),还会影响返回结果。

sticky标志

sticky翻译过来,就是粘性匹配。单从字面意思的看,实在不太好理解。

可以先考虑这样一个场景,假设这样一个字符串:

var str="wwl is nobody";    //fighting! to be a big man.

如果我们想匹配开头的单词,可以这么写:/^\S+/,如果想匹配结尾的单词,可以这么写:/\S+$/。那么,如果我们想匹配从位置索引4处开始的单词呢?

目前在javascript支持的正则语法中,没有这样匹配指定位置的语法。如果非要写,我觉得是不是可以这么写:/^.{4}(\S+)/

那如果通过sticky标志,就可以这么写:

var reg=/\S+/y;
reg.lastIndex=4;
reg.exec(str);  //["is", index: 4]

不知道各位理解其中的意思没有,再放一个和global标志的对比:

var reg_g = /is/g, reg_y = /is/y;

reg_g.lastIndex = 1;
reg_y.lastIndex = 1;
console.log(reg_g.exec(str), reg_g.lastIndex);  //["is", index: 4]  6
console.log(reg_y.exec(str), reg_y.lastIndex);  //null  0

reg_g.lastIndex = 4;
reg_y.lastIndex = 4;
console.log(reg_g.exec(str), reg_g.lastIndex);  //["is", index: 4]  6
console.log(reg_y.exec(str), reg_y.lastIndex);  //["is", index: 4]  6

sticky标志和global标志类似,从lastIndex指定的索引出开始查找匹配,如果匹配成功,则改变lastIndex的值为匹配结果的索引值加1,如果匹配失败,则重置lastIndex为0。

不同之处在于,global标志会从指定的索引处一直向后查找,直到找到为止或到达字符串末尾。而sticky标志限制只匹配指定的索引位置。
lastIndex为0时,等效正则语法"^"。

所以这下明白了么? 再放一个示例:

var str = 'wwl#123';
var reg1 = /#/, reg2 = /#/y;

console.log(reg1.exec(str), reg1.lastIndex);    //["#", index: 3] 0
console.log(reg2.exec(str), reg2.lastIndex);    //null  0

reg1.lastIndex=3;
reg2.lastIndex=3;
console.log(reg1.exec(str), reg1.lastIndex);    //["#", index: 3] 3
console.log(reg2.exec(str), reg2.lastIndex);    //["#", index: 3] 4

console.log(reg1.exec(str), reg1.lastIndex);    //["#", index: 3] 3
console.log(reg2.exec(str), reg2.lastIndex);    //null 0

第一次匹配的时候,lastIndex默认为0,在索引0处,字符为"w",所以匹配失败。
第二次匹配的时候,lastIndex为3,在索引3处,字符为"#",所以匹配成功,更改lastIndex为4,
接下来继续匹配,由于索引4的位置为"1",所以匹配失败,lastIndex重置为0。

到这里,想必应该已经明白所谓粘性匹配的规则了,这里引用一下MDN给出的定义:

sticky; matches only from the index indicated by the lastIndex property of this regular expression in the target string (and does not attempt to match from any later indexes).

我猜应该会有很多人和我有同样的疑问,新增的粘性匹配有什么使用场景呢?

我简单的搜了一下,关于粘性匹配大多都出现在一些关于词法分析的文章中,这里举个特别简单的栗子,假设有如下格式字符串:

var str='<1><2><3><5>6<7><8>'

如果我们要正确匹配每个尖括号中的内容,当遇到不规范的格式时则停止匹配:

var reg = /\s*<(\d+)>\s*/y, result = [], m;
while (m = reg.exec(str)) result.push(m[1]);
console.log(result);    //[1,2,3,5]

接下来看下其余5个常用方法在设置了sticky时的情况:(此为未设置global时的情况,同时设置sticky和global见后文)

RegExp#test()

RegExp#test()RegExp#exec()匹配逻辑完全相同,只是RegExp#test()返回的是布尔值,而RegExp#exec()返回的是数组或者null。

String#split()

对于String#split(),会忽略sticky标志,例如:

var str = 'a,b,c,d', reg1 = /,/, reg2 = /,/y;
reg1.lastIndex = 5;
reg2.lastIndex = 5;
console.log(str.split(reg1), reg1.lastIndex);   //["a", "b", "c", "d"] 5
console.log(str.split(reg2), reg2.lastIndex);   //["a", "b", "c", "d"] 5

String#search()

对于String#search(),会忽略lastIndex,但是要保证开头位置匹配,类似正则前面添加了"^"。这个解释起来比较费劲,看下代码就明白了:

var str = 'abc';
console.log(str.search(/b/));   //1
console.log(str.search(/b/y));  //-1
console.log(str.search(/ab/y)); //0
console.log(str.search(/^ab/)); //0

String#replace()

在上面说global标志时,已经说过,当不存在global时,replace()只替换第一个匹配的子字符串。
global是控制是否全局匹配的,所以当只设置了sticky时,仍然只替换第一个匹配的子字符串,示例代码:

var str = '1,2,3';
var reg_n = /,/, reg_y = /,/y;

console.log(str.replace(reg_n, '#'), reg_n.lastIndex);  //1#2,3 0
console.log(str.replace(reg_y, '#'), reg_y.lastIndex);  //1,2,3 0

reg_n.lastIndex=1;
reg_y.lastIndex=1;
console.log(str.replace(reg_n, '#'), reg_n.lastIndex);  //1#2,3 1
console.log(str.replace(reg_y, '#'), reg_y.lastIndex);  //1#2,3 2

reg_n.lastIndex=2;
reg_y.lastIndex=2;
console.log(str.replace(reg_n, '#'), reg_n.lastIndex);  //1#2,3 2
console.log(str.replace(reg_y, '#'), reg_y.lastIndex);  //1,2,3 0

可以看到,在sticky下,replace()会仅从lastIndex指定的索引出检查是否匹配,不会进行后续的检查。如果匹配则替换,lastIndex为匹配结果位置加1;如果不匹配,则lastIndex重置为0。

String#match()

在上文说过,如果不存在global时,String#match()的返回值和RegExp#exec()一致,即可以理解为当没有global标志时,str.match(reg)等同于reg.exec(str)。


同样列个简单的表格:

方法匹配起始位置匹配成功lastIndex值匹配失败lastIndex值匹配成功返回值匹配失败返回值
RegExp#exec()仅从lastIndex指定处匹配结果的索引+10单个匹配结果的数组NULL
RegExp#test()仅从lastIndex指定处匹配结果的索引+10truefalse
String#replace()仅从lastIndex指定处匹配结果的索引+10只替换掉第一个匹配的字符原字符串
String#match()仅从lastIndex指定处匹配结果的索引+10单个匹配结果的数组NULL
String#search()从字符串开头位置匹配不改变不改变第一个匹配的索引位置-1
String#split()0不改变不改变拆分后的数组拆分后的数组

简单的说,除了split()没什么影响,search()限制必须匹配字符开头,其余的方法都限制仅从lastIndex处检查匹配,并会更改lastIndex的值。

同时设置sticky标志和global标志

上文已经总结过,当设置global标志时,只会影响4个方法: RegExp#exec(),RegExp#test(),String#replace(),String#match()

RegExp#exec(), RegExp#test()

sticky和global都是从lastIndex开始匹配,并且会更改lastIndex值,针对RegExp#exec(),sticky其实相当于更严格的global。

所以,如果同时设置了sticky和global,可以当做只设置了sticky。

String#replace()

如果设置了global,则replace()会替换所有匹配,否则只替换第一个匹配。
如果同时设置了global和sticky呢:

var reg=/w/gy;
console.log('wwlww'.replace(reg, '#'), reg.lastIndex);  //##lww 0
reg.lastIndex = 3;
console.log('wwlww'.replace(reg, '#'), reg.lastIndex);  //##lww 0

这里写一个伪代码:

function replace_gy(str, searchRegex, replacement) {
    var reg = new RegExp(searchRegex.source, 'y');
    var result = '';
    for (var i = 0; i < str.length;) {
        if (reg.test(str)) {
            result += replacement;
        }
        else {
            result += str.slice(i);
            break;
        }
        i = reg.lastIndex;
    }
    return result;
}

当设置了global全局匹配,就会从索引0处以粘性匹配的规则检查是否进行替换,最终达到匹配失败位置或者匹配到字符串末尾,正则的lastIndex被重置为0。

String#match()

当没有设置global时,String#match()等用于RegExp.exec(),返回单个匹配结果的数组。
当设置了global时候,从索引0处会重复执行,返回所有匹配结果。
同时设置了sticky后,依然是重复执行匹配,但是是进行的粘性匹配。放个示例:

var str = 'wwl,WWL,wwL';
var reg_y = /wwl/iy, reg_g = /wwl/ig, reg_gy = /wwl/igy;

console.log(str.match(reg_y), reg_y.lastIndex);     //["wwl", index: 0] 3
console.log(str.match(reg_g), reg_g.lastIndex);     //["wwl", "WWL", "wwL"] 0
console.log(str.match(reg_gy), reg_gy.lastIndex);   //["wwl"] 0

reg_y.lastIndex = reg_g.lastIndex = reg_gy.lastIndex = 4;   
console.log(str.match(reg_y), reg_y.lastIndex);     //["WWL", index: 4] 7
console.log(str.match(reg_g), reg_g.lastIndex);     //["wwl", "WWL", "wwL"] 0
console.log(str.match(reg_gy), reg_gy.lastIndex);   //["wwl"] 0

同样,写一个便于理解的伪代码:

function match_gy(str, searchRegex) {
    var reg = new RegExp(searchRegex.source, searchRegex.flags.replace('g', ''));
    var result = [], match;
    for (var i = 0; i < str.length;) {
        if (match = reg.exec(str)) result.push(match[0]);
        else break;
        i = reg.lastIndex
    }
    return result;
}
方法匹配起始位置匹配成功lastIndex值匹配失败lastIndex值匹配成功返回值匹配失败返回值
RegExp#exec()仅从lastIndex指定处匹配结果的索引+10单个匹配结果的数组NULL
RegExp#test()仅从lastIndex指定处匹配结果的索引+10truefalse
String#replace()000替换掉所有匹配的字符串原字符串
String#match()000所有匹配结果的数组NULL

Regulex

给大家推荐另一个正则可视化的页面:Regulex。在学习正则的过程中,这个工具给了我很大的帮助。
这位大神用JS实现了一个NFA,在博客中(刚试了下发现现在打不开了)看到作者目标是希望实现一个更高效的正则解析器,不过后来貌似搁浅了。有兴趣的可以围观一下。

不过在使用的过程中呢,如果对正则还不是很熟悉的时候,要不断的切换看正则语法,以及在调试界面测试字符串,于是后来就又重复造轮子,不过我的代码比较Low,全程if-else。

本人才疏学浅对正则也只是一般了解,如果文中有错误之处,还请各位及时指出,或者有什么建议也希望能够不吝赐教啊,哈哈和各位在新的一年里,共同学习。

相关链接

知乎文章: JavaScript中的正则表达式对象