手摸手从小白到精通正则(略长)

1,523 阅读21分钟

什么是正则表达式

什么是正则表达式?在我刚入行的时候,可能就肤浅地认为它可以通过一堆奇奇怪怪的字符进行校验,每当有“复杂”的校验时就会去搜索对应的正则,ctrl c 和ctrl v一气呵成。但是正则的应用不仅仅局限于简单的校验,现在先来全面地看下正则表达式。

概念

正则表达式是对字符串操作的一种匹配模式,它由字符元字符组成,然后对目标字符串进行匹配

核心:匹配

从上面的概念可以看出正则表达式的核心就是匹配

  • 匹配什么? 匹配目标字符串中对应的字符位置。这句话灰常重要,一定要有这个意识,这对我们后面的学习会很有帮助。

  • 匹配了能做什么?

    • 校验:也是我们最常用到的,匹配到则存在。
    • 提取:当匹配到对应的字符时,可以将其提取出来以作他用。
    • 替换:当匹配到对应的字符时,可以将其替换为我们想要的(例如:使用replace方法),以此实现增删改功能。

总结

正则表达式是由字符元字符组成的表达式,它能对目标字符串里的字符和位置进行匹配,并能对其进行校验,提取和替换。

入门正则匹配

大部分程序语言都是支持正则的,但作为一个前端,这里就主要以JS里的正则进行讲解。

Tip:下面我们将正式进入正则表达式的编写环节。这里建议可以通过这个网站https://jex.im/regulex 来对自己的正则表达式进行分析,可视化地辅助编写。为了巩固大家的学习成果,强烈建议可以搭配常用正则表达式,进行学习。

创建正则表达式

在JavaScript中,你可以使用以下两种方法来构建正则表达式:

  • 1,使用正则表达式字面量,其由包含在斜杠之间的模式组成,栗子:
    const regex = /shotCat/;
    
  • 2,使用RegExp对象的构造函数,栗子:
    const regex = new RegExp('shotCat');
    

上面两种写法是等价的,都是仅仅只能匹配shotCat。它们的主要区别是,第一种方法是在编译时创建正则表达式,第二种方法则是在运行时创建正则表达式。

注意: 不推荐第二种使用RegExp对象的构造函数,因为用构造函数会多写很多 \,非常不适合阅读,也不适合自己编写。

字符和元字符

从上一章的概念可以知道,正则表达式是由字符和元字符组成的。

  • 字符:就是计算机字符编码,例如:我们常见 数字、英文字母 等。
  • 元字符: 这个是我们要说的重点。元字符也被称为特殊字符。是一些用来表示特殊语义的字符。如\d表示0到9的数字。

正则里的元字符非常多很杂,不利于记忆理解。后面我会按常见使用对其进行分类讲解。如果你想查看所有的元字符可以查看 这里

匹配模式

前面说过正则表达式的核心就是匹配

在正则里,匹配模式可以简单分为:

  • 准确匹配:有时也称为简单匹配。由简单的数字和字母字符组成,没有元字符,纯粹就是一一对应的关系。例如:/shotcat/ 就只能匹配到 shotcat
  • 模糊匹配:由元字符组成,可以匹配复杂的多个字符。例如:/^[0-9]*$/ 则可以匹配所有的数字。模糊匹配也分为两种:匹配的字符有多种可能和字符出现的次数有多种可能。
    • 纵向模糊匹配:正则匹配字符时,如果这个字符,不是唯一的,它可以是a,也可以是b,甚至更多其他可能中的一个。这种情况就被称为纵向模糊匹配。即需要匹配的字符不确定,存在多种可能。为什么叫纵向,举个栗子:我们用手机设置时间时,数字选择时都会有一个纵向的滚轮让你选择,其实都是一个意思,纵向表示有多种可能。
    • 横向模糊匹配:正则匹配字符时,如果这个字符不是只出现一次,它可能出现多次,甚至最少几次,最多几次。种情况就被称为横向模糊匹配。即需要匹配的字符重复次数不确定,存在多种可能。为什么叫横向,很简单,因为重复次数会有很多,横向长度就会拉长。

其实等你熟练了之后,其实没必要记得这么多模式,这里细分出来,为的是大家刚开始学习的时候方便记忆,尤其是对应的元字符的记忆。

正则表达式的方法

在正式学习元字符之前,先熟悉下正则表达式可以使用的方法,方便大家后面理解元字符的例子。

正则表达式可以被用于 RegExp 的 exec 和 test 方法以及 String 的 match、replace、search 和 split 方法。

来一张全家福表格:

方法 描述
exec 一个在字符串中执行查找匹配的RegExp方法,它返回一个数组(未匹配到则返回 null)。
test 一个在字符串中测试是否匹配的RegExp方法,它返回 true 或 false。
match 一个在字符串中执行查找匹配的String方法,它返回一个数组,在未匹配到时会返回 null。
matchAll 一个在字符串中执行查找所有匹配的String方法,它返回一个迭代器(iterator)。
search 一个在字符串中测试匹配的String方法,它返回匹配到的位置索引,或者在失败时返回-1。
replace 一个在字符串中执行查找匹配的String方法,并且使用替换字符串替换掉匹配到的子字符串。
split 一个使用正则表达式或者一个固定字符串分隔一个字符串,并将分隔后的子字符串存储到数组中的 String 方法。

