正则分享

正则表达式

本次分享,帮助大家整体过一遍正则表达式的常用基础知识点,能够在以后的工作中,举一反三,不必害怕。

从最简单开始——尽可能严谨的给出匹配规则

现在有一串坚果pro在微信下的useragent信息,通过大量的观察,可以发现,所有的坚果pro都有一个OD标识,且后面紧跟一串数字作为他的具体型号。

1
Mozilla/5.0 (Linux; U; Android 7.1.1; zh-CN; OD105 Build/NMF26F) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.1.3.993 Mobile Safari/537.36

自然的,立刻想到了一个最简单的正则表达式/OD/
问题马上就来了,考虑如下正则

1
Mozilla/5.0 (iPOD; CPU iPhone OS 6_0_1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A523 Safari/8536.25

使用/OD/竟然也测试通过了?但是这一条,是早期ipod toch的ua信息。


为了避免类似的情况,我们的正则表达式至少应该写成这样/\bOD\d+/,其中\b匹配一个单词的开头或者结尾(注:它是一个位置,不是单词和单词之间的空格),\d代表数字,+代表出现的次数为至少出现1次。其中\d\\b+,被称为元字符,其中+表示数量,有时候也被称之为限定符。接下来详细展开这2个类型。

元字符

除了\d\\b,常用的元字符如下所示

元字符 说明
. 匹配除了换行符外任意字符
\s 匹配任意的空白符
\d 匹配数字
\w 匹配字母、数字、下划线、汉字
\b 匹配单词边界
^ 匹配字符串的开始
$ 匹配字符串的结束

仍然以上面提到的useragent为例

1
Mozilla/5.0 (iPOD; CPU iPhone OS 6_0_1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A523 Safari/8536.25

  • .匹配除了换行符的任意字符,如果没做全局匹配,则匹配结果为整个ua信息的首字母M,单独使用没啥意义
  • \s匹配了ua信息中的空格,如果没做全局匹配,则匹配结果为Mozilla/5.0后面的空格
  • \d匹配数字,上面有提过,如果没做全局匹配,匹配结果为5
  • \w匹配字母、数字、下划线、汉字,如果没做全局匹配,匹配结果为M
  • \b匹配边界,一般是配合别的一起使用,比如\bA,如果没做全局匹配,匹配结果为AppleWebKit的首字母A
  • ^匹配字符串的开始,一般是配合别的一起用,比如^\w,匹配结果为整个ua信息的首字母M
  • $匹配字符串的结束,一般是配合别的一起用,比如\w$,匹配结果为整个ua薪资的最后一个数字5

注: $^都是为了写出更精准的匹配条件。

道理我都懂,看了不会用。



举个栗子,判断一个串,是不是手机号

  • 粗糙一点,\d{11},一个数字重复11次就行了。但是形如12345678987654321或者abc12345678987645sdf也会通过,怎么办?
  • 加个限制^\d{11}$,一个数字重复11次,且必须是数字作为串的开头,数字作为串的结尾。但是01234567898也会通过,有0开头的手机号么?没有。
  • 加个限制^1\d{10}$,是不是好多了?到目前为止,我们先写到这里

限定符

限定符 说明
* 重复0次或者更多次
+ 重复1次或者更多次
? 重复0次或者1次
{n} 重复n次
{n,} 重复n次或者更多次
{n,m} 重复n到m次

限定符的使用比较简单,仍然以上面提到的useragent为例

1
Mozilla/5.0 (iPOD; CPU iPhone OS 6_0_1 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A523 Safari/8536.25

比如我们要提取ua中最后的Safari/8536.25,我们假定Safari/后面的数字是版本号,且小数点不是必须的。

  • 最粗糙的,可以写Safari\/\d+但是小数点会被忽略
  • 改造下,可以写Safari\/\d+\.\d+但是没小数点的就挂了
  • 再改造下,可以写Safari\/\d+\.?\d*
  • 同样的,如果确定是完整的一个串,且不与正文混合,Safari\/\d+\.?\d*,但是问题又来了,Safari/12.也能通过?
  • 原来,.和后面的数字,必须同时出现,所以Safari\/\d+(\.\d+)? 这样是不是好多了?

转义

你应该发现了,跟元字符一样的匹配,需要使用\反斜杠来转义。比如我们刚刚Safari/8536.25中的/在匹配时,需要写为\/,中间的小数点,在匹配时,也要写为\.。其他元字符同理。

类和条件

如果单独想匹配出数字、字母、空白等,有元字符已经可以直接帮助获取,有木有自己建立的集合呢?
考虑我们刚刚所写的手机号匹配^1\d{10}$,这里限制了第一位数,必须是1,那么,第二位数字是不是也可以限制下,保证匹配更加准确?目前已知的第二位有35678,我们可以使用[34678]来表示匹配其中的一个。
更改后的正则为^1[34678]\d{9}$,这样就准确多了。
对于连贯性的数字或者字母,[]内甚至可以直接写[0-9],等价于\d,同样的,字母的匹配可以写[a-z][A-Z],甚至可以组合使用[0-9a-zA-Z],如果再加上一个下划线,[0-9a-zA-Z_],等价于\w


但是,pm不要脸,换需求了


pm要求,匹配15开头的号段,结尾必须是0,匹配13开头的号段,结尾必须是1。
本能反应,我们噼里啪啦甩出去^1[35]\d{8}[01]$,但不幸的是同样的,15开头的号段匹配了1结尾,13号段也匹配了0结尾。
最粗糙的写法,^13\d{8}[0]$|^15\d{8}[1]$,看着像不像2个正则表达式给拼起来了?中间用了个 |分割了下?你会优化么?
这里|即为正则表达式的条件。同样的场景,由于javascript内,对正则的支持有限,无法满足对括号的匹配,所以对于简单的括号情况,需要用条件语法去处理。但是,对于复杂的括号需求,还是建议使用递归,进行处理。

逆匹配

上面,提到了常用的元字符,以及自定义的类,还有一种常用的需求,求一个类的倪匹配,比如,需要匹配除了数字以外,其他所有的情况。这时候需要用逆匹配来进行处理,规则非常简单:

元字符 说明
\S 匹配任意的非空白符
\D 匹配非数字
\W 匹配非字母、非数字、非下划线、非汉字
\B 匹配非单词边界
[^abc] 匹配非abc的其他所有

特殊的地方只有1处,就是对自定义类的逆匹配,需要一个[^]进行区分,常用的需求为,匹配一个xml标签,通常这么写<[^\s>]+>

分组与引用

对于分组,上面应该已经注意到,我们已经很顺其自然的写出了一个形如Safari\/\d+(\.\d+)?的正则,这里面的()即分组。分组非常有用,比如对单个字符的数量控制,可以使用限定符控制,对于一组字符串的处理,加个()就可以了。比如上面刚刚所写的(\.\d+)?就是小数点和数字在一个括号内,后面的限定符?,表示必须同时出现。


对于分组,更多的应用场景,其实是引用。考虑一个需求,有一串电话号码<010>85600596,我们需要匹配他的区号,他的区号,都会以尖括号包括

  • 粗糙的,我们写出了<\d{3,4}>\d{7,8},因为区号有3-4位数,电话号码有7-8位数。但是这个正则在使用的时候,只能'<010>85600596'.match(/<\d{3,4}>\d{7,8}/),怎么能在确认了是电话号码的情况下,拿到区号?
  • 增加分组<(\d{3,4})>\d{7,8},这样,let res = '<010>85600596'.match(<(\d{3,4})>\d{7,8})就能在结果内,res[1]拿到我们的区号010

对于引用,还有一种使用方式,叫向后引用。考虑一个字符串,'abc',它的引号,有可能是'也有可能是",做匹配怎么处理?

  • 仍然最粗糙的开始,写出了['"][a-z]+['"],不幸的是,'abc"这样的字符串竟然也能通过。
  • 这里考虑使用向后引用,(['"])[a-z]+\1,考虑这里的\1,意思是第一个分组。如果有多个括号,这个反斜杠的数字持续累加,对应的是从左到右的括号顺序,就是对应的分组。

为了加深印象,我们看下一个偏实际的需求:

1
2
3
div {
background-image: url('https://a.com/b.png');
}

我们需要对background-image后面的url内容进行收集。但是,url的内容,可以是单引号,也可以是双引号。根据我们上面提到的知识点,很容易想到

  • 首先可以得出url\((['"])([^\s'"]+)\1\)
  • 观察可以发现,引号可以单双,但是也可以完全不要引号,还记得上面提过的限定符么?url\((['"])?([^\s'"]+)\1\)

不分组捕获和零宽断言

为啥给这2个放到一起,因为他们的表达式很像,方便记忆。。
首先看下不分组捕获,考虑上面提到的css内容

1
2
3
div {
background-image: url('https://a.com/b.png');
}

现在不但要考虑url的内容,还需要考虑background-image

  • 粗糙的,background-image\s*:\s*url\((['"])?([^\s'"]+)\1\),因为冒号前后的空格个数,是随意的。
  • 但是不难发现,在编写css时,会有连写属性,比如background: url('https://a.com/b.png')-image并不是必须,所以正则改写为background(-image)?\s*:\s*url\((['"])?([^\s'"]+)\2\)
  • 问题来了,刚刚我们\1的地方,现在改为了\2,但是\1并没有什么卵用,等于浪费了一个标号数字,怎么解决?
  • 不分组捕获的作用就来了,写法非常简单,(?:)即可。
  • 修改后的正则background(?:-image)?\s*:\s*url\((['"])?([^\s'"]+)\1\),这样,虽然前面有一个括号,但是并没有给他赋予\1的编号。

零宽断言的例子,其实,在开始的时候已经提过了。零宽,顾名思义,匹配的是个位置,而不是实际的内容,也就是说^$\b都算是零宽断言。
考虑更复杂的需求,还是开头的那一行useragent

1
Mozilla/5.0 (Linux; U; Android 7.1.1; zh-CN; OD105 Build/NMF26F) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.1.3.993 Mobile Safari/537.36

我们分析发现,在OD105这个手机型号之前,总是有一个系统语言的标识,我们想得到他。

  • 通过上面掌握的内容,可以轻松得到([\w-]+);\s*\bOD\d+\b,这样分组\1就是想要的内容。
  • 有木有更优雅的方法?[\w-]+(?=;\s*\bOD\d+\b),注意这里的(?=),代表的意思为零宽正预测先行断言,名字很屌,也相当拗口,只需记住,匹配以XXX为结尾的串,但是不捕获它,就ok了。

同样的,有正预测,就有负预测,负预测的意思就是,匹配不以XXX为结尾的串,但是不捕获它。应用场景也很常见,这里举一个简单的例子,已知一个生日,匹配不是9月1日生的人,是哪一年生的。

  • \d{4}(?!0901)(?!)的意思为零宽负预测先行断言

有正也有负,有先行,必然也有后发,然而,后发断言javascript不支持,感兴趣的同学可以自己搜索下学习。总结下:

名称 语法 解释
不分组捕获 (?:exp) 匹配exp,但是不捕获,也不给分组,也没有组号
零宽正预测先行断言 (?=exp) 匹配exp前面的内容,单独使用没意义
零宽负预测先行断言 (?!exp) 匹配非exp前面的内容,单独使用没意义

贪婪与懒惰

正常的限定符,都是贪婪匹配,有时候,我们会使用懒惰匹配,只需在限定符后面加?即可。
举个栗子作为结束:a.*b,标识匹配a开头,b结尾的串

  • 对于输入abbbbba.*b将为贪婪匹配到整个字符串,也就是说匹配结果是abbbbb
  • 如果将限定符后面加上?得到a.*?b,则同样的输入,输出只会是ab

其他没聊到的

以上基本上为web开发中大部分的应用场景,至于平衡组、递归匹配等高级内容,目前已知的只有.Net环境下支持,感兴趣的话可以自己搜索。

至于常用的正则学习方法,有2个网站供大家参考
https://regexr.com/ 主要用来编写正则
http://140.143.237.171/reg 用来可视化正则,方便找错
玩的开心!