正则表达式详解

1,296 阅读2分钟

为什么要学

正则表达式是可以说是现代开发的必需品。虽然有的小伙伴在实际开发中并没有怎么用到正则表达式,也顺利完成了任务,实现了功能,但不使用的话,很多JavaScript问题就不能以更优雅的方式来解决了。

举个简单例子:

// 校验这种格式:01234-567

// 正则
/^\d{5}-\d{3}$/

如果不用正则表达式,该如何循环判断大家都懂。看到了吧,这就是正则表达式之美!现在,让我们一起开始优雅之旅吧!

是什么

正则表达式,英文是 Regular Expression,简称 RE

它是描述文本内容组成规律的表示方式。

我们也可以把它看作一门编程语言:常用的编程语言,如JavaScript、Java、Python等,是对事物的抽象;而正则表达式则是对复杂程序代码的进一步抽象。

知识框架如下图:

正则表达式.png

流派

说流派之前,我们先来说说正则的发展历史。

简史

正则表达式最早的起源,可以追溯到早期科学家对人类神经系统工作原理的研究上。

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标准的主要区别,如下图:

image.png

POSIX字符组

POSIX流派还有一个特殊的地方,就是有自己的字符组,叫POSIX字符组,像我们现在用到的元字符\d、\s之类的,写法比较复杂,不够简洁。如下,列举了几个主要的POSIX字符:

image.png

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等语言。

元字符

元字符,其实就是正则表达式中的那些专用字符。

元字符.png

前文提到了正则表达式的功能,主要有三个:

  • 校验:电话号码、邮箱的有效性
  • 查找:符合要求的文本
  • 对文本进行切割或替换

要实现这些功能,都是一个个元字符组合的。

后面都是用JavaScript语言在测试工具RegExr上进行。

特殊单字符

元字符 \d 这是我们最常用的,可以匹配任意数字,如下图,匹配到了首个数字,如果要匹配文本所有数字的话,要用全局匹配修饰符,后面再说。\w、\s用法类似,都是匹配元专用含义的文本字符。

image.png

空白符

需要注意的一点是,在windows系统中,我们敲击回车键,既是回车,又是换行。输入\n能匹配到,但输入\r或\r\n都是匹配不到的。为了兼容各系统或各语言的差别,可以用管道符处理。

image.png

image.png

范围

管道符是或的意思,也就是只要符合各分支项都可以被匹配到;而[]也表示多选一,它们有个最大的区别在于管道符可以匹配到多个字符,而[]是单字符匹配。

image.png

image.png

量词

量词符*、+、?相当于是{m, n}的特殊形式。都很好理解,多多练习。

修饰符

上篇我们讲述了元字符,在实践的过程中应该会遇到:

image.png

image.png

图2是在图1的基础上新增了全局的修饰符,所以得到了更多的匹配项。
修饰符,也可以称为匹配模式,它的作用就是改变元字符匹配行为的方式。下面我们就来逐个看看。

全局

全局模式从上图也能看到区别,如果没有全局模式的加持,\d仅匹配到文本的首选项,而在全局模式下能匹配整个文本。在实际开发中,我们可能需要替换文本中所有某个字符,就很有用了。

const text = '投我以木瓜,报之以琼琚。投我以木桃,报之以琼瑶。投我以木李,报之以琼玖。'
// 句号统一替换为分号
const newText = text.replace(/。/g, ';')
console.log(newText) // 投我以木瓜,报之以琼琚;投我以木桃,报之以琼瑶;投我以木李,报之以琼玖;

不区分大小写

不区分大小写这种模式也是最常用之一,在过滤查询拼音、英语文本的时候基本会用到。如下:

image.png

单行

在特殊单字符中提到了点号可以匹配除换行符以外的所有字符,那如果我们需要匹配全字符可以用[\d\D]或[\w\W]等,但这样写很不优雅,从而单行匹配模式就诞生了。这么说的话容易造成误解,因为接下来还有个多行匹配模式,而两者没有什么关系,故单行匹配模式也称作点号通配模式。

image.png

如上图:换行符也算是匹配到的。

多行

