为什么要学
正则表达式是可以说是现代开发的必需品。虽然有的小伙伴在实际开发中并没有怎么用到正则表达式,也顺利完成了任务,实现了功能,但不使用的话,很多JavaScript问题就不能以更优雅的方式来解决了。
举个简单例子:
// 校验这种格式:01234-567
// 正则
/^\d{5}-\d{3}$/
如果不用正则表达式,该如何循环判断大家都懂。看到了吧,这就是正则表达式之美!现在,让我们一起开始优雅之旅吧!
是什么
正则表达式,英文是 Regular Expression,简称 RE。
它是描述文本内容组成规律的表示方式。
我们也可以把它看作一门编程语言:常用的编程语言,如JavaScript、Java、Python等,是对事物的抽象;而正则表达式则是对复杂程序代码的进一步抽象。
知识框架如下图:
流派
说流派之前,我们先来说说正则的发展历史。
简史
正则表达式最早的起源,可以追溯到早期科学家对人类神经系统工作原理的研究上。
20世纪40年代,美国新泽西州的Warren McCulloch和底特律的Walter Pitts这两位神经生理方面的科学家,研究出了一种用数学方式来描述神经网络的方法,创造性地将神经系统中的神经元描述成了小而简单的自动控制元。
1956 年,数学家Stephen Kleene在前人的研究基础上发表了一篇标题为《神经网络事件表示法和有穷自动机》的论文。这篇论文首次提到了“正则集合(Regular Sets)”的概念,随后,大名鼎鼎的Unix之父Ken Thompson于1968年发表了文章《正则表达式搜索算法》,并且将正则引入了自己开发的编辑器qed,以及之后的编辑器ed中,然后又移植到了大名鼎鼎的文本搜索工具grep中。自此,正则表达式被广泛应用到 Unix 系统或类 Unix 系统 (如 macOS、Linux) 的各种工具中。
POSIX流派
在上篇我们在说正则简史的时候最后说了,正则出现了百花争鸣式的发展,有了很多不同声音,从而引发了流派之争。
在1986年,POSIX开始对正则进行标准化尝试,试图一统江湖。大家都知道,Unix世界最流行的应用编程接口遵循的标准正是POSIX,所以最先在Unix系统或类Unix系统上发力,其大部分工具,如grep、sed、awk等,均遵循POSIX标准,PISIX流派开始形成。
两种标准
POSIX规范定义了正则表达式的两种标准:
BRE 全称基本正则表达式(Basic Regular Expression),不支持量词问号和加号,也不支持多选分支结构管道符。
ERE 扩展正则表达式(Extended Regular Expression),在使用花括号,圆括号时要转义才能表示特殊含义。BRE功能不够强大,导致了ERE扩展标准的诞生。
GNU套件
现在使用的Linux发行版,大多都集成了GNU套件。GNU在实现POSIX标准时,做了一定的扩展,主要有以下三点扩展:
- GNU BRE 支持了 +、?,但转义了才表示特殊含义,即需要用+、?表示
- GNU BRE 支持管道符多选分支结构,同样需要转义,即用 |表示
- GNU ERE 也支持使用反引用,和BRE一样,使用 \1、\2…\9表示
BRE标准和ERE标准的主要区别,如下图:
POSIX字符组
POSIX流派还有一个特殊的地方,就是有自己的字符组,叫POSIX字符组,像我们现在用到的元字符\d、\s之类的,写法比较复杂,不够简洁。如下,列举了几个主要的POSIX字符:
PCRE流派
全称Perl兼容正则表达式(Perl Compatible Regular Expressions)。
Perl怎么自成一派了呢?原因是1987年12月,Larry Wall发布了第一版Perl语言,由于其功能强大,它所使用的正则表达式也跟着大放异彩,后来经过不断改进,存在感就越来越强了,1997年,由Philip Hazel开发,兼容Perl语言正则表达式诞生了,也就是PCRE。
我们现在使用的语言和工具大部分常用编程语言都是源于PCRE标准,这个流派显著特征是有\d、\w、\s 这类字符组简记方式,从上面的对比图也能清楚的看到。
兼容性问题
虽然现在基于PCRE流派标准的应用很广泛,但存在更新方面的差异,从而有否直接兼容之分。
直接兼容
主要指的是Perl系编程语言或工具,如Perl、PHP、preg,PCRE库都是直接兼容。
间接兼容
间接兼容的语言,大家就更熟悉了,如Java、Python、JavaScript、.net等语言。
元字符
元字符,其实就是正则表达式中的那些专用字符。
前文提到了正则表达式的功能,主要有三个:
- 校验:电话号码、邮箱的有效性
- 查找:符合要求的文本
- 对文本进行切割或替换
要实现这些功能,都是一个个元字符组合的。
后面都是用JavaScript语言在测试工具RegExr上进行。
特殊单字符
元字符 \d 这是我们最常用的,可以匹配任意数字,如下图,匹配到了首个数字,如果要匹配文本所有数字的话,要用全局匹配修饰符,后面再说。\w、\s用法类似,都是匹配元专用含义的文本字符。
空白符
需要注意的一点是,在windows系统中,我们敲击回车键,既是回车,又是换行。输入\n能匹配到,但输入\r或\r\n都是匹配不到的。为了兼容各系统或各语言的差别,可以用管道符处理。
范围
管道符是或的意思,也就是只要符合各分支项都可以被匹配到;而[]也表示多选一,它们有个最大的区别在于管道符可以匹配到多个字符,而[]是单字符匹配。
量词
量词符*、+、?相当于是{m, n}的特殊形式。都很好理解,多多练习。
修饰符
上篇我们讲述了元字符,在实践的过程中应该会遇到:
图2是在图1的基础上新增了全局的修饰符,所以得到了更多的匹配项。
修饰符,也可以称为匹配模式,它的作用就是改变元字符匹配行为的方式。下面我们就来逐个看看。
全局
全局模式从上图也能看到区别,如果没有全局模式的加持,\d仅匹配到文本的首选项,而在全局模式下能匹配整个文本。在实际开发中,我们可能需要替换文本中所有某个字符,就很有用了。
const text = '投我以木瓜,报之以琼琚。投我以木桃,报之以琼瑶。投我以木李,报之以琼玖。'
// 句号统一替换为分号
const newText = text.replace(/。/g, ';')
console.log(newText) // 投我以木瓜,报之以琼琚;投我以木桃,报之以琼瑶;投我以木李,报之以琼玖;
不区分大小写
不区分大小写这种模式也是最常用之一,在过滤查询拼音、英语文本的时候基本会用到。如下:
单行
在特殊单字符中提到了点号可以匹配除换行符以外的所有字符,那如果我们需要匹配全字符可以用[\d\D]或[\w\W]等,但这样写很不优雅,从而单行匹配模式就诞生了。这么说的话容易造成误解,因为接下来还有个多行匹配模式,而两者没有什么关系,故单行匹配模式也称作点号通配模式。
如上图:换行符也算是匹配到的。
多行
非多行模式匹配:
多行匹配模式:
如上是是否多行匹配模式对比测试。在正则表达式中,^匹配开头,匹配字符串的结尾,多行匹配模式改变的就是^ 的匹配模式。这种模式可以应用于单行都是作为独立信息需要被匹配时,如日志文件,以时间开头,就可以抓取每条日志信息。
Unicode
先来看个例子:
/^\uD83D/.test('\uD83D\uDC2A') // true
/^\uD83D/u.test('\uD83D\uDC2A') // false
这样的结果能看懂吗?
首先要扫个盲,\uD83D\uDC2A
转译成是🐪,代表的是一个emoji,独立的四字节UTF-16编码字符。再来说为什么,也就是Unicode匹配模式的作用:ES6新增了u
修饰符。用来正确处理大于\uFFFF
的 Unicode 字符。刚第一个正则表达式是非Unicode模式,ES5不支持四字节UTF-16编码,会将其识别为2个字符,所以才得到true,懂了吧!哈哈哈...
定点
ES6新增标签y
,通常称为定点(sticky)模式,定点指的是在正则表达式的起点有一个虚拟锚点lastIndex,正则表达式只会从lastIndex指定的位置开始匹配,lastIndex可赋值,默认0,从头开始。sticky的值是Boolean,表示搜索是否具有粘性,仅可读。
const str = 'green tea'
const regex = new RegExp('ee', 'y')
console.log(regex.sticky, regex.lastIndex) // true, 0
regex.lastIndex = 3
console.log(regex.test(str)) // false
贪婪模式
前面我们有说到过元字符中的量词,先来简单回顾下:
其中*、+、?都可以用m,n来表示,算是特殊简写。我们今天主要说的贪婪模式/非贪婪模式,改变的就是量词的匹配规则。看个例子:
文本共匹配了2处,+表示的是只要有1个e字符即可被匹配到,而第2处为什么没有分开被匹配,而是合并匹配一处呢?这里就与是否贪婪模式有关。我们具体来看...
来接着说刚才那个疑问,之所以会合并匹配ee作为一个匹配项,是因为量词在匹配过程中,默认是贪婪模式,贪婪即越多越好,就是在符合量词规则的情况下,会尽量多的匹配作为单独匹配项。
非贪婪模式
和贪婪模式含义相反,非贪婪模式是在量词匹配的规则内,尽量少的匹配。
如上图:有3个匹配项,后面ee作为了2个单独的匹配项。设置非贪婪模式只要在量词符后面加个?即可。
独占模式
在说独占模式前,我们先了解个概念:回溯。不管是贪婪模式或非贪婪模式,都会发生回溯。举个例子:
正则默认是贪婪模式,会优先匹配有3个b的文本,但并没有匹配到,这时候就会发生回溯,再匹配2个b,就可以匹配到了。这就是回溯,我们也能想到,在匹配过程中,回溯越少,则性能就会越高。
独占模式和贪婪模式类似,在量词匹配规则下会尽量多的匹配,且不会发生回溯,没匹配到即返回,所以性能较高。不过遗憾的是,目前JavaScript、Python、Go等的标准库不支持独占模式。
分组
如何既能匹配南京,又能匹配南京市。如果你这样写:
匹配结果并不符合预期。因为正则是左侧优先匹配。你一定就能想到,管道符两侧正则换下位置就可以了。
这样的结果符合预期。我们还可以使用量词来实现。如下图所示:
类似的例子,如果我们要匹配一串数字的个数呢,如下:
但如果也用上面量词的方法,那就得这么写了:
/\d{6}\d{3}?/
仔细分析好像不对,?
加在量词后面正是上篇我们说的非贪婪模式,这里的量词是3,那不管贪婪模式或非贪婪模式,都是3,很明显,只能匹配到最长的那串数字。
该怎么做才能实现可以都匹配上呢?很简单,就是我们今天要说的:分组。
新建一个分组非常容易,就是给表达式加个括号。接着来说上面的问题,我们只要给\d{3}
加上个括号。
每一个括号括起来的可以看做一个子表达式,也叫子组。每个子组都是有编号的,编号的顺序是从左往右数左括号(开括号)。
这样的命名方式是自动定义的,在有的语言中还可以自定义命名(JavaScript不支持):
// ?P<自定义编号>
/\d{6}(?P<自定义编号>\d{3})?/
子组默认是保存的,目的主要是为了后续可能被引用,当然,可以设置为不保存,仅仅用于当前分组,这样会提高正则的性能。
// 不保存子组 ?:
/\d{6}(?:\d{3})?/
引用
正则表达式中编号主要目的有2类:首先是为了分组而分组,不需要保存子组信息;最主要分组还是为了引用。在JavaScript中有点特殊,查找和替换引用的语法略有不同:
如上图:查询我们用\分组编号
来定义;如果是实现替换功能,则要使用$分组编号
let reg = /(\w+)\s(\w+)/;
let str = "John Bull";
let newstr = str.replace(reg, "$2, $1");
console.log(newstr); // Bull,John
断言
单词边界
我们还是先看个例子方便理解:
const str = 'tom wants to go fishing tomorrow.'
const newStr = str.replace(/tom/g, 'jim')
console.log(newStr) // jim wants to go fishing jimorrow.
很明显,并不是我们想要的结果:tomorrow
中的tom
也被替换了,出现了错误。要解决这类问题,单词边界就非常有用了:
const str = 'tom wants to go fishing tomorrow.'
const newStr = str.replace(/\btom\b/g, 'jim')
console.log(newStr) // jim wants to go fishing tomorrow.
\b
中的b是单词边界Boundary首字母缩写,它对tom
位置左右两边作了限制,必须是单独存在。所以上例中仅会匹配到第1个tom
.
行的开始/结束
行的开始/结束就是通过 ^
和 $
来进行位置界定。这个在前文和导言中提到过多次,不再赘言。
就匹配了一个ca
,是因为有了限制:只会匹配以ca
开头的文本。这就相当于对被匹配文本有了位置环境的要求,我们称之为断言。
环视
也可以称为零宽断言。
环视和单词边界也有相同的地方:单词边界是左右位置都是空,油盐不进;而环视则是我们可以通过定义一些规则来匹配,不是一刀切。规则如下:
正则 | 名称 | 含义 |
---|---|---|
(?<=R) | 肯定逆序环视(positive-lookbehind) | 左边是R |
(?<!R) | 否定逆序环视(negative-lookbehind) | 左边不是R |
(?=R) | 肯定顺序环视(positive-lookahead) | 右边是R |
(?!R) | 否定顺序环视(positive-lookahead) | 右边不是R |
R
是我们自定义的规则,<
表示左边,右边没有尖括号;!
表示非。
常用例子
1、取地址栏中参数a的值
const url = 'https://juejin.cn/user/430664291452248?a=666&b=888'
url.match(/(?<=a=)\d+/g) // ['666']
2、根据大写字母拆分成字符串数组
const str = 'HelloWorldHowAreYou'
const ret = str.split(/(?=[A-Z])/) // ['Hello', 'World', 'How', 'Are', 'You']
注意:
环视的括号不会被保存为子组,这个和分组与引用的括号不同。