学习JS中的正则表达式——从入门到懵逼

198 阅读21分钟

正则表达式,英文简称:RegExp,全称:Regular Expression。关于正则表达式的由来和介绍就不多说了,大家只要记住正则是匹配模式,要么匹配字符,要么匹配位置。

我们系统的学习知识和打游戏是一样的,初期只能挑战一些小喽啰,涨涨经验值,等级和装备齐全后就可以逐一去挑战boss们。所以我们先来看下正则世界里都有哪些小怪物。

友情提示:学习正则表达式对新手来说并不是很友好,一些基础的知识需要通过死记硬背来学习,你得先记住它,才能灵活运用它。

学一门前端工具,几年就过时了。学正则表达式,受用一辈子。

1 正则表达式的语法(正则世界里的小怪物)

1.1 创建一个正则表达式

  1. 使用字面量的方式
/pattern/flags
  1. 使用构造函数的方式
new RegExp(pattern [, flags])
RegExp(pattern [, flags])

其中pattern为正则表达式的文本,flags为修饰符,可以为一个,也可以是多个为一组。 一般来说需要动态构造正则的时候才会用到构造函数的方式。因为字面量写法的正则性能更好一些。 当一个正则实例创建出来以后,它都会有以下的实例属性:

  • lastIndex属性。它的作用是全局匹配时标记下一次匹配开始的位置,全局匹配的抓手就是它。
  • source属性。它的作用是存储正则模式的主体。比如/abc/gi中的abc。
  • 对应的修饰符属性。目前有globalignoreCasemultilinestickydotAllunicode属性,返回布尔值表明是否开启对应修饰符。
  • flags属性。返回所有的修饰符。比如/abc/gi中的gi。

ps:如果以构造函数方式构造正则的时候,pattern中定义了修饰符,但flags中又有值,则以flags定义的修饰符为准,这是ES2015的新特性。

1.2 修饰符

vue指令后面跟着的修饰符一样,正则表达式有修饰符,它像小尾巴一样跟在正则主体后面。

修饰符含义是否常用
ggglobal的缩写。默认情况下,正则从左向右匹配,只要匹配到了结果就会收工。g修饰符会开启全局匹配模式,找到所有匹配的结果常用
iiignoreCase的缩写。在正则世界里,大小写字母是有区别的,i修饰符可以全局忽略大小写。常用
mmmultiline的缩写。这个修饰符有特定起作用的场景:它要和^$搭配起来使用。默认情况下,^$匹配的是文本的开始和结束,加上m修饰符,它们的含义就变成了行的开始和结束。常用
yysticky的缩写。y修饰符有和g修饰符重合的功能,它们都是全局匹配。所以重点在sticky上,怎么理解这个粘连呢?g修饰符不挑食,匹配完一个接着匹配下一个,对于文本的位置没有要求。但是y修饰符要求必须从文本的开始实施匹配,因为它会开启全局匹配,匹配到的文本的下一个字符就是下一次文本的开始。这就是所谓的粘连。ES2015新特性,不常用
sssingleline的缩写。s修饰符要和.搭配使用,默认情况下,.匹配除了换行符之外的任意单个字符,然而它还没有强大到无所不能的地步,所以正则索性给它开个挂。s修饰符的作用就是让.可以匹配任意单个字符。ES2018新特性,不常用
uuunicode的缩写。有一些Unicode字符超过一个字节,正则就无法正确的识别它们。u修饰符就是用来处理这些不常见的情况的。ES2015新特性,不常用

m修饰符在匹配换行文本的时候很有用

`
abc
xyz
`.match(/^xyz$/);
// 匹配结果为null。如果不加m,这里^和$还是代表文本的开始与结束,这里文本的开始和结束相邻的字符都是换行符,所以匹配不到东西
`
abc
xyz
`.match(/^xyz$/m); 
// 匹配结果为['xyz']。加了m,这里^和$还是代表行的开始与结束,其中xyz就为一行。

1.3 普通字符

何为普通字符?就是包括各种人类语言,24个英文字母(包括大小写),没有特殊含义的符号,甚至emoji表情也算普通字符。普通字符在正则中的含义就是检索它本身,你看:

'hello 😀 regex'.match(/😀/);
// ["😀", index: 6, input: "hello 😀 regex", groups: undefined]

1.4 元字符

在正则世界里大部分符号都是作为普通字符的存在,但总是有钉子户的存在,还有几个符号是有自己的特殊含义的,这种符号被称为元字符。

