正则表达式介绍

正则表达式是匹配模式,匹配字符或者匹配位置。

一、字符匹配

1.两种模糊匹配

1.1 横向模糊匹配

一个正则可匹配的字符串的长度不是固定的,可以是多种情况的,实现方式是使用量词。
例如{m,n},表示连续出现最少m次,最多n次。

var regex = /ab{2,5}c/g;
var string = "abc abbc abbbc abbbbc abbbbbc abbbbbbc";
console.log(string.match(regex) );  // ["abbc", "abbbc", "abbbbc", "abbbbbc"]
1.2 纵向模糊匹配

一个正则匹配的字符串,具体到某一位字符时,它可以不是某个确定的字符,可以有多种可能,实现方式是使用字符组。
例如[abc],表示该字符可以是字符“a”、“b”、“c”中的任何一个。

var regex = /a[123]b/g;
var string= "a0b a1b a2b a3b a4b";
console.log(string.match(regex) );  // ["a1b", "a2b", "a3b"]

2. 字符组

虽然叫字符组(字符类),但只是其中一个字符。例如[abc],表示匹配一个字符,它可以是“a”、“b”、“c”之一。

2.1 范围表示法

比如[123456abcdefGHIJKLM],可以用连字符-来简写成[1-6a-fG-M]。
因为连字符有特殊用途,匹配“a”、“-”、“z”这三者中任意一个字符,就可以写成以下的方式:[-az]或[az-]或[a\-z],即放在开头、结尾或者转义。

2.2 排除字符组

例如[^abc],表示是一个除"a"、"b"、"c"之外的任意一个字符。字符组的第一位放^(脱字符),表示求反。

2.3 常见的简写形式

\d:[0-9]。表示是一位数字。
\D:[^0-9]。表示除数字外的任意字符。
\w:[0-9a-zA-Z_]。表示数字、大小写字母和下划线。
\W:[^0-9a-zA-Z_]。非单词字符。
\s:[ \t\v\n\r\f]。表示空白符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页符。
\S:[^ \t\v\n\r\f]。非空白符。
.是[^\n\r\u2028\u2029]。通配符,表示几乎任意字符。换行符、回车符、行分隔符和段分隔符除外。

3. 量词

量词也称重复。掌握{m,n}的准确含义后,只需要记住一些简写形式。

3.1 简写形式

{m,} 表示至少出现m次。
{m} 等价于{m,m},表示出现m次。
? 等价于{0,1},表示出现或者不出现。
+ 等价于{1,},表示出现至少一次。
* 等价于{0,},表示出现任意次,有可能不出现。

3.2 贪婪匹配和惰性匹配

贪婪的,会尽可能多的匹配,只要在能力范围内,越多越好。

var regex = /\d{2,5}/g;
var string = "123 1234 12345 123456";
console.log( string.match(regex) ); // ["123", "1234", "12345", "12345"]

惰性的,就是尽可能少的匹配。
通过在量词后面加个问号就能实现惰性匹配,因此所有惰性匹配情形如下:
{m,n}?
{m,}?
??
+?
*?

var regex = /\d{2,5}?/g;
var string = "123 1234 12345 123456";
console.log( string.match(regex) ); // ["12", "12", "34", "12", "34", "12", "34", "56"]

4. 多选分支

多选分支可以支持多个子模式任选其一。
具体形式如下:(p1|p2|p3),其中p1、p2和p3是子模式,用|(管道符)分隔,表示其中任何之一。

var regex = /good|nice/g;
var string = "good idea, nice try.";
console.log( string.match(regex) ); // ["good", "nice"]

分支结构也是惰性的。

var regex = /good|goodbye/g;
var string = "goodbye";
console.log( string.match(regex) ); // ["good"]

二、位置匹配

位置是相邻字符之间的位置



对于位置的理解,可以理将其解成空字符""。

1.匹配位置

1.1^和$

在多行匹配中^匹配行开头,$ 匹配行结尾。
模拟字符串trim方法,即匹配到字符串开头和结尾的空白符,然后替换成空字符。

function trim(str) {
  return str.replace(/^\s+|\s+$/g, '');
}
console.log( trim("  foobar   ") ); // "foobar"
1.2 \b和\B

\b是单词边界,具体就是\w和\W之间的位置,包括\w和^之间的位置,也包括\w和$之间的位置。

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

