WebGL Tutorial
and more

Patterns

撰写时间:2024-02-28

修订时间:2024-03-06

概述

上面我们学到了在正则表达式中如何设定搜索内容:

const src = `This one is good. I like it.`; let re = /is/g;

像这种将要搜索的字符集is直接写进pattern中的方法,我们称为静态搜索。正则表达式引擎将完全按照我们所指定的字符来进行搜索。但这种方式缺乏灵活性。

例如,我们准备搜索所有的大写字母。使用静态搜索是很难实现目标的,除非将26个大写字母都列出来。但我们所不知道的是,正则表达式的pattern已经为我们提供了极大的便利。例如:

const src = `This one is good. I like it.`; let re = /[A-Z]/g;

pattern表达式[A-Z]表示匹配从AZ的所有的大写字母。很直观。

还有更多、更丰富、更实用的表达式等待我们去探索。

在支持灵活的pattern表达式后,使用者是很方便了,但可想而知,正则表达式的内部搜索过程将变得无比复杂。而RegExp类的方法却只有两个:testexec。这也是我们之前为何要花许多时间来学习这些方法的原因。我们将一条大香肠切成众多的小片,烤着吃。不仅味道鲜美,而且还易于消化。

惊叹于这种高效的设计,作为使用者,我们从本节开始,将全面学习pattern的各种表达式。我们将从最实用、最常见的功能开始,循序渐进地学习。

Pattern表达式是最能体现正则表达式强大之处的地方,内容多,但表达式对人类不够友好,因此需慢慢体会、习惯、熟悉。

下面,我们将从4大方面学习pattern:

  1. 匹配哪些字符
  2. 限定字符的位置
  3. 限定字符的数量
  4. 分组与引用

关于本页面例子的说明

出于讲解的需要,本页面中将会出现许多较小、但功能特定的例子。每个例子都应有3个要求:例子要求准确,且需经过运行测试,最后还能将结果显示出来。这个工作量是相当巨大且很容易出错的,并且,如果以后改了代码,就得重新运行并同步其结果。

鉴于此,我边学边用,利用正则表达式的功能,编写了一段小代码,实现了这样的功能:我只负责编写例子代码,而在HTML页面加载后,这段小代码将自动运行页面中的每个例子程序,并将运行结果动态地添附到该例子代码的下面。