1.4.1 常用元字符

  1. 字符相关

    • \:将下一个字符标记为一个特殊字符、或一个原义字符、或一个向后引用、或一个八进制转义符。例如,“n”匹配字符“n”。“\n”匹配一个换行符。串行“\”匹配“\”而“(”则匹配“(”。简单来说,转义符能将一些普通字符提升成元字符,转义符能将任何符号砭为庶民(普通字符),包括它自己(我狠起来自己都打),当然普通字符转义后还是转义字符。

    • |:|就代表或者。字符组其实也是一个多选结构,但是它们俩有本质区别。字符组最终只能匹配一个字符,而分支匹配的是左边所有的字符或者右边所有的字符。经常在()中使用。

    • \d:匹配一个数字字符,等价于[0-9],这里的数字不是JS中的Number类型,指字符串中的数字。

    • \D:匹配一个非数字字符,等价于[^0-9]。

    • \s:匹配一个空白字符,空白字符不单单包括空格,它是一个总集空格\f\n\r\t\v,包括空格,换页符,换行符,回车符,水平制表符,垂直制表符。但是这个总集里大部分符号都是不可打印的符号,只有\n和我们经常接触了,如果你不需要区分空格与换行,可以大胆使用\s,如果你想只匹配一个空格,在正则的写法就是空一格。

    • \S:匹配任何非空白字符。等价于[^ \f\n\r\t\v]

    • \w:匹配一个26个英文字母或者一个数字或者一个下划线,等价于[A-Za-z0-9_].注意这里会多一个下划线,因为在Javascript中的变量规则中,使用这三种命名是比较合理的。

    • \W:匹配任何非单词字符。等价于[^A-Za-z0-9_]

    • .:所谓字越少,事情越大。这个小点号在正则世界里的能量非常大。它能匹配任意字符(但是行结束符除外:\n \r \u2028\u2029),但是在字符组中它就是个点,无论你怎么转义都没有用。

  2. 位置相关

    • ^:通常匹配输入字符串的开始位置,说通常,因为它在字符组中[^abc]另有含义。

    • $:与上面的相反,匹配输入字符串的结束位置。 需要注意的几点地方:

      • 作为匹配文本开始元字符的时候必须是正则主体的第一个符号或者最后一个符号,否则正则无效。
      • 它匹配的是一个位置,而不是具体的文本
      • 多行匹配模式时(修饰符中带m),二者分别是行开头和行结尾的概念
      • ^符号在正则的字符组中有其他含义,$在字符串方法replace中有其他含义。
    • \b:匹配一个单词边界(boundary),匹配的是一个位置,而不是一个字符。单词和空格之间的位置,就是所谓单词边界,并且对汉字及其他语言是无效的。具体来讲就是匹配\w\W之间的位置,也包括\w^之间的位置,也包括\w$之间的位置。

        'hello regex'.match(/\bregex$/); // ['regex']
        'hello regex'.match(/\b/g); // ["", "", "", ""] 有4个单词边界,分别为hello前后位置,regex前后位置
        '窝窝头 一块钱 4个'.match(/\b窝窝头\b/); // null
      
    • \B:匹配一个非单词边界,匹配的一个不是单词边界的位置。具体说来就是匹配\w\w\W\W^\W\W$之间的位置。

        '1234'.match(/1\B2/); // ['12']
        '1234'.match(/\B/g); // ["", "", ""] 有3个非单词边界,分别为12,23,34中间的位置
        		'#'.match(/\B/g);  // ["", ""]
      

1.4.2 不常用元字符

  • \cx:匹配由x指明的控制字符。例如,\cM匹配一个Control-M或回车符。x的值必须为A-Z或a-z之一。否则,将c视为一个原义的“c”字符。
  • \f:匹配一个换页符。等价于\x0c和\cL。
  • \n:匹配一个换行符。等价于\x0a和\cJ。
  • \r:匹配一个回车符。等价于\x0d和\cM。
  • \t:匹配一个制表符。等价于\x09和\cI。
  • \v:匹配一个垂直制表符。等价于\x0b和\cK。

1.5 量词

在正则的世界里,一个字符或者元字符也只能匹配对应的一个字符,但是如果需要匹配重复的字符,正则世界里量词就能帮助我们,量词加上元字符的使用,就像是加特林机枪加上了蓝光特效,强大并且炫酷。

  • ?:重复零次或者一次。很好记忆,就像我们理解的问号一样,不确定有没有。
  • +:重复一次或者多次,也就是至少一次。
  • *:重复零次或者多次,也就是任意次数。可以把这个想象成天上的星星,有时候你看不到星星 ,有时候你可以看到数不完的星星。
  • {n}:重复n次,在正则中{}符号中间要是有个数字或者是数字加,是有特殊含义的,但是如果中间是其他东西是不需要转义的。
  • {n,}:重复n次或者更多次,{1,}相当于+{0,}相当于*
  • {n,m}:重复n次到m次之间的次数,包含n次和m次,{0,1}相当于?

量词虽好,但还是需要注意以下几点!

  • {n,m}中间不能有空格,空格在正则中是有含义的,{,m}是没有意义的,甚至不用对他进行转义,它就是普通的字符串。
  • 量词后面跟着量词会报错(有特殊情况,下面说的非贪婪模式)。
  • /.*/功能强大,常用于匹配对我们没有价值的字符,能匹配若干除换行符之外的字符,但是性能不太好。
  • 正则中不是所有地方的?都是做量词用的,?还有其他含义,在下面的内容我会都列出来。

1.6 贪婪模式与非贪婪模式

上面说到了量词后面不能跟量词,但是会有一种特殊情况,那就是通过?切换贪婪模式与非贪婪模式

正则世界的默认模式是贪婪模式,什么意思呢?比如'Gooooogle'.match(/o+/g)这个正则里,无论你有多少个o我都给你匹配出来。

?紧跟在任何一个其他限制符(*,+,?,{n},{n,},{n,m})后面时,匹配模式切换成非贪婪模式。非贪婪模式会尽可能少的匹配所搜索的字符串,而默认的贪婪模式则尽可能多的匹配所搜索的字符串。

'gridsum good'.match(/g\w{1,3}/g) // ["grid", "good"] 既然你能给我3个,我就要3个
'gridsum good'.match(/g\w{1,3}?/g) // ["gr", "go"]  我只要一个就好啦,不贪

正确的使用非贪婪模式可以无形的优化你的正则表达式,因为正则引擎在解析你写的正则的时候能更明确你所要的东西。

小练习

'123 1234 12345 123456'.match(/\d{2,5}/g)的匹配结果

'123 1234 12345 123456'.match(/\d{2,5}?/g)的匹配结果

1.7 字符组

在正则的世界里,一个字符只能匹配到它自己,但是如果你只知道这个字符是'abc'中的一种,这时候字符组就能帮助到我们。注意:字符组虽然里面带组,但是它是匹配一个字符的。

[]:方括号在正则中表示一个区间,我们称它为字符组,字符组中的字符集合只是所有的可选项,最终它只能匹配一个字符,除了-^在字符组中有特殊含义,其他字符一律作为普通字符处理。但是呢 ,带\的元字符还是可以正常使用的。

^如果在字符组的最前面中表示取反,不再是文本开始的位置了。如果在字符组的其他位置就是个普通字符。

-本来是一个普通字符,在字符组中摇身一变成为连字符。但是注意,只能连接大小写英文字母和数字,比如数字-数字数字-字母字母-字母。如果-前后有一个地方是数字或者字母,但是另一边是其他符号,正则会报错哦,其中小写字母-大写字母这种方式也会报错,为什么会报错呢,我们想一下ASCII码,大写字母是在小写字母前面的。但是数字-小写字母或者数字-大写字母或者大写字母-小写字母这些方式不能乱用哦,因为这里面包含的不只是数字与字母了,还会有其他符号。所以可以看出字符组里的连接符是根据ASCII码来的。

var str = '123abcABC~!@#$%^&*()_+=-{}[]\|:;,.?/<>`';
str.match(/[A-z]/g) // ["a", "b", "c", "A", "B", "C", "^", "_", "[", "]", "`"]
str.match(/[0-z]/g) // ["1", "2", "3", "a", "b", "c", "A", "B", "C", "@", "^", "_", "=", "[", "]", ":", ";", "?", "<", ">", "`"]
str.match(/[0-Z]/g) // ["1", "2", "3", "A", "B", "C", "@", "=", ":", ";", "?", "<", ">"]

如果你只需要-^作为普通字符,前面加\转义即可。

'grey or gray'.match(/gr[ae]y/g); // ['grey','gray']
'$'.match(/[$&@]/); // ['$']
'xyz-3'.match(/[0-c]/); // ['3']

小练习

匹配24小时时间制:/^([01][0-9]|[2][0-3]):[0-5][0-9]$/

1.8 捕获组与非捕获组

前面我们知道了普通字符加量词的组合,能够匹配出单个字符重复很多次的字符串,那么我要匹配一个重复的字符串呢?此时捕获组来了,它跨着大马步向我们走来。

在正则中()俩个圆括号代表着将它其中的字符集合打包成一个整体,然后量词就可以操作这个整体了。并且()的匹配结果默认是可以捕获的。如果你不想将括号内的字符捕获出来,只要在圆括号内最前面加上?:标识,像这样(?:)

'窝窝头 一块钱 一块钱 一块钱 4个'.match(/窝窝头 (一块钱 )+4个/); // ['窝窝头 一块钱 一块钱 一块钱 4个', '一块钱'] 默认情况下,括号内的内容也会匹配出来
'窝窝头 一块钱 一块钱 一块钱 4个'.match(/窝窝头 (?:一块钱 )+4个/); // ['窝窝头 一块钱 一块钱 一块钱 4个'] ‘一块钱’没有了

捕获分为正则内捕获和正则外捕获

  • 正则内捕获,正则内捕获使用\数字的形式,分别对应前面的圆括号捕获的内容。这种捕获的引用也叫反向引用。比如我们把html所有非自闭合标签都匹配出来,非自闭合标签的特性就是前后有对应的标签名,像<div></div>。如果在括号内使用了?:标示,\数字的反向引用方式就会失效。

      '<App>hello regex</App>'.match(/<([a-zA-Z]+)>.*<\/\1>/); // ['<App>hello regex</App>'] 这里的\1就是前面()捕获到的内容
      '<App>hello regex</App>'.match(/<(?:[a-zA-Z]+)>.*<\/\1>/); // null
    

    同时,括号内是可以继续加括号的,那么\数字是如何一一对应前面的括号的,记住一点深度优先的原则,同级捕获按前后顺序,同一个捕获内最外面一层优先(就像剥洋葱),比如下面的栗子:

      '<App>hello regex</App><p>A</p><p>hello regex</p>'.match(/<((A|a)pp)>(hello regex)+<\/\1><p>\2<\/p><p>\3<\/p>/);
      // ["<App>hello regex</App><p>A</p><p>hello regex</p>", "App", "A", "hello regex"]
      // \1对应的是((A|a)pp)这个捕获,
      // \2对应的是(A|a)这个捕获,
      // \3对应的是(hello regex)这个捕获
    

    (ps:小思考——既然反向应用是按照数字顺序来的,那么\10表示的是第十个括号,还是\10的意思呢?)

    这个确实有点绕,如果你还没整明白,你可以尝试使用ES2018的新特性——捕获命名。在捕获组内部最前面加上?<key>,它就被命名了。使用\k<key>语法就可以引用已经命名的捕获组。捕获命名只在正则内捕获生效哦,对上面的代码该改造如下。

      '<App>hello regex</App><p>A</p><p>hello regex</p>'.match(/<(?<bar>(?<foo>A|a)pp)>(?<tob>hello regex)+<\/\k<bar>><p>\k<foo><\/p><p>\k<tob><\/p>/);
      // ["<App>hello regex</App><p>A</p><p>hello regex</p>", "App", "A", "hello regex"]
      // \k<bar>对应的是((A|a)pp)这个捕获,
      // \k<foo>对应的是(A|a)这个捕获,
      // \k<tob>对应的是(hello regex)这个捕获
    
  • 正则外捕获,我们先看下面这个栗子。

    '@abc'.match(/@(abc)/);
    // ["@abc", "abc", index: 0, input: "@abc", groups: undefined]
    RegExp.$1;
    // "abc"
    

    没错,RegExp就是构造正则的构造函数。如果有捕获组,它的实例属性$数字会显示对应的引用。如果有多个正则,RegExp构造函数的引用只显示最后一个正则的捕获。

    其实正则外的捕获更加常用的是字符串的方法replacereplace是我们日常开发中比较常用的。在下面的环节中会详细介绍它。

    'hello **regex**'.replace(/\*{2}(.*)\*{2}/, '<strong>$1</strong>');
    // "hello <strong>regex</strong>"
    

1.8 零宽断言

零宽断言算是一个精英怪了,在前面的元字符环节,我们了解到,正则里有一些元字符只匹配位置,不匹配字符。比如^,$,\b,\B等。当然正则还有一些比较高级的匹配位置的语法,它匹配的是:在这个位置之前或之后应该有什么内容。

所谓零宽指的就是它匹配一个位置,位置是没有宽度的(这句话很关键的,所以位置匹配可以无限重复的,所以上面说的位置相关元字符也是可以和断言搭配使用的)。所谓断言指的是一种判断,断言之前或之后应该有什么或应该没有什么。

  • 零宽肯定先行断言:所谓的肯定就是判断有什么,而不是判断没有什么。而先行指的是向前看(lookahead),断言的这个位置是为前面的正则规则服务的(人话:整个圆括号匹配的是个位置,那么匹配到这个位置的规则写在圆括号内,圆括号里的内容为括号前面的正则息息相关)。语法很简单:圆括号内最左边加上?=标识。看下面这个栗子:

      `CoffeeScript JavaScript Typescript`.match(/\b\w{4}(?=Script\b)/); // ["Java"]
    

    这句正则什么意思呢?匹配4个字母,这4个字母最前面是一个单词边界,并且后面有一个符合一个规则的位置,这个规则为一个结尾为单词边界位置的Script字符串。在CoffeeScript JavaScript Typescript里,满足这个规则的只有前面俩个CoffeeScript JavaScript,其中CoffeeScriptScript前面为\b\w{6},所以不符合正则要求,只有JavaScript符合,并且牢记断言是没有宽度的这个特性,所以只匹配出里Java出来。

    为了说明没有宽度这个特性,我们接着看这个栗子:

      'CoffeeScript JavaScript javascript'.match(/\b\w{4}(?=Script\b)\w+/); // ['JavaScript']
    

    我们在(?=Script\b)后面新添里正则\w+,根据前面的解释,\b\w{4}(?=Script\b)依旧匹配的是Java,然后\w+匹配到里Script,所以匹配结果为JavaScript

  • 零宽否定先行断言:肯定是判断有什么,那么否定就是判断没有什么,语法是圆括号内最左边加上?!标识。

      'TypeScript Perl JavaScript'.match(/\b\w{4}(?!Script\b)/); // ['Perl']
    
  • 零宽肯定后行断言:前面说的先行是向前看,那么后行就是向后看(lookbehind),断言的这个位置是为后面的规则服务的。语法很简单:圆括号内最左边加上?<=标识。有一点要注意,后行断言是ES2018的新特性,目前桌面浏览器只有Chrome62和Opera49以上的版本才支持,其他浏览器都不支持,会报错

      '演员高圆圆 将军霍去病 演员霍思燕'.match(/(?<=演员)霍\S+/);
      // 匹配结果为['霍思燕']。我们先看`霍\S+`这个正则的意思,霍开头并且后面有一个或者无数个为非空字符的字符组,满足这个正则条件的是`霍去病`和`霍思燕`,而后行断言是为后面的规则服务的,`(?<=演员)`的意思就是谁前面的字符串为演员,很明显`霍思燕`是满足这个条件的。
    
  • 零宽否定先行断言:同理,肯定是判断有什么,那么否定就是判断没有什么,语法是圆括号内最左边加上?<!标识。

      '演员高圆圆 将军霍去病 演员霍思燕'.match(/(?<!演员)霍\S+/);
      // 匹配结果为['霍去病']
    

2 JS中与正则相关的方法(正则世界的武器)

讲到这里,我们已经手撕了前面的小怪们,准备推开武器库的大门,让我们看看都有哪些趁手的武器吧。

因为正则表达式是用来处理字符串的,所以大部分的方法与String类型有关。在上面的栗子中,使用的match,就是其中一种,下面我把所有和正则有关的的方法集结如下(有些MDN上标明已废弃的就不列出),我们简单的过一下。

经过前面一番小怪的历练,我们经验值逐渐涨了起来,但是光有等级,没有好使的武器可不行啊,所以我们接下来去正则世界里的"武器库",看看有哪些好使的武器。

2.1 String相关

2.1.1 match

match检索返回一个字符串匹配正则表达式的的结果。

语法: str.match(regexp)

它接受一个正则表达式作为唯一参数,但是你传一个字符串进去,会隐式的将其转换为一个正则实例。 match的返回值可以分为三种情况

  • 匹配失败的时候返回null
  • 非全局匹配:返回一个数组,数组的第一项为匹配结果,如果你没有传任何参数给match,匹配结果为空字符串,如果正则里有捕获组并且没有设置?:,那么从第二项开始依次排列捕获的结果。并且数组里有三个属性:index,input,groups
  'i love gridsum'.match(/\blo(v)(?<key>e)/)
  //  ["love", "v", "e", index: 2, input: "i love gridsum", groups: {key: "e"}]
  // index属性,标明匹配结果在文本中的起始位置。
  // input属性,显示源文本。
  // groups属性,它存储的不是捕获组的信息,而是捕获命名的信息。
  • 全局匹配:返回一个数组,匹配的结果会依次在数组列出,但是其他信息都不会列出来。

2.1.2 matchAll

matchAll方法返回一个包含所有匹配正则表达式的结果及分组捕获组的迭代器。迭代器相关的知识可以参考阮一峰ECMAScript入门;

语法:str.matchAll(regexp)

  const bar = '@gridsum1-good-$gridsum3'.matchAll(/([^-]+)gridsum(\d+)/g);
  bar.next(); // {value:['@gridsum1', '@', '1'], done: false}
  bar.next(); // {value:['@gridsum3', '$', '3'], done: false}
  bar.next(); // {value:undefined, done: true} 结束
  // value里的这个数组里,第一个数代表的是匹配的结果,后面依次为捕获组的内容

如果没有 g 标志则matchAll 只会返回首个匹配。当迭代器消耗尽了,无法再次复用,需要重新获取一个新的迭代器。

也可以通过扩展运算符或者Array.form()或者for...of来进行循环。

  var bar = '@gridsum1-good-$gridsum3'.matchAll(/([^-]+)gridsum(\d+)/g);
  var [bar1,bar2,bar3] = Array(3).fill(bar, 0 , 3);
  var arr1 = [...bar];
  // [["@gridsum1", "@", "1", index: 0, input: "@gridsum1-good-$gridsum3", groups: undefined],["$gridsum3", "$", "3", index: 15, input: "@gridsum1-good-$gridsum3", groups: undefined]];
  var arr2 = Array.from(bar2);
  for (i of bar3) {
    i.next();
  }

2.1.3 replace

replace根据给定的字符串或者正则替换匹配结果,并返回新的替换后的文本。源文本不会改变。它有俩个参数,第一个参数为 字符串或者正则表达式,作用为匹配(字符串只能替换一次,不如正则的表达能力强)。第二个参数为字符串或者一个函数,作用为替换。

语法: str.replace(regexp|substr, newSubStr|function)

我们在正则小怪中的捕获中埋了一个坑,说正则外捕获大部分与replace有关,现在我们就讲讲为啥与replace有关。

在替换字符串中,replace提供了一些特殊的变量名。

变量名代表的值示例示例结果
?插入一个 $'-abcd-'.replace(/-/g, '?')abcdabcd
$&插入匹配的子串。'-abcd-'.replace(/-abcd-/g, '{$&}'){-abcd-}
$`插入当前匹配的子串左边的内容。'-abcd-'.replace(/-/g, '{$}')`{abcd-}abcd{}
$'插入当前匹配的子串右边的内容。'-abcd-'.replace(/-/g, "{$'}"){abcd-}abcd{}
$n假如负责匹配的参数是一个正则,$n代表的是第n个括号匹配到的字符串(同样为内部没有?:的括号),n是从1开始并且小于100的非负整数。'-abcd-'.replace(/(-)(a)bcd-/g, '$1$2')-a

其中用的最多的还是$n这种方式。所以正则外捕获是与replace息息相关的。

replace的第二个参数为一个函数时,当匹配执行时,该函数就会执行,函数的返回值作为替换字符串,如果你没设置返回值,默认返回undefined字符串,如果第一个参数是正则表达式,并且其为全局匹配模式,那么这个方法将被多次调用,每次匹配都会被调用。

该函数共有4个参数。

函数的第一个参数,是匹配结果。对应于上述的$&

  'hello gridsum'.replace(/gridsum/g, (match) => `{${match}}`);
  // "hello {gridsum}"
  'hello gridsum'.replace(/gridsum/g, (match) => {});
  // "hello undefined"

如果replace方法第一个参数是正则表达式,函数的第二个参数开始与正则中的捕获组一一对应,对应与上诉的$n

  '@gridsum1-good-$gridsum3'.replace(/([^-]+)gridsum(\d+)/g, (match, $1, $2) => `{${$1}${match}${$2}}`);
  // "{@@gridsum11}-good-{?gridsum33}""

函数的倒数第二个参数是匹配结果在文本中的位置。

  '@gridsum-good-$gridsum'.replace(/([^-]+)gridsum/g, (match, $1, index) => `{${match}是位置是${index}}`);
  // "{@gridsum是位置是0}-good-{$gridsum是位置是14}"

函数的最后一个参数是被匹配的原字符串。

replace的用途很广泛,它能很好的达到我们想要替换字符串的操作。几个栗子。

  • 转义HTML标签:'<p>hello regex</p>'.replace(/</g, '&lt;').replace(/>/g, '&gt;');
  • 将华氏温度转换为对等的摄氏温度: '70°F'.replace(/(\d+)°F\b/g, (match, p1)=> (p1-32)*5/9+"°C");
  • 获取指定标签内的值:'<span>hello</span>'.replace(/<span[^>]*>(.*?)<\/span>/g, '$1');
  • 模拟实现trim方法:str.replace(/^\s*(.*?)\s*$/g, "$1");
  • 单词大驼峰化:str.replace(/(^|[-_\s])+(.)?/g, (match, p1,p2)=>p2?p2.toUpperCase():'')

2.1.4 search

search会找出首次匹配项的索引。它的功能较单一,性能也更好。它接受一个正则表达式作为唯一参数。与match一样,如果传入一个非正则表达式,它会调用new RegExp()将其转换成一个正则实例。

语法: str.search(regexp)

  'gridsum-good-gridsum'.search(/good/);
  // 8

因为只能返回首次匹配的位置,所以全局匹配对它无效。如果匹配失败,返回-1。

2.1.5 split

split的作用是根据传入的分隔符切割源文本。它返回一个由被切割单元组成的数组。它接受两个参数。第一个参数可以是字符串或者正则表达式,它是分隔符;第二个参数可选,限制返回数组的最大长度。

语法:str.split([separator[, limit]])

  'abc-def_mno+xyz'.split(/[-_+]/g);
  // ["abc", "def", "mno", "xyz"]

因为split方法中的正则是用来匹配分隔符,所以全局匹配没有意义。

2.2 RegExp相关

2.2.1 exec

exec的作用是根据参数返回匹配结果,与字符串方法match相似。

语法:regexObj.exec(str)

  /xyz/.exec('abc-xyz-abc');
  // ["xyz", index: 4, input: "abc-xyz-abc", groups: undefined]

execmatch小小的区别在于参数为空的情况:exec直接返回nullmatch返回一个空字符串数组。

更大的区别在于全局匹配的时候,exec由于每次都只能匹配一个结果出来,所以全局匹配每次成功后,都需要更新正则实例的lastIndex属性,用于记录上次匹配成功后的下标,当最终匹配结果为null的时候,lastIndex会重归于0,所以这个匹配过程是可以无限重复的。lastIndex属性是属于正则实例的。所以使用exec一般配合正则实例使用。

如果直接使用字面量的正则搭配exec使用,就会一直在原地打转,因为每次都是一个新的正则实例,每次lastIndex都要从0开始。

exec还有与replace搭配使用的方法,看下面这个栗子:

  var regex = /(\d{4})-(\d{2})-(\d{2})/;
  var string = "2017-06-12";
  var result = string.replace(regex, function() {
    return RegExp.$2 + "/" + RegExp.$3 + "/" + RegExp.$1;
  });

注意:RegExp是会被新的正则表达覆盖的。

2.2.2 test

test:方法执行一个检索,用来查看正则表达式与指定的字符串是否匹配。返回 truefalse。与字符串方法search相似。多用于表单验证中。

语法:regexObj.test(str)

test方法与search方法的区别主要体现在两点:

  • search方法返回的是索引,test方法只返回布尔值。

  • 因为是正则实例方法,全局匹配时也会更新正则实例的lastIndex属性,所以也可以多次执行。


3 能看懂复杂的正则(小boss)

从武器库里认识了众多武器后,我们开始来挑战一些小boss。

3.1 匹配身份证

  /^(\d{18}|\d{17}[\dxX])$/

这个正则比较简单,首先想到身份证一定是18位的,但是最后一位可能是x或者X,其他的都是数字,那么正则只要写个分支出来就好了。

3.2 匹配16进制颜色值

  /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/

首先16进制的颜色都是#号开头,后面是3位数或者是6位数的,并且每个数都只能是‘0-9’或者‘a-f’或者‘A-F’,所以我们就可以写出个分支出来了。

3.3 金钱字符串千位符分割

   '1234567'.replace(/\B(?=(\d{3})+(?!\d))/g,',');
   // 1,234,567

这个正则难点在于断言里套了一个断言,本来正则里的断言就不好理解,还是俄罗斯套娃版的断言,更加有难度。但是仔细把这个正则进行拆分,分步骤解读我们还是可以理解这个正则的。

首先我们先理解\B匹配出来的结果是["", "", "", "", "", ""],这些玩意是1和2之间,2和3之间等等的位置,然后(?=(\d{3})+(?!\d))这个先行肯定断言语句能匹配出来一个跟在\B后面的位置,这个位置呢符合\B(\d{3})+(?!\d))这个规则,\d{3}+(?!\d)的意思呢是匹配某种长度至少为3的倍数的数字串,(?!\d)先行否定断言,就是说这个数字串后面的位置不能有数字啦。

