正则表达式是匹配模式,匹配字符或者匹配位置。
一、字符匹配
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.位置和序列 ^ 、$、 \元字符、 一般字符
- 管道符(竖杠)|
这些操作符的优先级从上至下,由高到低。
例子:
正则:/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
总结
正则有回溯的过程,匹配效率肯定低一些。但是好在编译快而且也有趣,所以比较流行。
一般情况下,针对某问题能写出一个满足需求的正则,基本上就可以了。至于准确性和效率方面的追求,纯属看个人要求了,我觉得够用就行了。