前面说过,正则可以帮助我们对字符进行校验,提取,替换。下面就按这三种功能,将对应的方法进行分类:

  • 校验:
    • test:RegExp方法,校验成功则返回 true 否则返回 false。也是最常用的校验方法。
      var regex = /shotcat/
      var result = re.test('my name is shotcat')
      console.log(result)
      // => true
      
    • search:RegExp方法,校验成功则返回匹配到的位置索引,失败则返回-1。
      var regex = /shotcat/
      var string = "my name is shotcat";
      var result = string.search(re)
      console.log( result );
      // => 11  如果失败则返回-1
      
  • 提取:
    • exec:RegExp方法,返回一个数组,其中存放匹配的结果。如果未找到匹配,则返回值为 null。
      var regex = /shotcat/;
      var string = "my name is shotcat";
      var result = regex.exec(string); 
      console.log(result)
      // => ["shotcat", index: 11, input: "my name is shotcat", groups: undefined]
      
    • match:String方法,返回一个数组,其中存放匹配的结果。如果未找到匹配,则返回值为 null。
      var regex = /shotcat/
      var string = "my name is shotcat";
      var result = string.match(regex)
      console.log( result );
      // => ["shotcat", index: 11, input: "my name is shotcat", groups: undefined]
      
  • 替换:
    • replace:String方法,使用提供的字符串替换掉匹配到的字符串。
      var regex = /shotcat/;
      var string = "my name is shotcat";
      var result = string.replace(regex, '彭于晏'); 
      console.log(result)
      // => ["shotcat", index: 11, input: "my name is shotcat", groups: undefined]
      

注意: 这里只是简单介绍了相关方法的使用,在最后”相关api使用注意“章节中会详细说明这些方法的注意点和坑。

元字符--字符集合 [abc]

栗子:正则/a[bcd]e/可以接受的匹配到结果有abe,ace,ade三种情况。其中[bcd]就被称为字符集合。它用方括号 [ ]表示。

字符集合用来匹配一个字符,该字符可能是方括号中的任何一个字符。栗子:正则/a[bcd]e/表示字符a和e之间的这一个字符只能是[]里面的b或c或d。

表示范围 [a-z]

如果在字符集合里有多个字符,且具有一定顺序的情况下,我们可以使用破折号(-)来指定一个字符范围。例如:用/[a-z]/则可以匹配从a到z的所有英文小写字母。再例如:[123456abcdefGHIJKLM],可以写成[1-6a-fG-M]。用连字符-来省略和简写。

反向字符集 [^abc]

当你在字符集合的第一位加上^(脱字符),表示反向的意思,即它匹配任何没有包含在方括号中的字符。例如[^abc]则匹配任何不是a或b或c的字符。注意:[^abc][^a-c] 意思是一样的。

元字符--匹配单个字符

一般情况下匹配单个字符直接写出来就行了,但是如果需要匹配一些特殊字符,例如:空格,制表符,回车,换行等。这个时候就需要通过转义符来搭配进行使用,详见下表:

特殊字符 正则表达式 记忆方式
换行符 \n new line
换页符 \f form feed
回车符 \r return
空白符 \s space
制表符 \t tab
垂直制表符 \v vertical tab
回退符 [\b] backspace,之所以使用[]符号是避免和\b重复

元字符--同时匹配多个字符

在正则里如果我们要匹配多个字符可以用到[]或者[0-9]这种形式,但是这样仍然不够简洁。所以就有了下表中更加简洁高效的写法来匹配多个字符。

匹配区间 正则表达式 记忆方式
除了换行符之外的任何字符 . 句号,除了句子结束符
单个数字, [0-9] \d digit
除了[0-9] \D not digit
包括下划线在内的单个字符,[A-Za-z0-9_] \w word
非单字字符 \W not word
匹配空白字符,包括空格、制表符、换页符和换行符 \s space
匹配非空白字符 \S not space

元字符--量词{m,n}

在匹配时,匹配到的字符经常会出现重复的情况,这时就需要通过量词对次数进行限制。

{m,n}形式

{m,n}是最常见最基础的量词形式,m 和 n 都是整数。匹配前面的字符至少m次,最多n次。

栗子:/a{1, 3}/表示a出现的次数最少一次,最多3次。 所以它并不匹配shotct中的任意字符。但可以匹配shotcat中的a,匹配shotcaat中的前两个a,也匹配shotcaaaaaaaat中的前三个a。注意: 当匹配shotcaaaaaaaat时,匹配的值是“aaa”,即使原始的字符串中有更多的a。

简写形式

一些常用的量词为了方便(偷懒),人们又规定了一些简写形式:

匹配规则 元字符 联想方式
具体只能多少次 {x} {x}内只有一个数字。定死了,是几就只能是几次
至少min次 {min, } 左边min表示至少min次,右边没有则可以无限次
至多max次 {0, max} 左边数字为0表示至少0次,右边max表示至多max次
0次或1次 ? ,此事
0次或无数次 * 宇宙洪荒,辰宿列张:宇宙伊始,从无到有,最后星宿布满星空
1次或无数次 + 一加, +1
特定次数 {min, max} 可以想象成一个数轴,从一个点,到一个射线再到线段。min和max分别表示了左闭右闭区间的左界和右界

