WebGL Tutorial
and more

正则表达式

撰写时间:2023-10-15

修订时间:2024-02-26

概述

正则表达式regular expression)是对文本输入源进行高效地搜索、替换的强大工具。

在该领域,正则表达式仅此一家,别无他店。

相关对象

正则表达式主要关心以下几个部分的内容:

  1. 文本输入源
  2. 搜索内容
  3. 搜索方式
  4. 搜索结果

多对象协同合作

let src = 'Hello RegExp!'; let pattern = "RegExp"; let re = new RegExp(pattern); let result = re.test(src); console.log(result); // true

变量src是要搜索的文本输入源,pattern是要从文本输入源中搜索的特定内容,reRegExp的一个实例。在这里,将pattern作为RegExp构造器的参数以创建其一个实例。

之后,以src作为参数调用retest方法,这样便可从src中搜索pattern的内容。

如果在src中能搜索到pattern的内容,我们称为结果匹配matched),则test方法返回true;否则,返回false。上例该方法返回true,说明结果已经匹配。

上面各对象的协作图如下:

digraph { edge [fontcolor="#ACB7C4"] subgraph keyComps { node [style="filled, bold", fillcolor=teal, fontcolor=yellow, color="#eee", margin=0.1] pattern, re, result } subgraph dataTpes { node [shape=component, fontcolor="#BF7B41", style="filled,bold", fillcolor="#444"] String, Boolean, RegExp } subgraph methods { node[shape=diamond, colorscheme=rdbu11, fillcolor=11, fontcolor=white, style="filled,bold"] test } subgraph instanceOf { edge [style="dotted", color=gray91, label="is"] String -> pattern, src [dir=back] result -> Boolean re -> RegExp } subgraph using { edge [arrowhead=diamond color=lightgoldenrod] pattern -> re [label="constructor param"] } { rank=same; re -> test [label="invoke method"] test -> src [label="search pattern on"] } test -> result[label="yields"] }

正如上面代码所示,应用正则表达式,我们需要与4种对象打交道。第1种对象是我们要搜索的字符串src,由客户端指定。一旦指定,内容就固定下来了。而另外的3种对象如下图所示,则是我们学习正则表达式的重点。

digraph { node [class="key-comp" margin=0.1] pattern, re, result }

RegExp字面符

除了以new RegExp()的方式创建RegExp的实例外,还可以字面符的方式来创建。

let src = 'A simple sentence.'; let re = /simple/; let result = re.test(src); console.log(result);

这种方式,以字面符/pattern/作为RegExp的实例创建方式,将pattern直接写进字面符中。

注意:RegExp字面符中的pattern不管有否字符串,都不能加双引号。

以字面符来创建实例的方式,可规避一些字符转义的问题,在下面我们将看到这一点。而以new RegExp()的方式来创建实例的方式,可以使代码更加灵活。两种方法各有特点,可灵活选用。

digraph { re [class="key-comp"] }

需要更多的搜索结果信息

上节中RegExptest方法只返回一个布尔值,表示是否匹配。我们如果想获得更多信息,如,到底匹配到了什么文本、在文本输入源的哪个位置匹配到等信息,我们可以调用exec方法。

const src = 'Good to see you.'; let re = /see/; let result = re.exec(src);

其协作图如下:

digraph { edge [fontcolor="#ACB7C4"] subgraph keyComps { node [class="key-comp", margin=0.1] pattern, re, result } subgraph dataTpes { node [shape=component, fontcolor="#BF7B41", style="filled,bold", fillcolor="#444"] Array } subgraph methods { node[shape=diamond, colorscheme=rdbu11, fillcolor=11, fontcolor=white, style="filled,bold"] exec } subgraph instanceOf { edge [style="dotted", color=gray91, label="is"] result -> Array } subgraph using { edge [arrowhead=diamond color=lightgoldenrod] pattern -> re [label="constructor param"] } { rank=same; re -> exec [label="invoke method"] exec -> src [label="search pattern on"] } exec -> result[label="yields"] }