\B就是\b的反面,非单词边界,具体说来就是\w与\w、\W与\W、^与\W,\W与$之间的位置。

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"
1.3 (?=p)和(?!p)

二者的学名分别是positive lookahead(正向先行断言)和negative lookahead(负向先行断言)。ES6中,还支持(?<=p)positive lookbehind和(?<!p)negative lookbehind。
比如(?=l),表示'l'字符前面的位置

var result = "hello".replace(/(?=l)/g, '#');
console.log(result); // "he#l#lo" 

var result = "hello".replace(/(?!l)/g, '#');
console.log(result); // "#h#ell#o#"

比如把"123456789",变成"123,456,789"。

var result = "123456789".replace(/(?!^)(?=(\d{3})+$)/g, ',')
console.log(result); // "123,456,789"

三、正则表达式括号的作用

1. 分组和分支结构

1.1 分组

/a+/匹配连续出现的“a”,而要匹配连续出现的“ab”时,需要使用/(ab)+/。

var regex = /(ab)+/g;
var string = "ababa abbb ababab";
console.log( string.match(regex) );  // ["abab", "ab", "ababab"]
1.2 分支结构

在多选分支结构(p1|p2)中,括号提供了子表达式的所有可能。

var regex = /^I love (JavaScript|Regular Expression)$/;
console.log( regex.test("I love JavaScript") ); // true
console.log( regex.test("I love Regular Expression") ); // true

2. 引用分组

2.1 提取数据

比如提取时间字符串中某些字符

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

有修饰符g的时候返回结果不一样

var regex = /(\d{4})-(\d{2})-(\d{2})/g;
var string = "2019-04-12";
console.log( string.match(regex) ); // [ '2019-04-12' ]
2.2 替换
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2019-04-12";
var result = string.replace(regex, "$2/$3/$1"); 
console.log(result);               // "04/12/2019"

3. 反向引用

比如要写一个正则支持匹配如下三种格式:
2017-04-12
2019/04/12
2019.04.12

var string = "2019-04/12";
var string2 = "2019-04-12";
var regex = /\d{4}(-|\/|\.)\d{2}\1\d{2}/;
console.log( regex.test(string) ); // false
console.log( regex.test(string2) ); // true

\1,表示的引用之前的那个分组(-|/|.)。不管它匹配到什么(比如-),\1都匹配那个同样的具体某个字符。同理\2和\3指代第二个和第三个分组。\10是表示第10个分组

4. 非捕获分组

之前文中出现的分组,都会捕获它们匹配到的数据,以便后续引用,因此也称他们是捕获型分组。
如果只想要括号最原始的功能,但不会引用它,即,既不在API里引用, 也不在正则里反向引用。此时可以使用非捕获分组(?:p)

var regex = /(?:ab)+/g;
var string = "ababa abbb ababab";
console.log( string.match(regex) ); // ["abab", "ab", "ababab"]

四、正则表达式回溯法原理

1. 没有回溯的匹配

假设正则是/ab{1,3}c/,其可视化形式是:



而当目标字符串是"abbbc"时,就没有所谓的“回溯”。其匹配过程是:


2. 有回溯的匹配

如果目标字符串是"abbc",中间就有回溯。


图中第5步有红颜色,表示匹配不成功。此时b{1,3}已经匹配到了2个字符“b”,准备尝试第三个时,结果发现接下来的字符是“c”。那么就认为b{1,3}就已经匹配完毕。
然后状态又回到之前的状态(即第6步,与第4步一样),最后再用子表达式c,去匹配字符“c”。当然,此时整个表达式匹配成功了。图中的第6步,就是“回溯”。

3.常见的回溯形式

正则表达式匹配字符串的这种方式,叫回溯法。也称试探法
基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。这种不断“前进”、不断“回溯”寻找解的方法,就称作“回溯法”。本质上就是深度优先搜索算法。其中退到之前的某一步这一过程,我们称为“回溯”。即,尝试匹配失败时,接下来的一步通常就是回溯。
JS中正则表达式会产生回溯的地方有以下几种:

3.1 贪婪量词

贪婪量词相关的。比如b{1,3},因为是贪婪的,尝试可能的顺序是从多往少的方向。
首先会尝试"bbb",然后再看整个正则是否能匹配。不能匹配时,吐出一个"b",即在"bb"的基础上,再继续尝试。如果不行,再吐出一个,再试。如果还不行就只能说明匹配失败了。
当多个贪婪量词挨着存在,并相互有冲突时,因为是深度优先搜索,会先下手为强!

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"