贪婪匹配与惰性匹配

  • 贪婪匹配

    默认情况下,量词(包括简写形式)是贪婪的,即它们会尽可能的多去匹配符合条件的字符(我全都要=。=)。还是之前的栗子:/a{1,3}/,当它匹配“shotcaaaat”时,虽然a出现1次,2次,3次都是符合的,但它还是会贪婪地尽可能匹配最多的次数。所以它不会匹配到1个a时就结束,而是匹配得到3个a。

  • 惰性匹配(也称非贪婪)

    有时候我们不希望量词那么贪婪,只希望它匹配到刚好符合的次数就行,不要那么多。那怎么办呢,此时只需在后面加上一个问号?就行。举个栗子:/a{2,3}?/,当它匹配“shotcaaaat”时,由于此时是惰性匹配,所以它只会匹配得到2个a,而不会贪婪地要3个。

贪婪量词 惰性量词
{m,n} {m,n}?
{m,} {m,}?
? ??
+ +?
* *?

元字符--多选分支x|y

多选分支可以帮助我们匹配多种不同的情况。例如:要匹配字符串 "shot" 和 "cat" 可以使用 /shot|cat/其中通过管道符|将不同备选字符或位置隔开。

注意:多选分具有惰性

多选分支是具有惰性的!即当前面的匹配上了,后面的就不再尝试了。例如:当我们用 /shot|shotcat/去匹配"shotcat"时,得到的结果只有shot。改成 /shotcat|cat/去匹配“shotcat”,就只会得到“shotcat”。

元字符--位置匹配

正则表达式是匹配模式,要么匹配字符,要么匹配位置。

上面介绍的元字符都是匹配字符,下面介绍匹配位置的元字符。

既然要匹配位置,那字符串里的位置是指什么,很简单,指的就是字符与字符之间的位置,或者是字符之间的空字符""。例如:字符串"cat"就有4个位置,分别为:"1c2a3t4"。注意还包括字符开头和结尾的位置。

单词边界\b和非单词边界\B

  • \b 是单词边界

    单词与非单词之间的位置,也就是 \w 与 \W 之间的位置。\b,其中b是boundary边界的首字母。 栗子1:

    var result = "[JS] Lesson_01.mp4".replace(/\b/g, '#');
    console.log(result);
    //  "[#JS#] #Lesson_01#.#mp4#"
    

    栗子2:字符串"my name is shotcat."想要匹配到shotcat。可以使用\bshotcat\b。这样匹配shotcat时,会确保它的前后两边是否都为单词与非单词之间的位置。

  • \B是非单词边界

    很简单,就是单词边界的反面。具体来说就是单词内部之间的位置,非单词内部之间的位置,非单词与开头和结尾的位置,即 \w 与 \w、 \W 与 \W、^(开头) 与 \W,\W 与 $(结尾) 之间的位置。

    栗子1:

    var result = "[JS] Lesson_01.mp4".replace(/\B/g, '#'); 
    console.log(result);
    //  "#[J#S]# L#e#s#s#o#n#_#0#1.m#p#4"
    

字符串边界^ $

说完单词的边界,再说更长的字符串边界。

^(脱字符)匹配字符串开头,在有修饰符m的多行匹配中也匹配行开头。 $(美元符号)匹配字符串结尾,在有修饰符m的多行匹配中也匹配行结尾。

栗子1:

var result = "hello".replace(/^|$/g, '#'); 
console.log(result);
// "#hello#"

栗子2:

var result = "I\nlove\njavascript".replace(/^|$/gm, '#'); 
console.log(result);
/*
#I#
#love#
#javascript#
*/

特定字符的前后位置

如果匹配的位置是在某个特定位置呢,某个特定字符的前后位置。这时就可以用到下面的元字符:

  • 先行断言与后行断言

    • 先行断言:x(?=y) 当字符为y时,则匹配y前面的x。
    • 栗子:
    var result = "orangecat".replace(/orange(?=cat)/, 'shot'); 
    console.log(result);
    // => "shotcat"
    
    • 后行断言:(?<=y)x 当字符为y时,则匹配y后面的x。
    • 栗子:
    var result = "shotdog".replace(/(?<=shot)dog/, 'cat'); 
    console.log(result);
    // => "shotcat"
    
  • 正向否定查找与反向否定查找

    • 正向否定查找:就是先行断言的反面,区别就是不等于y。x(?!y) 当字符不为y时,则匹配y前面的x。
    • 栗子:
    var result = "orangecat".replace(/orange(?!dog)/, 'shot'); 
    console.log(result);
    // => "shotcat"
    
    • 反向否定查找:就是后行断言的反面,区别就是不等于y。(?<!y)x 当字符不为y时,则匹配y后面的x。
    • 栗子:
    var result = "shotdog".replace(/(?<!long)dog/, 'cat'); 
    console.log(result);
    // => "shotcat"
    

位置匹配总结

最后,总结一下:

边界和标志 正则表达式 记忆方式
单词边界 \b boundary
非单词边界 \B not boundary
字符串开头 ^ 头尖尖那么大个
字符串结尾 $ 美元符$
先行断言 x(?=y) 类似三元操作符,?=y 则找前面的x
后行断言 (?<=y)x <寓意前面已经关上了,从后面找 。匹配到y 则找后面的x。
正向否定查找 x(?!y) !表示否定,如果不是y 则匹配前面的x
反向否定查找 (?<!y)x <寓意前面已经关上了,从后面找 。如果不是y 则匹配后面的x。

