近几日对自己一直不太擅长的正则表达式做了一次全面的扫盲。心疼自己之余还是有一些收获吧,在这里做一个比较零散的总结,整理一些对理解正则比较有利的点。
一、"?"
你没有看错,就是黑人问号中的问号,这个字符在正则里面算是一个入门中很容易被带偏的点了。首先要知道什么是正则中的量词。
1.量词
在正则中,通常要表示一个表达式匹配的数量,这个时候量词就登场了。
主要会使用以下几个量词
/(\w)*/.exec(str) // 匹配任意次 /(\w)+/.exec(str) // 匹配一次到多次 /(\w)?/.exec(str) // 匹配零到一次(记住这里问号的用法!) /(\w){2,4}/.exec(str) // 匹配两次到四次 /(\w){2,}/.exec(str) // 匹配两次以上
我们可以发现,在这里"?"作为一个量词来使用,表示匹配零到一次。
接下来要理解下一个概念:贪婪匹配
2.贪婪匹配
搜了一下wiki,貌似没有相关的词条,通俗的解释,贪婪匹配模式下,会尽可能多地匹配满足条件的字符。而正则默认是贪婪模式的。
举个例子。比如我要匹配"suuuuuuuuuuck"字符中的s和k中间的字符。并没有什么问题。
let result = "suuuuuuuuuuck".match(/s(.*)k/)[1] // uuuuuuuuuuc
但是我现在要搞事情,要你在"suuuuuuuuuuck duck"字符串中匹配相同的字段,同样的表达式会匹配到以下的结果,因为此时的正则是贪婪的,它一定会匹配到无法匹配的时候才休止。
// uuuuuuuuuuck duc
这时候就需要手动开启非贪婪模式了
let result = "suuuuuuuuuuck duck".match(/s(.*?)k/)[1] // uuuuuuuuuuc
区别是在量词后加了个问号,这时候该捕获组就算是开启了非贪婪模式了。
按照上面的理解,如果你是一个新手,肯定会有所疑惑,量词(*)后面跟着一个量词(?),这是什么鬼意思,这么反人类的?
其实,这里就涉及到"?"的第二个用法了,即当它跟在一个量词背后的时候,表示该表达式开启了非贪婪模式,即对表达式尽可能少地匹配结果。所以,对应的,配合量词使用,会产生以下结果。
- "*?": 可以匹配任意多次,但是尽量少匹配。
- "+?": 至少必须匹配一次,但是尽量少匹配。
- "{m,n}?": 至少必须匹配m次,最多只能匹配n次,但是尽量少匹配。
- "{m,}?": 至少必须匹配m次,但是尽量少匹配。
思考题:所以,"??" 应该如何匹配呢?
二、捕获组
正则匹配除了验证一个字符串是否符合条件外,还可以从中提取我们所需要的信息。比如,我们得到了一个"新中国成立于1949-10-01"的字符串,作为一个爱国人士,我们要把这个年月日提取出来谨记于心。所以我写了一个正则,获得的结果如下
这里提取的操作就需要通过小括号进行捕获。正则会默认对捕获组分配组数。
"新中国成立于1949-10-01".match(/(\d{4})-(\d{2})-(\d{2})/) // ["1949-10-01","1949","10","01",index: 6,input: "新中国成立于1949-10-01",groups: undefined]
我们也可以忽略某些分组"(?:exp)",这样正则就不会为为其分配组数。
"新中国成立于1949-10-01".match(/(\d{4})-(\d{2})-(?:\d{2})/) // ["1949-10-01",groups: undefined]
假如我们有一个叠词判断的需求,验证一个词语是不是"ABA"格式的,我们可以这么做
// 首先汉字的unicode范围是\u4e00-\u9fa5 // 这里我们首先对第一个字符进行了捕获,组数为1 // 然后我们后面通过"\1"的方式去复用捕获组,这样就意味着匹配到了相同的字符,也就达到了限制的目的。 /([\u4e00-\u9fa5])[\u4e00-\u9fa5]\1/.test("是不是") // 当然是true
要记住下标对人类来说还是挺麻烦的,可以说完全没啥可读性,当然正则也提供了为分组命名的方式
"新中国成立于1949-10-01".match(/(?<year>\d{4})-(?<month>\d{2})-(?<date>\d{2})/) // 我们可以发现,这时候捕获组不仅拥有组数,同时groups属性不为空了。 // ["1949-10-01",groups: {…}] // 展开groups 是这样的 // {year: "1949",month: "10",date: "01"} /** 当然命名捕获组也是可以使用的 */ // (?<name>exp) 命名捕获组 // \k<name> 引用 // 还是叠词的那个例子 /(?<thx>[\u4e00-\u9fa5])[\u4e00-\u9fa5]\k<thx>/.test("是不是")
现在有一个需求,匹配出英文语句"I'm singing while you're dancing"中所有带有ing后缀的单词(不包含ing)。要想拿到danc 和 sing,我们需要用到零宽断言。
三、零宽断言
零宽断言用于查找某些内容之前或之后的东西,只指定一个位置,本身并不占据字符,这也是为什么我们称之为零宽度
对于表达式表示肯定,我们称之为正向,反之称之为负向,(注意,这里的正负指的是对条件的肯定和否定,而不是匹配的方向。)
而对于匹配的方向而言,我们有另外一种称呼,对向后匹配的称之为预测先行,向前匹配的称之为回顾后发
所以,对应的四种组合分别是
- (?=exp) 零宽度正预测先行断言(断言自身出现的位置后面能匹配exp)
- (?!exp) 零宽度负预测先行断言(断言自身出现的位置后面不能匹配exp)
- (?<=exp) 零宽度正回顾后发断言(断言自身出现的位置前面能匹配exp)
- (?<!exp) 零宽度负回顾后发断言(断言自身出现的位置前面不能匹配exp)
目前的js引擎对回顾后发断言的实现还不完全,就我所知在chrome能成功使用,但是在nodejs环境下是不识别的。
现在我们从引言中的例子来实践一下
"I am singing while you're dancing".match(/\b([a-zA-Z]+)(?=ing\b)/g) // 我们忽略前面不满足的匹配,直到index = 4时,s为单词边界,满足条件 // 而第一个捕获组是贪婪的,他会首先匹配到整个singing,然后将掌控权交给(?=ing\b),singing不满足匹配 "singinging" // 于是开始回溯到单词 singin,继续断言, 匹配到的下一个字符为"g",不满足"singining",又开始回溯到"singi"... // 直到回溯到"sing"时,断言后面有一个ing,并且是一个单词边界,于是"singing"满足条件,这时候我们的正则匹配到了第一个结果。 // 由于零宽断言是不消费字符的,所以我们得到整个表达式匹配的第一个结果是"sing" // 于是引擎以同样的方式向后面的位置查找,得到了danc // ["sing","danc"]
我们现在看一下怎么使用负向断言,假如我们有一个系统,3月25号要进行维护,不能使用了,这时候有用户要办理业务,选择日期的时候我们要过滤3月25日这一天,所以产品经理要你临时加上一条规则限定。
选择后日期输出的格式是"yyyy-mm-dd",这时候我们可以这么写正则
/(?!2018-03-25)(\d{4})-(\d{2})-(\d{2})/.test("2018-03-11") // true 通过验证 /(?!2018-03-25)(\d{4})-(\d{2})-(\d{2})/.test("2018-03-25") // false
用(?<=exp) 找出 "beep name=wanglihong abcdefg"
"beep name=wanglihong abcdefg".match(/(?<=\bname=)(\w+\b)/) // ["wanglihong","wanglihong",index: 10,input: "beep name=wanglihong abcdefg",groups: undefined]
提取a标签的属性的同时,通过(?<!exp) 过滤style属性
var template = '<a href="/bee" target="_blank" id="o" style="color: black;">点击跳转</a>' template.match(/(\w+)=(?<!style=)"([^"]+)"/g) // [href="/bee",target="_blank",id="o"]
摸透了零宽断言,正则的能力也就算上了一个台阶了,当然还有平衡组这种操作,因为在js不支持,所以就暂时不讨论了。