分解以后我们就好理解该正则的含义了,比如,先找到1和2中间这个位置,这个位置后面跟着的数字串为'234567',长度为6符合3的倍数,将该位置替换成',',接着检查2和3中间这个位置,这个位置后面的数字串为'34567',长度为5,不符合3的倍数,跳过该位置,依次类推。所以这个正则正好符合金钱的计数法,从最右边开始算起,每隔3位数插入一个','。

当然处理金钱字符串肯定不止这一种方法,比如下面这个:'1234567'.replace(/(?!^)(?=(\d{3})+$)/g,','),但是呢这种我觉得不好理解,当先行断言前面没有任何规则的时候,正则会对每一个位置发起匹配,每一个位置就是指\B\b,我们看这个栗子:

   '1234'.match(/(?=\d)/g) // ["", "", "", ""] 因为最后一个位置后没有数字了,所以不会被匹配出来

所以我们就可以理解这个正则了,我们可以把俩个括号所服务的位置其实是同一位置,这个位置后面不是开头,并且后面跟着长度为3的倍数的字符串,这个字符串结尾一定是结尾,'1234567'.match(/(?!^)(?=(\d{3})+$)/g)匹配出来的东西是["",""],说明有俩个位置符合这个规则,哪俩个呢?1后面的位置和4后面的位置,1后面位置跟着直到末尾的字符串是'234567',长度为6,符合规则,4后面位置跟着直到末尾的字符串是'567',长度为3,符合规则。