3.2惰性量词

惰性量词就是在贪婪量词后面加个问号,表示尽可能少的匹配。比如:

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

其中\d{1,3}?只匹配到一个字符"1",而后面的\d{1,3}匹配了"234"。
虽然惰性量词不贪,但也会有回溯的现象。比如正则是:



目标字符串是"12345",匹配过程是:



为了整体匹配成,最后\d{1,3}?匹配的字符是"12",是两个数字,而不是一个。
3.3 分支结构

分支也是惰性的,比如/can|candy/,去匹配字符串"candy",得到的结果是"can",因为分支会一个一个尝试,如果前面的满足了,后面就不会再试验了。
分支结构,可能前面的子模式会形成了局部匹配,如果接下来表达式整体不匹配时,仍会继续尝试剩下的分支。这种尝试也可以看成一种回溯。
比如正则:


目标字符串是"candy",匹配过程:


回溯法简单总结就是,正因为有多种可能,所以要一个一个试。直到某一步时整体匹配成功了或者最后都试完了,发现整体匹配不成功。
贪婪量词“试”的策略是:买衣服砍价。价钱太高了,便宜点,不行,再便宜点。
惰性量词“试”的策略是:卖东西加价。给少了,再多给点行不,还有点少啊,再给点。
分支结构“试”的策略是:货比三家。这家不行,换一家吧,还不行,再换。

五、 正则表达式的拆分

1. 结构和操作符

在正则表达式中,操作符都体现在结构(由特殊字符和普通字符所代表的特殊整体)中。

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

涉及到的操作符有:
1.转义符 \
2.括号和方括号 (...)、(?:...)、(?=...)、(?!...)、[...]
3.量词限定符 {m}、{m,n}、{m,}、?、*、+
4.位置和序列 ^ 、$、 \元字符、 一般字符

  1. 管道符(竖杠)|
    这些操作符的优先级从上至下,由高到低。

例子:
正则:/ab?(c|de)+|fg/
由于括号的存在,所以,(c|de
)是一个整体结构。
在(c|de)中,注意其中的量词,因此e是一个整体结构。又因为分支结构“|”优先级最低,因此c是一个整体、而de是另一个整体。
同理,整个正则分成了 a、b?、(...)+、f、g。而由于分支的原因,又可以分成ab?(c|de*)+和fg这两部分。其可视化结构如下:

2. 元字符转义问题

所谓元字符,就是正则中有特殊含义的字符。所有结构里,用到的元字符总结如下:
^ $ . * + ? | \ / ( ) [ ] { } = ! : - ,
当匹配上面的字符本身时,可以一律转义。

例(IPV4地址):

正则表达式是:
/^((0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5]).){3}(0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])$/
简化结构 ((...).){3}(...),两个(...)是一样的结构。表示匹配的是3位数字。因此整个结构是:3位数.3位数.3位数.3位数

(0{0,2}\d|0?\d{2}|1\d{2}|2[0-4]\d|25[0-5])
它是一个多选结构,分成5个部分:
0{0,2}\d,匹配一位数,包括0补齐的。比如9、09、009;
0?\d{2},匹配两位数,包括0补齐的,也包括一位数;
1\d{2},匹配100到199;
2[0-4]\d,匹配200-249;
25[0-5],匹配250-255。
其可视化形式如下:


六、正则表达式的构建

对正则的运用,最重要的就是如何针对问题,构建一个合适的正则表达式。

1.构建正则前提

1.是否能使用正则:比如匹配这样的字符串:1010010001....虽然很有规律,但是只靠正则就是无能为力。
2.是否有必要使用正则,能用字符串API解决的简单问题,就不该使用正则。比如,从日期中提取出年月日,虽然可以使用正则,但也可以使用字符串的split方法来做。
3.是否有必要构建一个复杂的正则。

2.效率

正则表达式的运行分为如下的阶段:
1.编译
2.设定起始位置
3.尝试匹配
4.匹配失败的话,从下一位开始继续第3步
5.最终结果:匹配成功或失败
以代码为例,如下:

