正则表达式
撰写时间:2023-10-15
修订时间:2024-02-26
概述
正则表达式(regular expression)是对文本输入源进行高效地搜索、替换的强大工具。
在该领域,正则表达式仅此一家,别无他店。
相关对象
正则表达式主要关心以下几个部分的内容:
- 文本输入源
- 搜索内容
- 搜索方式
- 搜索结果
多对象协同合作
变量src是要搜索的文本输入源,pattern是要从文本输入源中搜索的特定内容,re是RegExp的一个实例。在这里,将pattern作为RegExp构造器的参数以创建其一个实例。
之后,以src作为参数调用re的test方法,这样便可从src中搜索pattern的内容。
如果在src中能搜索到pattern的内容,我们称为结果匹配(matched),则test方法返回true;否则,返回false。上例该方法返回true,说明结果已经匹配。
上面各对象的协作图如下:
正如上面代码所示,应用正则表达式,我们需要与4种对象打交道。第1种对象是我们要搜索的字符串src,由客户端指定。一旦指定,内容就固定下来了。而另外的3种对象如下图所示,则是我们学习正则表达式的重点。
RegExp字面符
除了以new RegExp()
的方式创建RegExp的实例外,还可以字面符的方式来创建。
这种方式,以字面符/pattern/
作为RegExp的实例创建方式,将pattern直接写进字面符中。
注意:RegExp字面符中的pattern不管有否字符串,都不能加双引号。
以字面符来创建实例的方式,可规避一些字符转义的问题,在下面我们将看到这一点。而以new RegExp()
的方式来创建实例的方式,可以使代码更加灵活。两种方法各有特点,可灵活选用。
需要更多的搜索结果信息
上节中RegExp的test方法只返回一个布尔值,表示是否匹配。我们如果想获得更多信息,如,到底匹配到了什么文本、在文本输入源的哪个位置匹配到
等信息,我们可以调用exec方法。
其协作图如下:
该方法返回一个数组。我们将其打印出来看看:
终端输出:
属性0存储了源字符中匹配pattern的字符串。groups是分组,下面再谈到。index是本次搜索中,所匹配到的结果位于源字符串中的索引值。input即是src的内容。length存储匹配结果的数量。
需注意的是,exec方法所返回是确实是一个数组,但该数组同时也通过其他的属性存储了其他必要的信息。这里体现了JavaScript语言灵活强大的地方。Array是个数组,同时数组也是Object的实例,因此可拥有其他属性。
因此,result[0]
是本次匹配结果,result.index
是匹配结果在src中的位置。result.length
表示只有1个匹配结果。
本节所涉及正则表达式的核心对象:
exec方法匹配细节
从上节我们得知,当有匹配结果时,result会存储相关的信息。本节中我们来看其内部细节。
匹配时result的内部状态
当上面的代码匹配到see
时,正则表达式引擎的内部流程如下图:
src | G | o | o | d | t | o | s | e | e | y | o | u | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
result | 0 | see |
length | 1 | |
index | 8 | |
input | Good to see you. | |
groups | undefined |
第1步,先创建一个数组对象。上图将其标识为result
。
第2步,将匹配结果see
作为一个数组元素添加进数组对象中。数组的长度由此变为1
。
第3步,将数组对象的index属性值改写为匹配结果开始的索引值,将数组对象的input属性值改写文本输入源src的字符串值。
第4步,将数组对象返回至客户端。
上面,result的0及length这两个属性使用绿底标出,以明示这些是与数组性质有关的属性。而其他的属性则是与数组作为对象有关的属性,与数组属性无关。
属性groups存储与命名捕获组(named capturing group)有关的信息,这里暂未涉及到。
匹配结果的使用
上面result存储了匹配结果的索引位置、所匹配到的字符串、输入源等众多信息,我们就可以灵活使用。例如,在源字符串中加亮显示。所需的信息足够了。
或者,替换匹配结果。
String类的replace方法的第1个参数支持正则表达式,由此我们甚至都不需要显式地调用re的exec方法。
当然,上面这些都显得有点画蛇添足:
这当然是对的,因为在这里pattern正好等于硬编码的字符串值。但正则表达式的强大之处在于当我们构建了足够灵活的pattern时,匹配结果将非常丰富。别急,下面的章节会学到如何构建强大的pattern。而不管pattern如何变化,result的结构与构成原理是不变的。因此,本节中我们只需详细地了解result的细节,为以后更有效地使用正则表达式打下扎实的基础。
使用捕获组获取更多信息
我们可以让正则表达式引擎在result中向我们提供更多的信息。方法是在pattern中设置相应的捕获组(capturing groups)。
所谓捕获组,是正则表达式与我们所达成的契约条款:在原有的匹配结果上,您还需要什么更多的信息,放在括号内,我帮您都捕获下来。
在上面的pattern中,其代码(s)(ee)
表示,共有2个捕获组,第1个捕获组捕获s
字符,第2个捕获组捕获ee
2个字符。
引擎内部状态如下:
src | G | o | o | d | t | o | s | e | e | y | o | u | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
result | 0 | see |
1 | s | |
2 | ee | |
length | 3 | |
index | 8 | |
input | Good to see you. | |
groups | undefined |
result对象中的与数组有关的属性起了变化。
第0个元素仍是原来的匹配结果see
。不管有无捕获组,第0个元素永远都与捕获组无关,永远都是原来未设定捕获组时的匹配结果。
接着,捕获到了(s)
,将此捕获结果作为数组一项新的元素添加进数组中。
最后,捕获到了(ee)
,将此捕获结果作为数组一项新的元素添加进数组中。
因此数组共有3个元素,length属性值得以相应地更新。
而其他的属性,如index, input, groups,由于与捕获组无关,因此没有变化。
捕获组可以嵌套,如((London) (England))
, 则第1个捕获结果为London England
,第2个捕获结果为London
,第3个捕获结果为England
。
当捕获组多了以后,如何确定捕获内容到底属于哪个捕获组?这里有一个快速确定捕获组内容的方法:从左到右依序出现的左括号(
所包围的内容,将依序添加进数组中。嵌套的捕获组其规律也是如此。
注意,设置了捕获组后,只是改变了result数组的元素个数,但并未产生新的result。每调用一次exec方法,不管有无捕获组,也不管捕获组的数量是多少,都只返回一个result。
匹配多次出现的字符
这一节,我们探索如何得到多次匹配的结果。下面是搜索源。
我们准备在上面的字符串中查找is
这2个字符。通过观察,应可找到2个is
。下面的代码开始搜索:
上面的代码,exec将返回一个result。各属性值为:
属性名称 | 属性值 |
---|---|
0 | is |
length | 1 |
index | 2 |
input | This one is OK. |
groups | undefined |
上节说过,每调用一次exec方法,都只返回一个result。而这个result只匹配了第1个is,我们如何让正则表达式继续匹配第2个is?再次显式地调用exec方法?但又该如何指定从哪里继续搜索的位置呢?
RegExp的内部状态
RegExp是一个状态机。在搜索过程中,其内部有相应的属性来记录各个阶段的状态。使用下面的代码来查看其状态:
终端将列出re的各个属性的名称及其数值:
其中,source属性指的就是pattern的值。有许多属性的值都是false
。这些属性名称各代表什么意思,下面将会讲到。
属性lastIndex存储了最新匹配结果的结束位置。这是RegExp非常重要的一个属性,RegExp在每次搜索时,都从该属性值的位置开始搜索。
知道了这点,我们可以发起两次搜索来看看:
在搜索前,re的lastIndex值为0。意味着下次搜索将从此位置开始。
在第1次搜索中,在src数组的索引值为2
的位置匹配结果,则将result的index属性值改为2
。然而,re的lastIndex的值仍为0
。
而在第2次搜索中,由于re的lastIndex的值仍为0
,故仍从头开始搜索,这样,result的index属性值仍为2
。而re的lastIndex的值仍为0
。
现在的问题是,re在每次搜索时匹配到结果后为何不更新lastIndex属性值?
RegExp在默认情况下是很慵懒的。其默认的工作流程是:搜索,如果匹配成功,将匹配结果的信息填充进数组result中,然后终止搜索并返回result。也即说,默认情况下,如果第1次匹配成功,则直接返回匹配结果,既不会更新其内部的lastIndex的属性值,更不会再进行第2次的搜索。
我们需要按下一个触发全局搜索的开关。
全局搜索
因为我们已经知道,总共应有2个匹配结果,因此我们需要改变RegExp的这种慵懒的默认行为。我们需要告诉它,全面搜索所有匹配结果。而要让RegExp全面搜索所有的匹配结果,需为其设置一个标志:g,(英文单词global
的第1个字母,)表示需要全局搜索。设置该标志的代码如下:
即在字面符表达式的最后面添加一个g
字符即可。
下面,我们为其设置g标志,并在实际搜索之前查看其内部状态:
显示:
flags属性记下了g这个标志,且global属性值被设置为true
。其他属性值没有变化。
现在,让其干活。
result的结果还是匹配到第1个的is
,而re的各个属性中,属性lastIndex的值发生了变化,也即,它将上次匹配结束时的位置记下来了,即位于源字符串的第4
个索引值的位置。我们可以查看该索引位置之后的内容:
终端显示:
这意味着,re告诉自己:"我已做好标志,下回从这里开始继续搜索。好了,我先休息一下。"
re | lastIndex | 4 |
source | is | |
flags | g | |
global | true |
src | T | h | i | s | o | n | e | i | s | O | K | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
result | 0 | is |
length | 1 | |
index | 2 | |
input | This one is OK. | |
groups | undefined |
太懒了!即使已经为其设置了全局性标志g,也仅是让其记录下上次搜索到的位置而已,它才不会自主地一次性全部干完活。若不是亲眼所见,真不敢相信,连代码都完全学透了人性慵懒的精髓!
我们得一次又一次地推动它干活!
当我们设置了全局标志g后,exec方法一旦有匹配结果时,将更新其lastIndex的属性值指向匹配结果的结束位置,再返回匹配结果result;当没有匹配结果时,它将返回null值。因此,代码:
先将每次的匹配结果赋值于变量result,然后判断其是否是null值;如果不为null,说明活还没干完,得继续干活。
因此,在上面的循环体中,我们可查看其结果,及其内部状态:
首先,我们是在每次有匹配结果时都及时查看结果。其次,re在全部搜索后,将重置lastIndex的值为0
,这样,下回再让它干活时,它知道应从头开始搜索。它并不笨,但就是很懒。
运行这段代码,终端显示:
说明第一次匹配后应开始的位置是4
,第二次匹配后应开始的位置是11
。全部搜索完毕后,lastIndex又被重置为0
。
第二次匹配的状态图如下:
re | lastIndex | 11 |
source | is | |
flags | g | |
global | true |
src | T | h | i | s | o | n | e | i | s | O | K | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
result | 0 | is |
length | 1 | |
index | 9 | |
input | This one is OK. | |
groups | undefined |
注意:这里result[0]及re.source的值均等于is
,但它们分属不同的类型。result[0]是实际匹配到的结果,而re.source是pattern的字符串表现。在下面谈到pattern时,将看到它们将出现不同的值。此外,re不持有src输入源,只持有pattern。而result只有在匹配成功后,其input属性值才会等于src输入源的值。
在每次搜索时,存在一个等量关系:
也即,匹配结果开始的位置,加上匹配结果字符串的长度,等于下次搜索开始的位置。
本节小结
打开全局开头g,即可进行全局搜索。每次匹配都会返回一个结果,且结果中都含有匹配结果字符串、匹配开始位置等信息。
自动加亮匹配结果
下面做一个自动加亮匹配结果的小工具。
第一步,明确目标
通过图示来明确我们的目标:
src | T | h | i | s | o | n | e | i | s | O | K | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
一是将所有匹配结果都加亮。这个目标很简单,一行代码即可搞定:
$&
是String在配合正则表达式时的一个很便利的工具,它可以指代所匹配到的结果。
但我们这里提出第二个要求,即边匹配,边加亮,这能适合更多的场合。因此上面用不同的颜色标出先后两次的匹配结果。
第二步,确定算法
我们准备使用String的slice方法来生成各部分的字符串,最后再予以合并为最终的结果。该方法要求确定首尾两个位置。因涉及到多个对象,我们在上面第一次匹配的基础上再加上一个matchedObj对象,通过观察法来确定算法。
src | T | h | i | s | o | n | e | i | s | O | K | . | |||
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
re | lastIndex | 4 |
result | 0 | is |
index | 2 |
matchedObj | pointer | 0 |
str |
我们使用matchedObj对象的str属性来存储最终结果,pointer属性则是不同阶段的指针。一开始,matchedObj的pointer指向src的索引值为0
的位置,其str为空值。
而当第一次匹配成功时,re的lastIndex指向4
的位置,这正好是slice方法的结束参数的位置,而起始参数的位置即matchedObj的pointer属性值。而利用此时result对象的两个属性值,即可完成这一部分的字符串替换任务。然后,再将matchedObj的pointer指向4
的位置,该位置即成为下次分割字符串的开始位置。
而第二次匹配,lastIndex指向11
的位置,确定了第二次分割字符串的结束位置。与第一步一样,此时可将4
到10
之间的字符串加亮,再将结果添加至matchedObj的str属性值即可。再让matchedObj的pointer指向11
的位置。
而后面再无匹配结果,则余下11
至14
的剩余字符串,在代码中,只需在循环结束后加上这段字符串即可。
第三步,编写代码
于是便有了以下的代码:
运行结果如下所示:
当修改变量src及re的值时,也能出现预期的结果。
第四步,比较代码
上面的代码看似简单,但实际上却非如此。我们在同时与4个对象打交道,而每个对象又同时有多个属性,且属性值之间相互纠缠、随时变化,因而比较复杂。如果没有清晰的图示,我们可能需要花费许多时间,也不一定能写出如此简洁而高效的代码。
实际上,我第一次凭感觉直接编写代码,然后再多次调试,最后写出了如下的代码:
点击查看比较难看的代码:
虽然上面的代码也可正确运行,但没对比就没有伤害。看完再将它隐藏起来吧,难看。代码不是写出来的,代码只不过是我们清晰思路的字符表达。如果没有借助于图示,我们很难有清晰的思路,所写的代码就既难看、又难明白。很多时候,阅读别人的代码比自己来编写代码还要难得多。
代码也不是用来读的。同是一部《大话西游》,却经历了从最初被认定为烂片到不可攀越的经典之作的蜕变。在大家的脑海中同是一件物象,而经众口所描述出来的却大相径庭。只有物象是唯一的;只有图示是唯一的,而代码却不是。
九年多前,我写的一篇博客文章在Canvas中绘制圆角矩形,花了许多时间作了大量研究,洋洋洒洒写了3000多字,而到最后,却仅为了写出下面的代码:
虽然投入与产出不成比例,但这段代码却是看得很令人赏心悦目。其内在原因还是因为图象的威力所致,还是因为脑里有了清晰的思路所致。
目前,此工具尚缺乏必要的交互性,但已经够直观地告诉我们共有多少个匹配结果,以及各个匹配位置。通过制作这个小工具,能让我们熟悉正则表达式全局匹配的整个流程细节。