字符标志

字符标志并不属于元字符,它是对整个正则进行一些全局的操作。目前所有的标志仅有以下几个

标志 描述
g 全局搜索。在匹配到一个结果后,不会停止,直到将整个字符匹配完,得到所有结果
i 不区分大小写搜索。
m 多行搜索。会忽略换行符
s 允许 . 匹配换行符。
u 使用unicode码的模式进行匹配。
y 执行“粘性”搜索,匹配从目标字符串的当前位置开始,可以使用y标志。

一般情况下用得最多的就是前三个g,i,m。标志不是元字符,使用的位置也不在一起:

var re = /\w+\s/g;

var re = new RegExp("\\w+\\s", "g");

分组--正则中括号的作用 ( )

括号的作用:就是将正则表达式里的一部分用括号包裹起来,作为一个整体,也称为子表达式。 这样也就为表达式提供了分组功能。

分组和分支结构

  • 分组:用括号包裹的正则为分组
    • 栗子:
      //  /(ab)+/里ab用括号包裹,提供了分组的功能,表示ab作为一个整体至少出现一次
      var regex = /(ab)+/g;
      var string = "ababa abbb ababab";
      console.log( string.match(regex) ); 
      // => ["abab", "ab", "ababab"]
      
  • 分支结构:前面讲过分支,这里的分支则是在括号内,也是用管道符|表示
    • 栗子:
      //  /^I love (JavaScript|Regular Expression)$/ 包含两种情况 I love JavaScript 和 I love Regular Expression 都可以
      var regex = /^I love (JavaScript|Regular Expression)$/;
      console.log( regex.test("I love JavaScript") );
      console.log( regex.test("I love Regular Expression") );
      // => true
      // => true
      

分组引用

括号形成的分组还具有一个重要的功能 就是分组引用。就是你可以将括号里正则,匹配到的字符进行提取,以及替换的操作。

例如:我们要用正则来匹配一个日期格式,yyyy-mm-dd,我们可以写成分组形式的/(\d{4})-(\d{2})-(\d{2})/ 。这里三个括号包裹的就分别对应分组1,分组2,分组3。

提取数据

在介绍正则表达式方法时,介绍过提取数据,会用到两个方法:String 的 match 方法和正则的 exec 方法。

  • match:
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
console.log( string.match(regex) );
// => ["2017-06-12", "2017", "06", "12", index: 0, input: "2017-06-12"]

match返回一个数组,第一个元素是整体匹配结果,然后是各个分组(括号里)匹配的内容,然后是匹配下标,最后是输入的文本。

  • exec:
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
console.log( regex.exec(string) ); 
// => ["2017-06-12", "2017", "06", "12", index: 0, input: "2017-06-12"]
  • 也可以用正则对象构造函数的全局属性 $1 - $9 来获取:
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";

regex.test(string); // 正则操作即可,例如
//regex.exec(string);
//string.match(regex);

console.log(RegExp.$1); // "2017"
console.log(RegExp.$2); // "06"
console.log(RegExp.$3); // "12"

替换数据

替换数据使用的则是String 的 replace 方法。

栗子:把yyyy-mm-dd格式,替换成mm/dd/yyyy

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
var result = string.replace(regex, "$2/$3/$1");
console.log(result); 
// => "06/12/2017"

// String 的 replace 方法在第二个参数里面可以用 $1 - $9 来指代相应的分组

也等价于:

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-06-12";
var result = string.replace(regex, function(match, year, month, day) {
	return month + "/" + day + "/" + year;
});
console.log(result); 
// => "06/12/2017"

反向引用

前面说到引用分组,它引用的分组是来自于匹配完后得到的结果。而反向引用也可以引用分组,只是它的分组来自于匹配阶段捕获到的分组。为了方便理解下面来看栗子:

要写一个正则支持匹配如下三种格式:

2016-06-12

2016/06/12

2016.06.12

我们会想到这样写:

// 前面知道集合的写法,改写成 [-/.] ,表示这三种都可以
var regex = /\d{4}[-/.]\d{2}[-/.]\d{2}/;
var string1 = "2017-06-12";
var string2 = "2017/06/12";
var string3 = "2017.06.12";
var string4 = "2016-06/12";
console.log( regex.test(string1) ); // true
console.log( regex.test(string2) ); // true
console.log( regex.test(string3) ); // true
console.log( regex.test(string4) ); // true

但是"2016-06/12" 也被判断正确,这很显然不是我们希望的,我们希望第二个连接符,和第一个保持一致。这时候就需要用到反向引用了。我们希望第二个连接符和第一个匹配到的保持一致。首先需要把第一个[-/.]加上括号([-/.]),样才能方便引用。第二个连接符需要和第一个保持一致,这就需要引用它。这个时候就用\1,来表示第一个引用,同理\2\3等表示第二和第三个医用。那么之前的正则就改为了/\d{4}([-/.])\d{2}\1\d{2}/。接着进行验证:

var regex = /\d{4}([-/.])\d{2}\1\d{2}/;
var string1 = "2017-06-12";
var string2 = "2017/06/12";
var string3 = "2017.06.12";
var string4 = "2016-06/12";
console.log( regex.test(string1) ); // true
console.log( regex.test(string2) ); // true
console.log( regex.test(string3) ); // true
console.log( regex.test(string4) ); // false
// 结果完全符合预期!