所以解决同一个问题的时候,往往有多个版本的正则可以使用,并且对正则的理解可能也会有不同,所以具体使用哪种方法大家见仁见智了。

3.4 密码长度6-12位,由数字、小写字符和大写字母组成,但必须至少包括2种字符

var reg = /^((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-Z]))[0-9A-Za-z]{6,12}$/;
console.log( reg.test("abcdEF234") );

这个正则完整的一看还挺长的,我们先分析需求,先看最简单的由数字、小写字符和大写字母组成,并且长度为6-12位,可以得出正则[0-9A-Za-z]{6,12},至少包括2种字符,那么有3种组合:数字+小写字母,数字+大写字母,小写字母+大写字母,那么我们可以对开头这个位置处理,利用多个断言堆加一起,都是为前面的正则规则服务这个概念,我们可以写出3个分支(?=.*[0-9])(?=.*[a-z]),(?=.*[0-9])(?=.*[A-Z]),(?=.*[a-z])(?=.*[A-Z])。结合一下,就可以写出正则了。

3.5 输入框校验,避免特殊符号输入,允许-/${}符号输入,其他符号输入及空格输入视为非法,并且开头不允许数字或者符号,最长为32个字符。

   var reg = /^(?!(\d|[\W]|_))[0-9a-zA-Z\-/${}]{1,32}$/;
   console.log( reg.test("s-/${}") );   // true
   console.log( reg.test("-dsadsa") );   //false
   console.log(reg.test("s32 13")); // false
   console.log(reg.test("s1234567s1234567s1234567s1234567y")); // false