该方法返回一个数组。我们将其打印出来看看:

console.log(result);

终端输出:

Array(1) 0: "see" groups: undefined index: 8 input: "Good to see you." length: 1

属性0存储了源字符中匹配pattern的字符串。groups是分组,下面再谈到。index是本次搜索中,所匹配到的结果位于源字符串中的索引值。input即是src的内容。length存储匹配结果的数量。

需注意的是,exec方法所返回是确实是一个数组,但该数组同时也通过其他的属性存储了其他必要的信息。这里体现了JavaScript语言灵活强大的地方。Array是个数组,同时数组也是Object的实例,因此可拥有其他属性。

因此,result[0]是本次匹配结果,result.index是匹配结果在src中的位置。result.length表示只有1个匹配结果。

本节所涉及正则表达式的核心对象:

digraph { result [class="key-comp" margin=0.1] }

exec方法匹配细节

从上节我们得知,当有匹配结果时,result会存储相关的信息。本节中我们来看其内部细节。

匹配时result的内部状态

当上面的代码匹配到see时,正则表达式引擎的内部流程如下图:

digraph { node [] edge [colorscheme=set312] src [shape=plaintext, colorscheme=greens9, label=<
srcGoodtoseeyou.
0123456789101112131415
>] result [shape=plaintext, colorscheme=brbg9, label=<
result0see
length1
index8
inputGood to see you.
groupsundefined
>] src:f1 -> result:f1 [color=1]; src:f2 -> result:f2 [color=2]; result -> client [label="return" shape=oval]; }

第1步,先创建一个数组对象。上图将其标识为result

第2步,将匹配结果see作为一个数组元素添加进数组对象中。数组的长度由此变为1

第3步,将数组对象的index属性值改写为匹配结果开始的索引值,将数组对象的input属性值改写文本输入源src的字符串值。

第4步,将数组对象返回至客户端。

上面,result0length这两个属性使用绿底标出,以明示这些是与数组性质有关的属性。而其他的属性则是与数组作为对象有关的属性,与数组属性无关。

属性groups存储与命名捕获组named capturing group)有关的信息,这里暂未涉及到。

digraph { result [class="key-comp" margin=0.1] }

匹配结果的使用

上面result存储了匹配结果的索引位置、所匹配到的字符串、输入源等众多信息,我们就可以灵活使用。例如,在源字符串中加亮显示。所需的信息足够了。

或者,替换匹配结果。

const src = 'Good to see you.'; let re = /see/; let result = re.exec(src); let newStr = src.replace(result[0], 'meet'); console.log(newStr); // Good to meet you.

String类的replace方法的第1个参数支持正则表达式,由此我们甚至都不需要显式地调用reexec方法。

const src = 'Good to see you.'; let re = /see/; let newStr = src.replace(re, 'meet'); console.log(newStr); // Good to meet you.

当然,上面这些都显得有点画蛇添足:

const src = 'Good to see you.'; let newStr = src.replace('see', 'meet'); console.log(newStr); // Good to meet you.

这当然是对的,因为在这里pattern正好等于硬编码的字符串值。但正则表达式的强大之处在于当我们构建了足够灵活的pattern时,匹配结果将非常丰富。别急,下面的章节会学到如何构建强大的pattern。而不管pattern如何变化,result的结构与构成原理是不变的。因此,本节中我们只需详细地了解result的细节,为以后更有效地使用正则表达式打下扎实的基础。

使用捕获组获取更多信息

我们可以让正则表达式引擎在result中向我们提供更多的信息。方法是在pattern中设置相应的捕获组capturing groups)。

所谓捕获组,是正则表达式与我们所达成的契约条款:在原有的匹配结果上,您还需要什么更多的信息,放在括号内,我帮您都捕获下来。

const src = 'Good to see you.'; let re = /(s)(ee)/; let result = re.exec(src);

在上面的pattern中,其代码(s)(ee)表示,共有2个捕获组,第1个捕获组捕获s字符,第2个捕获组捕获ee2个字符。