这里提到的都是理想情况,如果情况更加复杂呢?

分组嵌套

如果出现分组嵌套(括号嵌套)的情况怎么办?这时候对分组序号的判断会产生干扰,到现在你肯定也能感觉出来,正则的匹配顺序是从左到右,同样分组也是这样的从左到右。我们只需按左括号的顺序,依次判断分组即可。

栗子:

var regex = /^((\d)(\d(\d)))\1\2\3\4$/;
var string = "1231231233";
console.log( regex.test(string) ); // true
console.log( RegExp.$1 ); // 123 第一个分组
console.log( RegExp.$2 ); // 1 第二个分组
console.log( RegExp.$3 ); // 23 第三个分组
console.log( RegExp.$4 ); // 3 第四个分组

从左往右分析分组:

第一个分组:((\d)(\d(\d))) 表示需要匹配三个连在一起的数字,其中嵌套了三个分组,匹配得到结果\1:123 第二个分组:(\d) 表示需要匹配一个数字,按照顺序匹配得到结果\2:1 第三个分组:(\d(\d)) 表示需要匹配两个数字,其中嵌套了一个分组,按照顺序匹配得到结果\3:23 第四个分组:(\d) 表示需要匹配一个数字,按照顺序匹配得到结果\4:3

\10表示什么

\10是表示第10个分组,还是\1和0呢?

答案是第10个分组,虽然一个正则里出现\10比较罕见。