这是个与我们日常研发中比较相关的栗子,只允许-/${}符号输入,其他符号及空格输入视为非法,那么我们可以写出一个字符组[0-9a-zA-Z\-/${}],开头不允许数字或者符号,我们知道\w相当于[0-9a-z-A-Z_],但是里面有个_符号,所以要对它单独处理,所以配合先行否定断言可以写出正则^(?!(\d|[\W]|_)),最长为32个字符那么就是{1,32},拼接一起后就可以得出正则了。

我们在尝试使用正则表达式去解决问题的时候,会无形中锻炼我们分析问题的能力与思维逻辑,正则与普通程序一样,也有流程的概念。比如上面的身份证匹配正则可视化流程图如下:

所以这里推荐几个的优秀的正则相关网站:

4 扩展

4.1 回溯

回溯这个概念呢,我们稍作了解(因为我懂的也不多- -),正则在匹配的过程中,如果遇到了不正确的匹配,会回到上次匹配正确的步骤,重新开始发起匹配。看这个栗子

	'abbc'.match(/ab{1,3}c/)

一般发生回溯的地方有3个:贪婪量词,惰性量词,分支结构。

上面这个栗子就是贪婪量词的栗子,那为啥惰性量词也会发生回溯呢?因为有时候为了整体的正则成功,非贪婪模式下也不得不多匹配点东西。

	'12345'.match(/\d{1,3}?\d{1,3}/)