点击查看代码 init(); function init() { let tpres = document.querySelectorAll('trim-pre.re-demo'); for (let tpre of tpres) { let text = tpre.textContent.trim(); let src = (() => { let re = /const src = `(.+)`/; // after re.exec(), the escaped char such as '\n' in the result would be '\\n' let result = re.exec(text); return result[1]; })(); let reText = (() => { let re = /let re = (.+);/; let result = re.exec(text); result = result[1].split('/'); return { pattern: result[1], flags: result[2] }; })(); if (reText.pattern === `\\s`) { reText.pattern = `\\\\[0a-z]`; } let re = new RegExp(reText.pattern, reText.flags); let outputStr; outputStr = src.replace(re, replacer); // if the original text of src is "One\nTwo", then src would be "One\\nTwo" // and would not be matched by /\s/g //outputStr = outputStr.replace(/\\./g, `$&`); tpre.insertAdjacentHTML('afterend', `

${outputStr}

`); } } function replacer(matched, offset) { return `${matched}`; }

一点说明:上面代码出于直观的目的,为了显示不可打印的控制字符,特将它们均转换为2个可打印的普通字符,并相应地改了匹配模式。具体详见ASCII转义字符

因此,本页面中所有的例子程序都会自动得到运行,并在该例子代码下面自动生成运行结果。如果运行结果与我们的预期不同,则只能说明一个问题,该例子代码有问题。而如果运行结果没问题,则说明例子代码已经实时运行验证而没问题,可放心使用。

同时,这也是为何要学习正则表达式的一个活生生的解答:学习正则表达式之后,我们的工具箱中又多了一件强大的工具,在需要时,即可将其亮出来。

更重要的是,当学会正则表达式之后,我们的锤子思维又更加丰富了:当我们拥有一把锤子,而又看到面包时,我们就会想到,我能不能用锤子将面包砸开来吃?

逻辑或

pattern表达式|表示逻辑或的关系。如one|two表示将匹配字符串one或字符串two

const src = `I would like to see the sea.`; let re = /w|k/g;

上面的代码w|k表示,匹配wk这两个字符。

逻辑或的关系可串联多个元素。

const src = `I would like to see the sea.`; let re = /w|k|t|s/g;

下面将逻辑或应用到多字符的场合。

const src = `I would like to see the sea.`; let re = /see|sea/g;

上面|符号的两边均是多个字符,则seesea成为逻辑或的关系。

seesea只有最后一个字符不同。我们能否写成/see|a/g?

const src = `I would like to see the sea.`; let re = /see|a/g;

正则表达式引擎将其理解为共有左右两部分,左边是see,右边是a。说明对于|,正则表达式引擎并非只从|的左右两边各取1个字符。正则表达式引擎从|的两边各取所有的字符作为逻辑或运算的成分。

而我们这里的匹配要求是:先是se两个字符,紧接着是ea字符。

我们可以使用分组符号(...)来明确要求。

分组

分组的作用

括号(...)是个好东西,在数学中它可以优先计算,在编程语言中它可以优先执行。

而相类似的,在正则表达式中,它称为分组grouping),分组内的部分将优先得到解析。

const src = `I would like to see the sea.`; let re = /se(e|a)/g;

se(e|a)的意思为:先是se两个字符,紧接着是ea其中的1个字符。意思非常明确,无任何歧义。

因此,在有可能产生歧义的地方,我们可使用分组符号(...)来消除歧义。

非捕获的分组

但上面产生了一个副作用。之前我们学过,如果在pattern中使用分组符号,将使result这个数组多产生额外的数组元素。

(() => { const src = `I would like to see the sea.`; let re = /se(e|a)/g; let result; while((result = re.exec(src)) !== null) { console.log(result[0]); console.log(result[1]); } })();

结果为:

see e sea a

分组可导致额外的数组元素的这个效果,在别的地方可能用得上。但在这里,我们只需要seesea这两个匹配结果。而额外的结果占用了更多的内存空间,加重了系统的负担,降低了程序运行效率。如何避免?

表达式(?:x)称为非捕获的分组non-capturing group)。正则表达式将只对x进行分组,但不会将其匹配结果添加为result新的数组元素。

(() => { const src = `I would like to see the sea.`; let re = /se(?:e|a)/g; let result; while((result = re.exec(src)) !== null) { console.log(result.length); console.log(result[0]); console.log(result[1]); } })();

(?:e|a)表示,选项只从分组括号内的字符ea中产生,而此分组不进行捕获。

终端显示:

1 see undefined 1 sea undefined

完全符合我们的目标!

范围

[xyz]: 特定范围内的字符集

要匹配特定范围内的字符,可用[xyz]的表达式。该表达式可匹配到[]之内的任一字符。

const src = `Lorem ipsum dolor sit amet, consectetur adipisicing elit.`; let re = /[abc]/g;

上面的句子是虚拟的伪文,语义上没有任何意义,但在这里可代表随意产生的字母组合,可作随机文本输入源使用。

[abc]表示,如果出现a, b, c这3个字符中任意一个字符,则予以匹配。我们可以随意将任意字符添加进中括号[]中。

从上面我们知道,表示范围的代码[abc]与表示逻辑或的代码a|b|c的效果都是一样的,都是从a, b, c这3个字符中匹配任意一个字符。当选项元素较多时,使用[abc]更加便捷、直观。

看下面的例子:

const src = `Mike and Tom are good friends.`; let re = /[(Mike](Tom)]/g;

我们的本意是将MikeTom作为两个独立的个体都列入匹配的范围,因此放在[]内,且使用()来明确分组。但如同上面所见,该表达式未产生匹配结果。说明,表示范围的表达式,只对单字符起作用。若要从由多个字符组成的元素中进行选择,可使用逻辑或的表达式。

const src = `Mike and Tom are friends.`; let re = /(Mike)|(Tom)/g;

[^xyz]: 不在特定范围内的字符集

[^xyz]的表达式,可匹配到不在[xyz]这个范围内的任意字符。

const src = `Lorem ipsum dolor sit amet, consectetur adipisicing elit.`; let re = /[^aeost]/g;

[A-Z]: 连续递进的范围

如果这些字符是连续且递进的,则可使用[x-z]的表达式,表示从x,到z这个范围的字符均可匹配。因此,最终匹配的字符将是x, y, z

所以,[A-Z]匹配所有大写字母:

const src = `Lorem ipsum dolor sit amet, consectetur adipisicing elit.`; let re = /[A-Z]/g;

[a-z]匹配所有小写字母:

const src = `Lorem ipsum dolor sit amet, consectetur adipisicing elit.`; let re = /[a-z]/g;

[0-9]匹配09所有阿拉伯数字的字符:

const src = `The result of 45 plus 23 is 68.`; let re = /[0-9]/g;

注意,连续的字符只能是递进的,不能递减。例如[Z-A]则是错误的表达式。

[^x-z]: 不在特定范围内的字符集

[^x-z]的表达式,可匹配到不在[x-z]这个范围内的任意字符。

const src = `Lorem ipsum dolor sit amet, consectetur adipisicing elit.`; let re = /[^a-v]/g;

我们原想排除匹配av这些小写字母,但从结果中发现,上面的长文还藏了大写字母、空格及标点符号。

多个范围的合并

可以合并多个范围。下面的表达式可匹配所有的字母及阿拉伯数字。

const src = `The result of 45 plus 23 is 68.`; let re = /[A-Za-z0-9]/g;

由于这种组合经常使用,您在下面将会看到,有专门的表达式来实现上面的匹配效果。

范围内的元字符原形毕露

置于中括号[]内的各个字符,除了^-之外,不管其原来是否元字符,都重新变成单纯的字符的组合。原来的元字符的效用失去作用。

const src = `I like tea (red tea). Do you?`; let re = /[(.?)]/g;

字符(, ), .?原是正则表达式中的重量级元字符。但即使是这些元字符,一旦身处[]中,就会变回普通的字符。因此,上面的结果分别匹配出了左括号(, 右括号), 句号.及问号?

而天选之子^-由于需要在范围内描述否定及连续增进的特征,才可得以保留其原来作为元字符的作用。

转义

所谓转义,是指我们可以通过某种方式,将某字符的原本含义转变为另一含义。在正则表达式中,转义分为两种。

元字符到普通字符的转义

上面我们接触了pattern的一些表达式,它们都使用了专门的字符来表示特定的含义。如|, (), ?, []等。当正则表达式看到这些字符,就会按特定的含义来解析这些字符。这些在正则表达式中有特定含义的字符,称为元字符meta character)。元字符不会被匹配。

如果我们需要匹配元字符,则需要将它们进行转义,只要在它们前面加上转义符\符号即可。当正则表达式引擎看到字符前面有转义符\,就不会解析为元字符,而是按普通字符来进行匹配。例如:

const src = `Did you see it yesterday?`; let re = /\?/g;

?本来是元字符,由于我们需要匹配它,因此在pattern表达式中,在其前面加上转义符\,从而构成了表达式\?。这样,正则表达式引擎将把它视为普通的?而予以匹配。

当我们使用转义符时,会产生两个问题。

第一个问题是,混杂了元字符及经转义后的普通含义的元字符的表达式,诸如/[\?\(\)]/g的代码有点难看,大脑一下子转不过来。

这是典型的人脑与电脑的区别。人类可以一眼就认出每个不同的人的相貌,而电脑却要通过人工智能的人脸识别技术才能精准识别出来。但对于/[\?\(\)]/g这样的代码,人脑确实反映不过来,但电脑却是一眼就能识别。这也是造成许多人对正则表达式望而生畏的主要原因之一:看不懂,不学了!

其实我们应当明白,正则表达式是我们给电脑下指令的一种方式,就是要通过电脑看得懂的方式来下指令,它才会准确执行。当我们有钱了,到国外去买菜,而售货员又不懂中文,要让他将菜卖给我们,我们得用他听得懂的当地语言来与其交流,无可厚非。但此问题并非无解。实际上,构建pattern表达式远比看懂pattern表达式容易得多。在构建pattern表达式时,我们是一步一步进行的,每一步的思路都很明确、清晰。搞定每一步骤后,又长又难看的表达式就出来了。而一旦运行结果与我们预期的完全一致,谁又会去要求自己回头快速地辨识这些表达式呢?逐步地构建表达式并及时验证运行结果,这就足够了。

第二个问题是,我们需时刻谨记哪些字符是元字符。这个好办。实践多了,大脑就会熟悉,就能解决了看不懂的问题。而难以全部记忆的问题也好解决,将这些所遇到的元字符专门列个清单,在需要时随时查阅即可。

后面我们还会学习一些元字符,在全面学习后,我会将这些元字符全部列为清单。

正则表达式为何高效?众多极简的元字符,功不可没。正则表达式的设计,可以说是设计史上的殿堂,自正则表达式设计出来后,几十年来,其他佳作,无出其右。

因此,不要纠结于这些问题,学就对了。

普通字符到元字符的转义

本来是普通字符,但出于应用的目的,正则表达式规定,一些普通字符,可以通过在其前面添加转义符\,从而将该普通字符转义为正则表达式的元字符。例如:

const src = `Did you see it yesterday?`; let re = /d/g;

字符d是26个英文字母中的一个普通字符,但由于它运气不佳,恰好是英文单词digit中的首字母,就被正则表达式选用为代表匹配数字(digit)的元字符。当然,选用后,不能失去字符d原来作为普通英文字符的含义,因此,正则表达式引擎要求在字符d的前面添加转义符\而将其转换为元字符:\d

const src = `My phone number is 13988668866, and my email is 123@263.com.`; let re = /\d/g;

相对于第一种的元字符,这一类的元字符不容易出错。一是原来的字符可以照常用来匹配,二是当我们需要使用普通字符转义为元字符来表述这种特定需求时,我们的大脑就会自然而然地想到:我需要将普通的d字符转义为代表匹配数字的元字符。

下面我们会依序学到许多这样的元字符。只是当我们看到诸如\d这样的代码时,我们应当清楚,这是由普通字符转义而来的正则表达式元字符

本节讲述了正则表达式中元字符的转义。此外,还有其他需要转义的场合,如HTML转义,以及ASCII转义字符等。它们均会在很大程度上影响到正则表达式的正确使用。

因此,在学习正则表达式过程中,一旦涉及到各个场合下的字符转义的问题,一定要立即警觉起来。

参考资源

  1. ECMA 262: RegExp Objects
  2. MDN: Regular expressions