硬刚正则表达式的心得总结

近几日对自己一直不太擅长的正则表达式做了一次全面的扫盲。心疼自己之余还是有一些收获吧,在这里做一个比较零散的总结,整理一些对理解正则比较有利的点。

一、"?"

你没有看错,就是黑人问号中的问号,这个字符在正则里面算是一个入门中很容易被带偏的点了。首先要知道什么是正则中的量词。

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", "1949", "10", index: 6, input: "新中国成立于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", "1949", "10", "01", index: 6, input: "新中国成立于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不支持,所以就暂时不讨论了。

相关推荐