分支结构发生的回溯情况,可能前面的子模式会形成了局部匹配,如果接下来表达式整体不匹配时,仍会继续尝试剩下的分支。这种尝试也可以看成一种回溯。

'candy'.match(/^(?:can|candy)$/)

正则如果存在过多的回溯就可能把cpu进程占满!之前网络安全实践分享中提到的 正则DOS攻击,就是黑客写的正则存在过多的正则回溯,把服务器的资源消耗过多,导致卡死,所以写出表达更正确的正则表达式是可以避免回溯的,比如.*这种写法就特别容易造成回溯,所以当你直到要匹配到哪个字符结束的时候,比如我知道要匹配到}结束,那你可以这么写[^}]*

4.2 NFA引擎和DFA引擎

NFA与DFA是俩个合并的单词缩写,FA是有限自动机(Finite Automate),我们可以有限自动机理解为一个机器人,在这个机器人眼里,所有的事物都是由有限节点组成的。机器人按照顺序读取有限节点,并表达成有限状态,最终机器人输出接受或者拒绝作为结束。

DFA是确定性有限自动机的缩写,NFA是非确定有限自动机的缩写,具体啥区别我没去研究过,总的来说,DFA可以称为文本主导的正则引擎,NFA可以称为表达式主导的正则引擎。我们平时写的正则都是用正则去匹配文本,这就是NFA的思路。

引擎的知识也是比较深层次的正则知识点了(我懂的也不是很多- -),大家只需要知道大部分计算机语言的正则引擎是NFA的,JS环境下的正则引擎就是NFA的,意味着所有浏览器的正则引擎都是NFA。

讲到这呢,我们对正则已经有了一个比较全面的了解,希望本次分享能对大家有所帮助。如果大家对这些知识点消化后,工作中灵活运用正则是没问题的。但是正则是门非常"古老"的概念,它源于数学,比任何计算机语言出现都要早,想要把正则世界这个游戏打通关,我们需要走的路还很长~