var regex = /(1)(2)(3)(4)(5)(6)(7)(8)(9)(#) \10+/;
var string = "123456789# ######"
console.log( regex.test(string) );
// => true

引用不存在的分组

在正则里引用了不存在的分组时,此时正则不会报错,只是匹配反向引用的字符本身。例如\2,就匹配"\2"。

注意:"\2"表示对"2"进行了转意。很可能转义的2 就不是数字2了,就变成其他字符了!所以我们再使用时一定要注意不要引用不存在的分组!

非捕获分组

前面说到的分组都可以被引用,如果我不想被引用,则可以使用非捕获分组(?:p)。因为引用是会在内存里开辟一个位置,所以非捕获分组还可以避免浪费内存。

var str = 'shotcat'
str.replace(/(shotca)(?:t)/, '$1,$2')
// 返回shotca,$2
// 由于使用了非捕获正则,所以第二个引用没有值,这里直接替换为$2

正则匹配步骤与回溯

正则匹配步骤

我们知道正则匹配的方向是从左到右的,那具体到每个字符的匹配步骤是怎样的。我们以一个例子来具体说明:

正则表达式/ab{1,3}bbc/ ,目标字符串为“abbbc”

2~5 :此时正则已经匹配到b{1,3},字符串来到了第三个b。这时候b{1,3}也已经得到满足了拿到了最多的3个b。

6:此时正则来到了b{1,3}后面的第一个b。这时候字符串开始对前面的c进行匹配。

7:发现匹配到了错误的c,但是正则并没有报错,而是进行了回溯。即它又往回走回头路了。正则又回到了b{1,3},字符串也从第三个b回退到了第二个b。发现2个b也是符合b{1,3}条件的。

8:此时正则又来到了b{1,3}后面的第一个b。字符串也把第三个b匹配给了它。

9:正则来到了b{1,3}后面的第二个b,字符串缺发现它前面又是c,又无法匹配。

10: 正则又逐步进行回溯,又来到了b{1,3},字符串也逐步退到了第一个b。

11:此时正则为b{1,3},字符串发现只有一个b,也是满足要求的,就把第一个b给了正则。

12~13:开始逐个匹配最后的bbc,字符串也逐个完成匹配。至此整个匹配过程结束。

回溯

从前面的例子,已经能感觉到什么是回溯。回溯就是正则在匹配过程中,发现下一个字符不能满足匹配,则回退到上一步正则,再匹配其他可能,然后继续往下匹配的过程。如果回溯一步不行,正则还会继续回溯。直到尝试完所有情况。这种匹配方法也被称为回溯法。

本质上就是深度优先搜索算法。其中退到之前的某一步这一过程,我们称为“回溯”。当前面的路走不通时,就会发生“回溯”。即,尝试匹配失败时,接下来的一步通常就是回溯。当回溯发生时会导致资源和时间的浪费,所以我们在编写正则时要尽量避免回溯的发生。

常见的回溯形式

在编写正则时,需要注意以下几点,来避免回溯:

贪婪量词

从前面的例子也可以看出是量词导致了回溯,原因就是默认情况下量词是贪婪匹配的。它会尽量匹配更多的结果,这样就可能导致后面的正则匹配出错,导致回溯。换句话说就是:你太贪了导致后面的吃不到,拿不到匹配的数据。

注意: 如果有多个量词的情况,匹配的结果是怎样的?答:先到先得!

栗子:

var string = "12345";
var regex = /(\d{1,3})(\d{1,3})/;
console.log( string.match(regex) );
// => ["12345", "123", "45", index: 0, input: "12345"]

其中,前面的 \d{1,3} 匹配的是 "123",后面的 \d{1,3} 匹配的是 "45"。

惰性量词

可能你会想到,既然贪婪量词会导致回溯,那就尽量使用惰性量词。

错!惰性量词也会导致回溯,前面说过贪婪量词是太贪了,吃得太多了,导致后面的吃不到匹配的数据。而惰性量词是太懒了,吃得太少了,导致后面的吃太多了,吃不下了

怎么理解,看这个例子:

正则/^(\d{1,3}?)(\d{1,3})$/对'12345’ 进行匹配。

第一个 从步骤5,6可以看出第一个(\d{1,3}?)只匹配了一个1,吃得太少了,导致第二个在遇到后5后,实在吃不下了。那没办法,只能回溯,让第一个再多吃一个2进去,这样就能继续匹配完。

分支结构

前面讲到分支时,也提过分支也是具有惰性的,同样也会导致回溯。例如:/shot|shotcat/当匹配到了shot时,则不会再去考虑后面的shotcat。所以当它匹配字符shotcat时,会首先匹配shot分支,但是到c字母时,发现不匹配又回溯,尝试第二个分支shotcat来进行匹配。

那怎么避免回溯?

我们分析了多种引起回溯的形式,导致回溯的原因是后面的情况走不通,正则回退到了上一步,这样就需要对正则的情况进行合理搭配限制,当次数过多时,可以通过惰性量词进行合理限定,当正则匹配的数据存在关联时,则可以通过引用限定为具体的数据。这些都能有效减少回溯。

正则表达式的阅读

正则是有一堆字符组合成的语言,在阅读起来没有其他语言轻松。所以当我们需要阅读他人的正则,理解其含义就显得很重要。

PS:如果正则实在太难懂了,或者不太确定。其实有很多辅助工具可以帮助分析正则。例如前面提到的https://jex.im/regulex

结构和操作符优先级

前面讲到过正则是有普通字符和元字符组成的。

那结构是什么?就是字符与元字符组成的一个整体。正则会将这个作为一个整体去匹配。例如[abc],它就是由元字符[]和普通字符abc一起组成的一个结构。正则遇到后就会作为一个整体去匹配,匹配的字符可能是abc中的任意一个。

JavaScript 正则表达式包含如下几种结构:字符字面量、字符组、量词、锚、分组、选择分支、反向引用。

结构 说明
字面量 匹配一个具体字符,包括不用转义的和需要转义的。比如a匹配字符"a",又比如\n匹配换行符,又比如\.匹配小数点。
字符组 匹配一个字符,可以是多种可能之一,比如[0-9],表示匹配一个数字。也有\d的简写形式。另外还有反义字符组,表示可以是除了特定字符之外任何一个字符,比如[^0-9],表示一个非数字字符,也有\D的简写形式。
量词 表示一个字符连续出现,比如a{1,3}表示“a”字符连续出现3次。另外还有常见的简写形式,比如a+表示“a”字符连续出现至少一次。
锚点 匹配一个位置,而不是字符。比如^匹配字符串的开头,又比如\b匹配单词边界,又比如(?=\d)表示数字前面的位置。
分组 用括号表示一个整体,比如(ab)+,表示"ab"两个字符连续出现多次,也可以使用非捕获分组(?:ab)+
分支 多个子表达式多选一,比如abc
反向引用 比如\2,表示引用第2个分组。

这些结构里的元字符,也被称为操作符。经常这些操作符会进行组合,嵌套。那到底先执行谁呢,那么操作符也是有优先等级的。如下表:

操作符描述 操作符 优先级
转义符 \ 1
括号和方括号 (...)(?:...)(?=...)(?!...)[...] 2
量词限定符 {m}{m,n}{m,}?*+ 3
位置和序列 ^ 、$、 \元字符、 一般字符 4
管道符(竖杠) | 5

上面操作符的优先级从上至下,由高到低。

说完这么多我们来个栗子,逐步进行讲解

栗子:/ab?(c|de*)+|fg/

1:正则匹配普通字符a

2:b? b字符出现0次或1次

3:遇到括号将(c|de*)作为一个整体

4:继续匹配括号里的c

5:遇到管道符,c和de*作为分支

6:匹配d,然后e后面跟着*,表示e可以重复任意次

7:括号匹配完,遇到+表示(c|de*)需要匹配至少1次

8:然后又遇到了一个管道符。此时将ab?(c|de*)+fg作为两个分支

下面我们再看辅助软件分析得到的示意图:

enter description here

总结: 遇到正则,从左向右进行阅读,根据结构对正则进行划分,结构复杂不确定的就比较优先级,相同结构则依照先到先得的原则。最后实在不行,还有杀手锏,借助辅助进行可视化分析。

正则表达式的构建

说了正则的阅读,现在来讲讲正则的构建。

什么时候需要自己构建正则

学到这,你会发现正则很强大。但你在想要构建正则的时候,希望你问自己几个问题?

  • 是否有现成的api可以做到?

    很多时候,比较简单常见的功能已经有现成的api可以满足。例如:判断字符里是否有'!',可以直接使用indexOf方法。提取某个字符可以根据下标使用substring 或 substr 方法。并且一些框架也会提供常见api方法,例如vue里的修饰符,表单里使用<input v-model.trim="msg">trim可以去除首尾空白字符。

  • 网上是否有现成的正则?

    对于一些很常见的校验,网上都有现成的正则可以使用,这些正则是他人使用后得到验证的,可靠性也是有保障的。

如果上面的问题都得不到满意结果的话,那么可以开始考虑构建正则了

构建的准则

在我们编写正则时,尽量遵循这几个原则,编写出准确高效可靠的正则。

  • 准确的匹配自己想要的字符
  • 正则的可靠性
  • 可读性与可维护性
  • 效率

准确的匹配与构建步骤

在开始编写正则时,首先必须明确的一点是:你必须弄清楚想要的是什么,是要匹配怎样的字符! 这点看似很简单,我当然知道我自己想要什么了,但往往拿到数据才发现有些数据是我不需要的,是我没考虑到。

考虑清楚自己到底想要什么的数据,对编写正则起到了至关重要的作用!

一般的构建步骤:

  • step1 弄清楚你想要的是什么,是要匹配怎样的字符

  • step2 写出一个你认为最具代表性的一个匹配字符

  • step3 开始从左到右构建你的正则,首先是否需要匹配位置,如果是的话,要匹配的字符的位置是在哪,单词边界还是特定字符的前后?还是正常的从左到右,需不需要用^``$限定开头结尾。

  • step4 位置找到后就是字符的限定。关于限定的字符有很多,这里大概分为两类:正向限定和反向限定。在进行限定时要合理使用,既要包含所有我们想要的字符,还要不匹配我们不想要的字符。

  • 正向限定

    什么是正向限定?当我们清楚知道自己想要匹配的数据具体是那些,例如某个具体的字符'abc',或者某个确定的位置,如某个字符的开头或结尾,再或者某个明确的引用\1,再或者明确的集合[1-10]。这些都是正向限定,即你明确知道自己想要的是那些具体的字符,然后对此进行正则限定。

  • 反向限定

    什么是反向限定?当想要匹配的字符范围很大亦或正向限定太多时,我们可以通过排除法,只要不是这字符的就是我们想要的字符。目前正则里的元字符更多的是正向限定,反向限定并不多。更没有什么大于小于之类的。总共就这几个:反向字符集 [^abc]\D,\W,\S,\B,正向否定查找x(?!y)和反向否定查找(?<!y)x

  • step5 字符限定完后,就是它的次数进行限定。注意:次数一般仅对它前面的单个字符起作用,多个字符需要括号作为整体。格外注意次数可能引起的回溯。之后还有其他字符匹配,重复步骤345.

  • step6 最后,就是字符标志,对整个正则进行限定,是全局匹配还是多行等等。

  • step7 校验!自己写的正则一定要回头进行校验检查,是不是包含了所有的情况,边际问题,特殊情况都需要考虑到。用一些特殊字符进行检查校验,并可以通过辅助进行可视化分析,方便修改。

正则的可靠性

这里的可靠性是指正则在运行时是稳定的,不会发生灾难性回溯:不会回溯过多,造成 CPU 100%,正常服务被阻塞。如果你写的正则回溯太多,效率低下,遇到一个很长很长的字符串时,就可能引发灾难性回溯。

这里就有一篇文章一个正则表达式引发的血案,让线上CPU100%异常!

所以在编写完正则后,一定要进行检查优化,确保正则的可靠性,不会出现灾难性回溯。

可读性与可维护性

正则虽然写完是给机器用的,但是还是要给人看的,所以写正则尽量简洁,不要复杂,例如提取分支里公共部分。

效率

有时虽然我们写的正则可以满足要求,但是遇到复杂长一点的字符,或者密集的使用,就会变得缓慢。这时就需要对正则进行修改优化,提升效率。

一般从三方面考虑:减小限定范围,减小内存占用,减小回溯。

  • 缩减限定范围:如果明确是哪些具体字符组,就不要使用通配符了。
  • 减小内存占用:前面讲过,引用是需要占用内存保存的,如果我们只想用来作为一个整体,不会引用,则可以使用非捕获分组(?:)
  • 减小回溯:提取分支公共部分,减少分支。合理使用量词或者当正则匹配的数据存在关联时,可以通过引用限定为具体的数据。最重要的还是对自己正则的匹配过程要熟悉,知晓在哪步会出现匹配不到而走回头路。

注意事项

正则相关方法注意事项

在文章开始部分介绍了正则表达式的方法,鉴于那时还没正式讲解正则,所以只提了基本用法。这里开始对它们使用的注意事项进行说明:

search 和 match 的参数转换问题

search和match方法会默认将字符转化为正则,什么意思,举个栗子:

var string = "2017.06.27"; 
console.log( string.search(".") ); // => 0
// 这里匹配的小标为什么是0呢?我们原本是打算匹配字符串'.' 但是search将它转化为正则了,在正则里'.'代表的是匹配除换行符之外的任何单个字符。所以取到的是2,下标自然就是0

//需要修改成下列形式之一
console.log( string.search("\\.") );  //通过转义
console.log( string.search(/\./) ); // 建议使用search时还是直接使用正则最安全
// => 4
// => 4


console.log( string.match(".") );  // 也是因为将'.'转化为正则了,所以取到的是2,下标也就是0
// => ["2", index: 0, input: "2017.06.27"]
//需要修改成下列形式之一
console.log( string.match("\\.") );
console.log( string.match(/\./) );
// => [".", index: 4, input: "2017.06.27"]
// => [".", index: 4, input: "2017.06.27"]

鉴于这样的坑,建议还是统一直接用正则,不要用字符串,省得转义。

match 返回结果的格式问题

注意: match 返回结果的格式,与正则对象是否有修饰符 g 有关。还是看个例子:

var string = "2017.06.27";
var regex1 = /\b(\d+)\b/;  //我们知道这段正则能匹配单词边界中间的数字
var regex2 = /\b(\d+)\b/g;  // 加上g标志后,表示全局搜索。即在匹配到一个结果后,不会停止,直到将整个字符匹配完,得到所有结果
console.log( string.match(regex1) );
console.log( string.match(regex2) );
// => ["2017", "2017", index: 0, input: "2017.06.27"]  由于第一个没有g,它在匹配到第一个2017,就没有继续了,但是此时是有括号作为分组的,所以它又接着匹配分组得到的2017,所以会出现两个2017,并且得到的数组还包含index和input

// => ["2017", "06", "27"]  //由于含有标志g,此时正则不会在2017处结束,而是一直匹配到字符串末尾。返回得到的结果也是没有input和index

我建议仍然是在使用match时尽量加上g,尤其是有分组引用时。

exec 比 match 更强大

上面说过在有g的时候,match返回的数组格式会有变化,么有index和input信息。但exec则可以,那它怎么做到了,答案就是分批返回。

var string = "2017.06.27";
var regex2 = /\b(\d+)\b/g;
console.log( regex2.exec(string) );
console.log( regex2.lastIndex);
console.log( regex2.exec(string) );
console.log( regex2.lastIndex);
console.log( regex2.exec(string) );
console.log( regex2.lastIndex);
console.log( regex2.exec(string) );
console.log( regex2.lastIndex);
// => ["2017", "2017", index: 0, input: "2017.06.27"]
// => 4
// => ["06", "06", index: 5, input: "2017.06.27"]
// => 7
// => ["27", "27", index: 8, input: "2017.06.27"]
// => 10
// => null
// => 0

例子可以看出:exec接着上一次匹配后继续匹配,其中lastIndex为上一次匹配的索引。

当你需要清楚掌握每次匹配到的信息时,可以使用强大的exec。

更加强大的replace

replace 有两种使用形式,这是它的第二个参数,既可以是字符串,也可以是函数。

  • 第二个参数为字符串时,可以插入以下特殊变量名
属性 描述
$1,$2,...,$99 匹配第1~99个分组里捕获的文本
$& 匹配到的子串文本
$` 匹配到的子串的左边文本
$' 匹配到的子串的右边文本
? 美元符号

栗子:把"2,3,5",变成"5=2+3":

var result = "2,3,5".replace(/(\d+),(\d+),(\d+)/, "$3=$1+$2");
console.log(result);
// => "5=2+3"
  • 第二个参数为函数时,函数各参数的意义
变量名 代表的值
match 匹配的子串。(对应于上述的$&。)
$1,$2, ... 假如replace()方法的第一个参数是一个RegExp 对象,则代表第n个括号匹配的字符串。例如,如果是用 /(\a+)(\b+)/ 这个来匹配,$1 就是匹配的 \a+$2 就是匹配的 \b+
index 匹配到的子字符串在原字符串中的索引。(比如,如果原字符串是 'abcd',匹配到的子字符串是 'bc',那么这个参数将会是 1)
input 被匹配的原字符串。
"1234 2345 3456".replace(/(\d)\d{2}(\d)/g, function(match, $1, $2, index, input) {
	console.log([match, $1, $2, index, input]);
});
// => ["1234", "1", "4", 0, "1234 2345 3456"]
// => ["2345", "2", "5", 5, "1234 2345 3456"]
// => ["3456", "3", "6", 10, "1234 2345 3456"]

编写正则的注意事项

匹配字符串整体问题

匹配整个字符串,我们经常会在正则前后中加上锚 ^ 和 $。但有时需要注意优先级问题。

例如:我们想匹配abc或者bcd,如果正则写成这样/^abc|bcd$/,由于位置的优先级更高,所以字符串就要求必须以a开头,d结尾。这显然不是我们期望的,所以此时则需要加上括号保护起来,作为一个真的个体。因此需要改为/^(abc|bcd)$/

量词连缀问题

有时我们有多个量词想"连着"使用,例如表示3的倍数,例如:

/^[abc]{3}+$/,我们希望匹配abc当中的任意一个,且次数是3的倍数,注意这里我们将{3}+两个量词连在一起使用,这样会报错,说 + 前面没什么可重复的。因为+前面也是量词而不是字符,此时也需要通过括号来解决。将其改为/^([abc]{3})+$/

元字符转义问题

我们知道元字符是一些字符在正则里表示特殊的含义。但是如果我们先匹配的字符串里包含这些字符,这时候就需要考虑元字符转义的问题。

这种情况下,基本上大部分元字符都需要逐个转义。但如果是些成对出现的元字符,只需要转义第一个,注意: 括号是必须两个都需要转义的。

var string = "[abc]";
var regex = /\[abc]/g;  //只需转义第一个[
console.log( string.match(regex)[0] ); 
// => "[abc]"

var string = "(123)";
var regex =/\(123\)/g;  //括号则需要两个都转义
console.log( string.match(regex)[0] ); 
// => "(123)"

不需要转义的符号:例如 = ! : - ,等符号,它们在正则里没有单独的含义,都是相互组合或者其他元字符搭配使用的。所以它们是不需要转义的。

参考资料

第一个老姚的正则 真的是你目前能找到的关于正则的最好最详细的,我有很多章节都是参考它的。