引擎内部状态如下:

digraph { node [] edge [colorscheme=set312] src [shape=plaintext, colorscheme=brbg11, label=<
srcGoodtoseeyou.
0123456789101112131415
>] result [shape=plaintext, colorscheme=brbg11, label=<
result0see
1s
2ee
length3
index8
inputGood to see you.
groupsundefined
>] src:f1 -> result:f1 [color=1]; src:f1 -> result:f2 [color=2]; src:f2 -> result:f3 [color=3]; src:f3 -> result:f4 [color=4]; result -> client [label="return" shape=oval]; }

result对象中的与数组有关的属性起了变化。

第0个元素仍是原来的匹配结果see。不管有无捕获组,第0个元素永远都与捕获组无关,永远都是原来未设定捕获组时的匹配结果。

接着,捕获到了(s),将此捕获结果作为数组一项新的元素添加进数组中。

最后,捕获到了(ee),将此捕获结果作为数组一项新的元素添加进数组中。

因此数组共有3个元素,length属性值得以相应地更新。

而其他的属性,如index, input, groups,由于与捕获组无关,因此没有变化。

捕获组可以嵌套,如((London) (England)), 则第1个捕获结果为London England,第2个捕获结果为London,第3个捕获结果为England

当捕获组多了以后,如何确定捕获内容到底属于哪个捕获组?这里有一个快速确定捕获组内容的方法:从左到右依序出现的左括号(所包围的内容,将依序添加进数组中。嵌套的捕获组其规律也是如此。

注意,设置了捕获组后,只是改变了result数组的元素个数,但并未产生新的result。每调用一次exec方法,不管有无捕获组,也不管捕获组的数量是多少,都只返回一个result

digraph { result [class="key-comp" margin=0.1] }

匹配多次出现的字符

这一节,我们探索如何得到多次匹配的结果。下面是搜索源。

const src = 'This one is OK.';

我们准备在上面的字符串中查找is这2个字符。通过观察,应可找到2个is。下面的代码开始搜索:

const src = 'This one is OK.'; let re = /is/; let result = re.exec(src);

上面的代码,exec将返回一个result。各属性值为:

属性名称属性值
0is
length1
index2
inputThis one is OK.
groupsundefined

上节说过,每调用一次exec方法,都只返回一个result。而这个result只匹配了第1个is,我们如何让正则表达式继续匹配第2个is?再次显式地调用exec方法?但又该如何指定从哪里继续搜索的位置呢?

RegExp的内部状态

RegExp是一个状态机。在搜索过程中,其内部有相应的属性来记录各个阶段的状态。使用下面的代码来查看其状态:

console.dir(re);

终端将列出re的各个属性的名称及其数值:

/the/ dotAll: false flags: "" global: false hasIndices: false ignoreCase: false lastIndex: 0 multiline: false source: "is" sticky: false unicode: false unicodeSets: false

其中,source属性指的就是pattern的值。有许多属性的值都是false。这些属性名称各代表什么意思,下面将会讲到。

属性lastIndex存储了最新匹配结果的结束位置。这是RegExp非常重要的一个属性,RegExp在每次搜索时,都从该属性值的位置开始搜索

知道了这点,我们可以发起两次搜索来看看:

const src = 'This one is OK.'; // before searching let re = /is/; console.log(re.lastIndex); // 0 // 1st searching let result = re.exec(src); console.log(result.index); // 2 console.log(re.lastIndex); // 0 // 2nd searching result = re.exec(src); console.log(result.index); // 2 console.log(re.lastIndex); // 0

在搜索前,relastIndex值为0。意味着下次搜索将从此位置开始。

在第1次搜索中,在src数组的索引值为2的位置匹配结果,则将resultindex属性值改为2。然而,relastIndex的值仍为0

而在第2次搜索中,由于relastIndex的值仍为0,故仍从头开始搜索,这样,resultindex属性值仍为2。而relastIndex的值仍为0

现在的问题是,re在每次搜索时匹配到结果后为何不更新lastIndex属性值?

RegExp在默认情况下是很慵懒的。其默认的工作流程是:搜索,如果匹配成功,将匹配结果的信息填充进数组result中,然后终止搜索并返回result也即说,默认情况下,如果第1次匹配成功,则直接返回匹配结果,既不会更新其内部的lastIndex的属性值,更不会再进行第2次的搜索。

我们需要按下一个触发全局搜索的开关。

digraph { node [class="key-comp" margin=0.1]; result, re; }

全局搜索

因为我们已经知道,总共应有2个匹配结果,因此我们需要改变RegExp的这种慵懒的默认行为。我们需要告诉它,全面搜索所有匹配结果。而要让RegExp全面搜索所有的匹配结果,需为其设置一个标志:g,(英文单词global的第1个字母,)表示需要全局搜索。设置该标志的代码如下:

let re = /is/g;

即在字面符表达式的最后面添加一个g字符即可。

下面,我们为其设置g标志,并在实际搜索之前查看其内部状态:

const src = 'This one is OK.'; let re = /is/g; console.dir(re);

显示:

/the/ ... flags: "g" global: true source: "is" ...

flags属性记下了g这个标志,且global属性值被设置为true。其他属性值没有变化。

现在,让其干活。

const src = 'This one is OK.'; let re = /is/g; let result = re.exec(src); console.log(result[0]); // "is" console.log(result.index); // 2 console.log(re.lastIndex); // 4

result的结果还是匹配到第1个的is,而re的各个属性中,属性lastIndex的值发生了变化,也即,它将上次匹配结束时的位置记下来了,即位于源字符串的第4个索引值的位置。我们可以查看该索引位置之后的内容:

console.log(src.slice(4));

终端显示:

" one is OK."

这意味着,re告诉自己:"我已做好标志,下回从这里开始继续搜索。好了,我先休息一下。"

digraph { node [] edge [colorscheme=set312] re [shape=plaintext, colorscheme=brbg9, label=<
relastIndex4
sourceis
flagsg
globaltrue
>] src [shape=plaintext, colorscheme=greens9, label=<
srcThisoneisOK.
01234567891011121314
>] result [shape=plaintext, colorscheme=brbg9, label=<
result0is
length1
index2
inputThis one is OK.
groupsundefined
>] {rank=same; re, result}; src:f3 -> re:f1 [color=1]; src:f1 -> result:f1 [color=2]; src:f2 -> result:f2 [color=3]; result -> client [label="return"]; }

太懒了!即使已经为其设置了全局性标志g,也仅是让其记录下上次搜索到的位置而已,它才不会自主地一次性全部干完活。若不是亲眼所见,真不敢相信,连代码都完全学透了人性慵懒的精髓!

我们得一次又一次地推动它干活!

当我们设置了全局标志g后,exec方法一旦有匹配结果时,将更新其lastIndex的属性值指向匹配结果的结束位置,再返回匹配结果result;当没有匹配结果时,它将返回null值。因此,代码:

let result; while((result = re.exec(src)) !== null) { // need keep doing work }

先将每次的匹配结果赋值于变量result,然后判断其是否是null值;如果不为null,说明活还没干完,得继续干活。

因此,在上面的循环体中,我们可查看其结果,及其内部状态:

const src = 'This one is OK.'; let re = /is/g; let result; while((result = re.exec(src)) !== null) { console.log(result[0]); console.log(result.index); console.log(re.lastIndex); } console.log(re.lastIndex);

首先,我们是在每次有匹配结果时都及时查看结果。其次,re在全部搜索后,将重置lastIndex的值为0,这样,下回再让它干活时,它知道应从头开始搜索。它并不笨,但就是很懒。

运行这段代码,终端显示:

is 2 4 is 9 11 0

说明第一次匹配后应开始的位置是4,第二次匹配后应开始的位置是11。全部搜索完毕后,lastIndex又被重置为0

第二次匹配的状态图如下:

digraph { node [] edge [colorscheme=set312] re [shape=plaintext, colorscheme=brbg9, label=<
relastIndex11
sourceis
flagsg
globaltrue
>] src [shape=plaintext, colorscheme=greens9, label=<
srcThisoneisOK.
01234567891011121314
>] result [shape=plaintext, colorscheme=brbg9, label=<
result0is
length1
index9
inputThis one is OK.
groupsundefined
>] {rank=same; re, result}; src:f3 -> re:f1 [color=1]; src:f1 -> result:f1 [color=2]; src:f2 -> result:f2 [color=3]; result -> client [label="return"]; }

注意:这里result[0]re.source的值均等于is,但它们分属不同的类型。result[0]是实际匹配到的结果,而re.sourcepattern的字符串表现。在下面谈到pattern时,将看到它们将出现不同的值。此外,re不持有src输入源,只持有pattern。而result只有在匹配成功后,其input属性值才会等于src输入源的值。

在每次搜索时,存在一个等量关系:

result.index + result[0].length = re.lastIndex

也即,匹配结果开始的位置,加上匹配结果字符串的长度,等于下次搜索开始的位置。

digraph { node [class="key-comp", margin=0.1]; result, re; }

本节小结

打开全局开头g,即可进行全局搜索。每次匹配都会返回一个结果,且结果中都含有匹配结果字符串、匹配开始位置等信息。

自动加亮匹配结果

下面做一个自动加亮匹配结果的小工具。

第一步,明确目标

通过图示来明确我们的目标:

digraph { node [] edge [colorscheme=set312] src [shape=plaintext, colorscheme=greens9, label=<
srcThisoneisOK.
01234567891011121314
>] }

一是将所有匹配结果都加亮。这个目标很简单,一行代码即可搞定:

let str = src.replace(/is/g, `$&`);

$&String在配合正则表达式时的一个很便利的工具,它可以指代所匹配到的结果。

但我们这里提出第二个要求,即边匹配,边加亮,这能适合更多的场合。因此上面用不同的颜色标出先后两次的匹配结果。

第二步,确定算法

我们准备使用Stringslice方法来生成各部分的字符串,最后再予以合并为最终的结果。该方法要求确定首尾两个位置。因涉及到多个对象,我们在上面第一次匹配的基础上再加上一个matchedObj对象,通过观察法来确定算法。

digraph { node [] edge [colorscheme=set312] src [shape=plaintext, colorscheme=greens9, label=<
srcThisoneisOK.
01234567891011121314
>] re [shape=plaintext, colorscheme=brbg9, label=<
relastIndex4
>] result [shape=plaintext, colorscheme=brbg9, label=<
result0is
index2
>] matchedObj [shape=plaintext, colorscheme=brbg9, label=<
matchedObjpointer0
str
>] matchedObj:f1 -> src:f1; src:f2 -> result:f1; src:f3 -> re:f1; }

我们使用matchedObj对象的str属性来存储最终结果,pointer属性则是不同阶段的指针。一开始,matchedObjpointer指向src的索引值为0的位置,其str为空值。

而当第一次匹配成功时,relastIndex指向4的位置,这正好是slice方法的结束参数的位置,而起始参数的位置即matchedObjpointer属性值。而利用此时result对象的两个属性值,即可完成这一部分的字符串替换任务。然后,再将matchedObjpointer指向4的位置,该位置即成为下次分割字符串的开始位置。

而第二次匹配,lastIndex指向11的位置,确定了第二次分割字符串的结束位置。与第一步一样,此时可将410之间的字符串加亮,再将结果添加至matchedObjstr属性值即可。再让matchedObjpointer指向11的位置。

而后面再无匹配结果,则余下1114的剩余字符串,在代码中,只需在循环结束后加上这段字符串即可。

第三步,编写代码

于是便有了以下的代码:

const src = 'This book is one of the most valuable books in the world.'; let re = /the/g; let matchedObj = { pointer: 0, str: '' }; let result; while((result = re.exec(src)) !== null) { matchedObj.str += src.slice(matchedObj.pointer, result.index) + `${result[0]}`; matchedObj.pointer = re.lastIndex; } matchedObj.str += src.slice(matchedObj.pointer); document.getElementById('match-output-1').innerHTML = matchedObj.str;

运行结果如下所示:

当修改变量srcre的值时,也能出现预期的结果。

第四步,比较代码

上面的代码看似简单,但实际上却非如此。我们在同时与4个对象打交道,而每个对象又同时有多个属性,且属性值之间相互纠缠、随时变化,因而比较复杂。如果没有清晰的图示,我们可能需要花费许多时间,也不一定能写出如此简洁而高效的代码。

实际上,我第一次凭感觉直接编写代码,然后再多次调试,最后写出了如下的代码:

点击查看比较难看的代码: const src = 'This book is one of the most valuable books in the world.'; let re = /the/g; let result; let matchedObj = ""; let finalStartIndex = 0; while((result = re.exec(src)) !== null) { let reStartIndex = re.lastIndex - result[0].length; let prevPart = src.slice(finalStartIndex, reStartIndex); matchedObj = matchedObj + `${prevPart}${re.source}`; finalStartIndex = re.lastIndex; } matchedObj += src.slice(finalStartIndex); document.getElementById('match-output-1').innerHTML = matchedObj;

虽然上面的代码也可正确运行,但没对比就没有伤害。看完再将它隐藏起来吧,难看。代码不是写出来的,代码只不过是我们清晰思路的字符表达。如果没有借助于图示,我们很难有清晰的思路,所写的代码就既难看、又难明白。很多时候,阅读别人的代码比自己来编写代码还要难得多。

代码也不是用来读的。同是一部《大话西游》,却经历了从最初被认定为烂片到不可攀越的经典之作的蜕变。在大家的脑海中同是一件物象,而经众口所描述出来的却大相径庭。只有物象是唯一的;只有图示是唯一的,而代码却不是。

九年多前,我写的一篇博客文章在Canvas中绘制圆角矩形,花了许多时间作了大量研究,洋洋洒洒写了3000多字,而到最后,却仅为了写出下面的代码:

var Point = function(x, y) { return {x:x, y:y}; }; var rect = Rect(50, 50, 300, 200); drawRoundedRect(rect, 25, ctx); function drawRoundedRect(rect, r, ctx) { var ptA = Point(rect.x + r, rect.y); var ptB = Point(rect.x + rect.width, rect.y); var ptC = Point(rect.x + rect.width, rect.y + rect.height); var ptD = Point(rect.x, rect.y + rect.height); var ptE = Point(rect.x, rect.y); ctx.beginPath(); ctx.moveTo(ptA.x, ptA.y); ctx.arcTo(ptB.x, ptB.y, ptC.x, ptC.y, r); ctx.arcTo(ptC.x, ptC.y, ptD.x, ptD.y, r); ctx.arcTo(ptD.x, ptD.y, ptE.x, ptE.y, r); ctx.arcTo(ptE.x, ptE.y, ptA.x, ptA.y, r); ctx.stroke(); }

虽然投入与产出不成比例,但这段代码却是看得很令人赏心悦目。其内在原因还是因为图象的威力所致,还是因为脑里有了清晰的思路所致。

目前,此工具尚缺乏必要的交互性,但已经够直观地告诉我们共有多少个匹配结果,以及各个匹配位置。通过制作这个小工具,能让我们熟悉正则表达式全局匹配的整个流程细节。

digraph { subgraph { node [class="key-comp", margin=0.1] re, result } }

参考资源

Reference

  1. ECMA 262: RegExp Objects
  2. MDN: Regular expressions
  3. MDN: Memory management

Tutorials

  1. regexlearn.com

Tools

  1. regexr.com
  2. regular expressions 101
  3. RegexLearn
  4. Regex Tester