var regex = /\d+/g;
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
// => 0 ["123", index: 0, input: "123abc34def"]
// => 3 ["34", index: 6, input: "123abc34def"]
// => 8 null
// => 0 ["123", index: 0, input: "123abc34def"]

1.当生成一个正则时,引擎会对其进行编译。报错与否出现这这个阶段
2.当尝试匹配时,需要确定从哪一位置开始匹配。一般情形都是字符串的开头,即第0位。但当使用test和exec方法,且正则有g时,起始位置是从正则对象的lastIndex属性开始。因此第一次exec是从第0位开始,而第二次是从3开始的。设定好起始位置后,就开始尝试匹配了。
3.比如第一次exec,从0开始,去尝试匹配,并且成功地匹配到3个数字。此时结束时的下标是2,因此下一次的起始位置是3。第二次,起始下标是3,但第3个字符是“a”,并不是数字。但此时并不会直接报匹配失败,而是移动到下一位置,即从第4位开始继续尝试匹配,但该字符是b,也不是数字。再移动到下一位,c仍不是数字,再移动一位是数字3,此时匹配到了两位数字34。此时,下一次匹配的位置是d的位置,即第8位。
4.第三次,是从第8位开始匹配,直到试到最后一位,也没发现匹配的,因此匹配失败,返回null。同时设置lastIndex为0,即,如要再尝试匹配的话,需从头开始。
可以看出,匹配会出现效率问题,主要出现在上面的第3阶段和第4阶段。因此,主要优化手法也是针对这两阶段的。

优化:
1.使用具体型字符组来代替通配符,来消除回溯。
2.使用非捕获型分组。
3.独立出确定字符 。例如/a+/,可以修改成/aa*/。因为后者能比前者多确定了字符a。这样会在第四步中,加快判断是否匹配失败,进而加快移位的速度。
4.提取分支公共部分。比如/this|that/,修改成/th(?:is|at)/。这样做,可以减少匹配过程中可消除的重复。
5.减少分支的数量,缩小它们的范围。

七、 正则表达式编程

1. 正则表达式的四种操作

正则表达式是匹配模式,不管如何使用正则表达式,万变不离其宗,都需要先“匹配”。有了匹配这一基本操作后,才有其他的操作:验证、切分、提取、替换。

1.1 验证 test

验证是正则表达式最直接的应用,比如表单验证。
比如,判断一个字符串中是否有数字.

var regex = /\d/;
var string = "abc123";
console.log( regex.test(string) ); //true
1.2 切分 (split直接切更直接)
1.3 提取 match

比如:以日期为例,提取出年月日

var regex = /^(\d{4})\D(\d{2})\D(\d{2})$/;
var string = "2019-04-12";
console.log( string.match(regex) ); // ["2019-04-12", "2019", "04", "12", index: 0, input: "2017-04-12"]
1.4 替换 replace

比如:把日期格式,从yyyy-mm-dd替换成yyyy/mm/dd:

var string = "2019-04-12";
var day = new Date( string.replace(/-/g, "/") );
console.log( day ); //(中国标准时间)

2.相关API注意要点

用于正则操作的方法,共有6个: String#search、 String#split、String#match、String#replace、RegExp#test、RegExp#exec

1.match返回结果的格式问题

match返回结果的格式,与正则对象是否有修饰符g有关。
没有g,返回的是标准匹配格式,即,数组的第一个元素是整体匹配的内容,接下来是分组捕获的内容,然后是整体匹配的第一个下标,最后是输入的目标字符串。
有g,返回的是所有匹配的内容。
当没有匹配时,不管有无g,都返回null。

2.test整体匹配时需要使用^和$

因为test是看目标字符串中是否有子串匹配正则,即有部分匹配即可。如果,要整体匹配,正则前后需要添加开头和结尾:

console.log( /123/.test("a123b") );
// => true
console.log( /^123$/.test("a123b") );
// => false
console.log( /^123$/.test("123") );
// => true

总结

正则有回溯的过程,匹配效率肯定低一些。但是好在编译快而且也有趣,所以比较流行。
一般情况下,针对某问题能写出一个满足需求的正则,基本上就可以了。至于准确性和效率方面的追求,纯属看个人要求了,我觉得够用就行了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,468评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,620评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,427评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,160评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,197评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,334评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,775评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,444评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,628评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,459评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,508评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,210评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,767评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,850评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,076评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,627评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,196评论 2 341

推荐阅读更多精彩内容