非多行模式匹配:

image.png

多行匹配模式: image.png

如上是是否多行匹配模式对比测试。在正则表达式中,^匹配开头,匹配字符串的结尾,多行匹配模式改变的就是^ 的匹配模式。这种模式可以应用于单行都是作为独立信息需要被匹配时,如日志文件,以时间开头,就可以抓取每条日志信息。

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

贪婪模式

前面我们有说到过元字符中的量词,先来简单回顾下:

image.png

其中*、+、?都可以用m,n来表示,算是特殊简写。我们今天主要说的贪婪模式/非贪婪模式,改变的就是量词的匹配规则。看个例子:

image.png 文本共匹配了2处,+表示的是只要有1个e字符即可被匹配到,而第2处为什么没有分开被匹配,而是合并匹配一处呢?这里就与是否贪婪模式有关。我们具体来看...

来接着说刚才那个疑问,之所以会合并匹配ee作为一个匹配项,是因为量词在匹配过程中,默认是贪婪模式,贪婪即越多越好,就是在符合量词规则的情况下,会尽量多的匹配作为单独匹配项。

非贪婪模式

和贪婪模式含义相反,非贪婪模式是在量词匹配的规则内,尽量少的匹配。

image.png

如上图:有3个匹配项,后面ee作为了2个单独的匹配项。设置非贪婪模式只要在量词符后面加个?即可。

独占模式

在说独占模式前,我们先了解个概念:回溯。不管是贪婪模式或非贪婪模式,都会发生回溯。举个例子:

image.png

正则默认是贪婪模式,会优先匹配有3个b的文本,但并没有匹配到,这时候就会发生回溯,再匹配2个b,就可以匹配到了。这就是回溯,我们也能想到,在匹配过程中,回溯越少,则性能就会越高。

独占模式和贪婪模式类似,在量词匹配规则下会尽量多的匹配,且不会发生回溯,没匹配到即返回,所以性能较高。不过遗憾的是,目前JavaScript、Python、Go等的标准库不支持独占模式。

分组

如何既能匹配南京,又能匹配南京市。如果你这样写:

image.png

匹配结果并不符合预期。因为正则是左侧优先匹配。你一定就能想到,管道符两侧正则换下位置就可以了。

image.png

这样的结果符合预期。我们还可以使用量词来实现。如下图所示:

image.png

类似的例子,如果我们要匹配一串数字的个数呢,如下:

image.png

但如果也用上面量词的方法,那就得这么写了:

/\d{6}\d{3}?/

仔细分析好像不对,?加在量词后面正是上篇我们说的非贪婪模式,这里的量词是3,那不管贪婪模式或非贪婪模式,都是3,很明显,只能匹配到最长的那串数字。

image.png

该怎么做才能实现可以都匹配上呢?很简单,就是我们今天要说的:分组。

新建一个分组非常容易,就是给表达式加个括号。接着来说上面的问题,我们只要给\d{3}加上个括号。

image.png

每一个括号括起来的可以看做一个子表达式,也叫子组。每个子组都是有编号的,编号的顺序是从左往右数左括号(开括号)。

image.png

这样的命名方式是自动定义的,在有的语言中还可以自定义命名(JavaScript不支持):

// ?P<自定义编号>
/\d{6}(?P<自定义编号>\d{3})?/

子组默认是保存的,目的主要是为了后续可能被引用,当然,可以设置为不保存,仅仅用于当前分组,这样会提高正则的性能。

// 不保存子组 ?:
/\d{6}(?:\d{3})?/

引用

正则表达式中编号主要目的有2类:首先是为了分组而分组,不需要保存子组信息;最主要分组还是为了引用。在JavaScript中有点特殊,查找和替换引用的语法略有不同:

image.png

如上图:查询我们用\分组编号来定义;如果是实现替换功能,则要使用$分组编号

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.

行的开始/结束

行的开始/结束就是通过 ^$ 来进行位置界定。这个在前文和导言中提到过多次,不再赘言。

image.png

就匹配了一个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']

注意:

环视的括号不会被保存为子组,这个和分组与引用的括号不同。

工具

正则测试