九、正则表达式

优质
小牛编辑
128浏览
2023-12-01

九、正则表达式

一些人遇到问题时会认为,“我知道了,我会用正则表达式。”现在它们有两个问题了。

Jamie Zawinski

Yuan-Ma said, 'When you cut against the grain of the wood, much strength is needed. When you program against the grain of the problem, much code is needed.'

Master Yuan-Ma,《The Book of Programming》

程序设计工具技术的发展与传播方式是在混乱中不断进化。在此过程中获胜的往往不是优雅或杰出的一方,而是那些瞄准主流市场,并能够填补市场需求的,或者碰巧与另一种成功的技术集成在一起的工具技术。

本章将会讨论正则表达式(regular expression)这种工具。正则表达式是一种描述字符串数据模式的方法。它们形成了一种小而独立的语言,也是 JavaScript 和许多其他语言和系统的一部分。

正则表达式虽然不易理解,但是功能非常强大。正则表达式的语法有点诡异,JavaScript 提供的程序设计接口也不太易用。但正则表达式的确是检查、处理字符串的强力工具。如果读者能够正确理解正则表达式,将会成为更高效的程序员。

创建正则表达式

正则表达式是一种对象类型。我们可以使用两种方法来构造正则表达式:一是使用RegExp构造器构造一个正则表达式对象;二是使用斜杠(/)字符将模式包围起来,生成一个字面值。

let re1 = new RegExp("abc");
let re2 = /abc/;

这两个正则表达式对象都表示相同的模式:字符a后紧跟一个b,接着紧跟一个c

使用RegExp构造器时,需要将模式书写成普通的字符串,因此反斜杠的使用规则与往常相同。

第二种写法将模式写在斜杠之间,处理反斜杠的方式与第一种方法略有差别。首先,由于斜杠会结束整个模式,因此模式中包含斜杠时,需在斜杠前加上反斜杠。此外,如果反斜杠不是特殊字符代码(比如\n)的一部分,则会保留反斜杠,不像字符串中会将其忽略,也不会改变模式的含义。一些字符,比如问号、加号在正则表达式中有特殊含义,如果你想要表示其字符本身,需要在字符前加上反斜杠。

let eighteenPlus = /eighteen\+/;

匹配测试

正则表达式对象有许多方法。其中最简单的就是test方法。test方法接受用户传递的字符串,并返回一个布尔值,表示字符串中是否包含能与表达式模式匹配的字符串。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

不包含特殊字符的正则表达式简单地表示一个字符序列。如果使用test测试字符串时,字符串中某处出现abc(不一定在开头),则返回true

字符集

我们也可调用indexOf来找出字符串中是否包含abc。正则表达式允许我们表达一些更复杂的模式。

假如我们想匹配任意数字。在正则表达式中,我们可以将一组字符放在两个方括号之间,该表达式可以匹配方括号中的任意字符。

下面两个表达式都可以匹配包含数字的字符串。

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

我们可以在方括号中的两个字符间插入连字符(),来指定一个字符范围,范围内的字符顺序由字符 Unicode 代码决定。在 Unicode 字符顺序中,0 到 9 是从左到右彼此相邻的(代码从48到57),因此[0-9]覆盖了这一范围内的所有字符,也就是说可以匹配任意数字。

许多常见字符组都有自己的内置简写。 数字就是其中之一:\ d[0-9]表示相同的东西。

  • \d任意数字符号

  • \w字母和数字符号(单词符号)

  • \s任意空白符号(空格,制表符,换行符等类似符号)

  • \D非数字符号

  • \W非字母和数字符号

  • \S非空白符号

  • .除了换行符以外的任意符号

因此你可以使用下面的表达式匹配类似于30-01-2003 15:20这样的日期数字格式:

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("30-01-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

这个表达式看起来是不是非常糟糕?该表达式中一半都是反斜杠,影响读者的理解,使得读者难以揣摩表达式实际想要表达的模式。稍后我们会看到一个稍加改进的版本。

我们也可以将这些反斜杠代码用在方括号中。例如,[\d.]匹配任意数字或一个句号。但是方括号中的句号会失去其特殊含义。其他特殊字符也是如此,比如+

你可以在左方括号后添加脱字符(^)来排除某个字符集,即表示不匹配这组字符中的任何字符。

let notBinary = /[^01]/;
console.log(notBinary.test("1100100010100110"));
// → false
console.log(notBinary.test("1100100010200110"));
// → true

部分模式重复

现在我们已经知道如何匹配一个数字。如果我们想匹配一个整数(一个或多个数字的序列),该如何处理呢?

在正则表达式某个元素后面添加一个加号(+),表示该元素至少重复一次。因此/\d+/可以匹配一个或多个数字字符。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

星号(*)拥有类似含义,但是可以匹配模式不存在的情况。在正则表达式的元素后添加星号并不会导致正则表达式停止匹配该元素后面的字符。只有正则表达式无法找到可以匹配的文本时才会考虑匹配该元素从未出现的情况。

元素后面跟一个问号表示这部分模式“可选”,即模式可能出现 0 次或 1 次。下面的例子可以匹配neighbouru出现1次),也可以匹配neighboru没有出现)。

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

我们可以使用花括号准确指明某个模式的出现次数。例如,在某个元素后加上{4},则该模式需要出现且只能出现 4 次。也可以使用花括号指定一个范围:比如{2,4}表示该元素至少出现 2 次,至多出现 4 次。

这里给出另一个版本的正则表达式,可以匹配日期、月份、小时,每个数字都可以是一位或两位数字。这种形式更易于解释。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("30-1-2003 8:45"));
// → true

花括号中也可以省略逗号任意一侧的数字,表示不限制这一侧的数量。因此{,5}表示 0 到 5 次,而{5,}表示至少五次。

子表达式分组

为了一次性对多个元素使用*或者+,那么你必须使用圆括号,创建一个分组。对于后面的操作符来说,圆括号里的表达式算作单个元素。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

第一个和第二个+字符分别作用于boohooo字符,而第三个+字符则作用于整个元组(hoo+),可以匹配hoo+这种正则表达式出现一次及一次以上的情况。

示例中表达式末尾的i表示正则表达式不区分大小写,虽然模式中使用小写字母,但可以匹配输入字符串中的大写字母B

匹配和分组

test方法是匹配正则表达式最简单的方法。该方法只负责判断字符串是否与某个模式匹配。正则表达式还有一个exec(执行,execute)方法,如果无法匹配模式则返回null,否则返回一个表示匹配字符串信息的对象。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

exec方法返回的对象包含index属性,表示字符串成功匹配的起始位置。除此之外,该对象看起来像(而且实际上就是)一个字符串数组,其首元素是与模式匹配的字符串——在上面的例子中就是我们查找的数字序列。

字符串也有一个类似的match方法。

console.log("one two 100".match(/\d+/));
// → ["100"]

若正则表达式包含使用圆括号包围的子表达式分组,与这些分组匹配的文本也会出现在数组中。第一个元素是与整个模式匹配的字符串,其后是与第一个分组匹配的部分字符串(表达式中第一次出现左圆括号的那部分),然后是第二个分组。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

若分组最后没有匹配任何字符串(例如在元组后加上一个问号),结果数组中与该分组对应的元素将是undefined。类似的,若分组匹配了多个元素,则数组中只包含最后一个匹配项。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

分组是提取部分字符串的实用特性。如果我们不只是想验证字符串中是否包含日期,还想将字符串中的日期字符串提取出来,并将其转换成等价的日期对象,那么我们可以使用圆括号包围那些匹配数字的模式字符串,并直接将日期从exec的结果中提取出来。

不过,我们暂且先讨论另一个话题——在 JavaScript 中存储日期和时间的内建方法。

日期类

JavaScript 提供了用于表示日期的标准类,我们甚至可以用其表示时间点。该类型名为Date。如果使用new创建一个Date对象,你会得到当前的日期和时间。

console.log(new Date());
// → Mon Nov 13 2017 16:19:11 GMT+0100 (CET)

你也可以创建表示特定时间的对象。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

JavaScript 中约定是:使用从 0 开始的数字表示月份(因此使用 11 表示 12 月),而使用从1开始的数字表示日期。这非常容易令人混淆。要注意这个细节。

构造器的后四个参数(小时、分钟、秒、毫秒)是可选的,如果用户没有指定这些参数,则参数的值默认为 0。

时间戳存储为 UTC 时区中 1970 年以来的毫秒数。 这遵循一个由“Unix 时间”设定的约定,该约定是在那个时候发明的。 你可以对 1970 年以前的时间使用负数。 日期对象上的getTime方法返回这个数字。 你可以想象它会很大。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

如果你为Date构造器指定了一个参数,构造器会将该参数看成毫秒数。你可以创建一个新的Date对象,并调用getTime方法,或调用Date.now()函数来获取当前时间对应的毫秒数。

Date对象提供了一些方法来提取时间中的某些数值,比如getFullYeargetMonthgetDategetHoursgetMinutesgetSeconds。除了getFullYear之外该对象还有一个getYear方法,会返回使用两位数字表示的年份(比如 93 或 14),但很少用到。

通过在希望捕获的那部分模式字符串两边加上圆括号,我们可以从字符串中创建对应的Date对象。

function getDate(string) {
  let [_, day, month, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

_(下划线)绑定被忽略,并且只用于跳过由exec返回的数组中的,完整匹配元素。

单词和字符串边界

不幸的是,getDate会从字符串"100-1-30000"中提取出一个无意义的日期——00-1-3000。正则表达式可以从字符串中的任何位置开始匹配,在我们的例子中,它从第二个字符开始匹配,到倒数第二个字符为止。

如果我们想要强制匹配整个字符串,可以使用^标记和$标记。脱字符表示输入字符串起始位置,美元符号表示字符串结束位置。因此/^\d+$/可以匹配整个由一个或多个数字组成的字符串,/^!/匹配任何以感叹号开头的字符串,而/x^/不匹配任何字符串(字符串起始位置之前不可能有字符x)。

另一方面,如果我们想要确保日期字符串起始结束位置在单词边界上,可以使用\b标记。所谓单词边界,指的是起始和结束位置都是单词字符(也就是\w代表的字符集合),而起始位置的前一个字符以及结束位置的后一个字符不是单词字符。

console.log(/cat/.test("concatenate"));
// → true
console.log(/\bcat\b/.test("concatenate"));
// → false

这里需要注意,边界标记并不匹配实际的字符,只在强制正则表达式满足模式中的条件时才进行匹配。

选项模式

假如我们不仅想知道文本中是否包含数字,还想知道数字之后是否跟着一个单词(pigcowchicken)或其复数形式。

那么我们可以编写三个正则表达式并轮流测试,但还有一种更好的方式。管道符号(|)表示从其左侧的模式和右侧的模式任意选择一个进行匹配。因此代码如下所示。

let animalCount = /\b\d+ (pig|cow|chicken)s?\b/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pigchickens"));
// → false

小括号可用于限制管道符号选择的模式范围,而且你可以连续使用多个管道符号,表示从多于两个模式中选择一个备选项进行匹配。

匹配原理

从概念上讲,当你使用exectest时,正则表达式引擎在你的字符串中寻找匹配,通过首先从字符串的开头匹配表达式,然后从第二个字符匹配表达式,直到它找到匹配或达到字符串的末尾。 它会返回找到的第一个匹配,或者根本找不到任何匹配。

为了进行实际的匹配,引擎会像处理流程图一样处理正则表达式。 这是上例中用于家畜表达式的图表:

如果我们可以找到一条从图表左侧通往图表右侧的路径,则可以说“表达式产生了匹配”。我们保存在字符串中的当前位置,每移动通过一个盒子,就验证当前位置之后的部分字符串是否与该盒子匹配。

因此,如果我们尝试从位置 4 匹配"the 3 pigs",大致会以如下的过程通过流程图:

  • 在位置 4,有一个单词边界,因此我们通过第一个盒子。

  • 依然在位置 4,我们找到一个数字,因此我们通过第二个盒子。

  • 在位置 5,有一条路径循环回到第二个盒子(数字)之前,而另一条路径则移动到下一个盒子(单个空格字符)。由于这里是一个空格,而非数字,因此我们必须选择第二条路径。

  • 我们目前在位置 6(pig的起始位置),而表中有三路分支。这里看不到"cow""chicken",但我们看到了"pig",因此选择"pig"这条分支。

  • 在位置 9(三路分支之后),有一条路径跳过了s这个盒子,直接到达最后的单词边界,另一条路径则匹配s。这里有一个s字符,而非单词边界,因此我们通过s这个盒子。

  • 我们在位置 10(字符串结尾),只能匹配单词边界。而字符串结尾可以看成一个单词边界,因此我们通过最后一个盒子,成功匹配字符串。

回溯

正则表达式/\b([01]+b|\d+|[\da-f]h)\b/可以匹配三种字符串:以b结尾的二进制数字,以h结尾的十六进制数字(即以 16 为进制,字母af表示数字 10 到 15),或者没有后缀字符的常规十进制数字。这是对应的图表。

当匹配该表达式时,常常会发生一种情况:输入的字符串进入上方(二进制)分支的匹配过程,但输入中并不包含二进制数字。我们以匹配字符串"103"为例,匹配过程只有遇到字符 3 时才知道进入了错误分支。该字符串匹配我们给出的表达式,但没有匹配目前应当处于的分支。

因此匹配器执行“回溯”。进入一个分支时,匹配器会记住当前位置(在本例中,是在字符串起始,刚刚通过图中第一个表示边界的盒子),因此若当前分支无法匹配,可以回退并尝试另一条分支。对于字符串"103",遇到字符 3 之后,它会开始尝试匹配十六进制数字的分支,它会再次失败,因为数字后面没有h。所以它尝试匹配进制数字的分支,由于这条分支可以匹配,因此匹配器最后的会返回十进制数的匹配信息。

一旦字符串与模式完全匹配,匹配器就会停止。这意味着多个分支都可能匹配一个字符串,但匹配器最后只会使用第一条分支(按照出现在正则表达式中的出现顺序排序)。

回溯也会发生在处理重复模式运算符(比如+*)时。如果使用"abcxe"匹配/^.*x/.*部分,首先尝试匹配整个字符串,接着引擎发现匹配模式还需要一个字符x。由于字符串结尾没有x,因此*运算符尝试少匹配一个字符。但匹配器依然无法在abcx之后找到x字符,因此它会再次回溯,此时*运算符只匹配abc。现在匹配器发现了所需的x,接着报告从位置 0 到位置 4 匹配成功。

我们有可能编写需要大量回溯的正则表达式。当模式能够以许多种不同方式匹配输入的一部分时,这种问题就会出现。例如,若我们在编写匹配二进制数字的正则表达式时,一时糊涂,可能会写出诸如/([01]+)+b/之类的表达式。

若我们尝试匹配一些只由 0 与 1 组成的长序列,匹配器首先会不断执行内部循环,直到它发现没有数字为止。接下来匹配器注意到,这里不存在b,因此向前回溯一个位置,开始执行外部循环,接着再次放弃,再次尝试执行一次内部循环。该过程会尝试这两个循环的所有可能路径。这意味着每多出一个字符,其工作量就会加倍。甚至只需较少的一堆字符,就可使匹配实际上永不停息地执行下去。

replace方法

字符串有一个replace方法,该方法可用于将字符串中的一部分替换为另一个字符串。

console.log("papa".replace("p", "m"));
// → mapa

该方法第一个参数也可以是正则表达式,这种情况下会替换正则表达式首先匹配的部分字符串。若在正则表达式后追加g选项(全局,Global),该方法会替换字符串中所有匹配项,而不是只替换第一个。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

如果 JavaScript 为replace添加一个额外参数,或提供另一个不同的方法(replaceAll),来区分替换一次匹配还是全部匹配,将会是较为明智的方案。遗憾的是,因为某些原因 JavaScript 依靠正则表达式的属性来区分替换行为。

如果我们在替换字符串中使用元组,就可以体现出replace方法的真实威力。例如,假设我们有一个规模很大的字符串,包含了人的名字,每个名字占据一行,名字格式为“姓,名”。若我们想要交换姓名,并移除中间的逗号(转变成“名,姓”这种格式),我们可以使用下面的代码:

console.log(
  "Liskov, Barbara\nMcCarthy, John\nWadler, Philip"
    .replace(/(\w+), (\w+)/g, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Philip Wadler

替换字符串中的$1$2引用了模式中使用圆括号包裹的元组。$1会替换为第一个元组匹配的字符串,$2会替换为第二个,依次类推,直到$9为止。也可以使用$&来引用整个匹配。

第二个参数不仅可以使用字符串,还可以使用一个函数。每次匹配时,都会调用函数并以匹配元组(也可以是匹配整体)作为参数,该函数返回值为需要插入的新字符串。

这里给出一个小示例:

let s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g,
            str => str.toUpperCase()));
// → the CIA and FBI

这里给出另一个值得讨论的示例:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // only one left, remove the 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

该程序接受一个字符串,找出所有满足模式“一个数字紧跟着一个单词(数字和字母)”的字符串,返回时将捕获字符串中的数字减一。

元组(\d+)最后会变成函数中的amount参数,而·(\w+)元组将会绑定unit。该函数将amount转换成数字(由于该参数是\d+`的匹配结果,因此此过程总是执行成功),并根据剩下 0 还是 1,决定如何做出调整。

贪婪模式

使用replace编写一个函数移除 JavaScript 代码中的所有注释也是可能的。这里我们尝试一下:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

或运算符之前的部分匹配两个斜杠字符,后面跟着任意数量的非换行字符。多行注释部分较为复杂,我们使用[^](任何非空字符集合)来匹配任意字符。我们这里无法使用句号,因为块注释可以跨行,句号无法匹配换行符。

但最后一行的输出显然有错。

为何?

在回溯一节中已经提到过,表达式中的[^]*部分会首先匹配所有它能匹配的部分。如果其行为引起模式的下一部分匹配失败,匹配器才会回溯一个字符,并再次尝试。在本例中,匹配器首先匹配整个剩余字符串,然后向前移动。匹配器回溯四个字符后,会找到*/,并完成匹配。这并非我们想要的结果。我们的意图是匹配单个注释,而非到达代码末尾并找到最后一个块注释的结束部分。

因为这种行为,所以我们说模式重复运算符(+*?{})是“贪婪”的,指的是这些运算符会尽量多地匹配它们可以匹配的字符,然后回溯。若读者在这些符号后加上一个问号(+?*???{}?),它们会变成非贪婪的,此时这些符号会尽量少地匹配字符,只有当剩下的模式无法匹配时才会多进行匹配。

而这便是我们想要的情况。通过让星号尽量少地匹配字符,我们可以匹配第一个*/,进而匹配一个块注释,而不会匹配过多内容。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

对于使用了正则表达式的程序而言,其中出现的大量缺陷都可归咎于一个问题:在非贪婪模式效果更好时,无意间错用了贪婪运算符。若使用了模式重复运算符,请首先考虑一下是否可以使用非贪婪符号替代贪婪运算符。

动态创建RegExp对象

有些情况下,你无法在编写代码时准确知道需要匹配的模式。假设你想寻找文本片段中的用户名,并使用下划线字符将其包裹起来使其更显眼。由于你只有在程序运行时才知道姓名,因此你无法使用基于斜杠的记法。

但你可以构建一个字符串,并使用RegExp构造器根据该字符串构造正则表达式对象。

这里给出一个示例。

let name = "harry";
let text = "Harry is a suspicious character.";
let regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.

由于我们创建正则表达式时使用的是普通字符串,而非使用斜杠包围的正则表达式,因此如果想创建\b边界,我们不得不使用两个反斜杠。RegExp构造器的第二个参数包含了正则表达式选项。在本例中,"gi"表示全局和不区分大小写。

但由于我们的用户是怪异的青少年,如果用户将名字设定为"dea+hl[]rd",将会发生什么?这将会导致正则表达式变得没有意义,无法匹配用户名。

为了能够处理这种情况,我们可以在任何有特殊含义的字符前添加反斜杠。

let name = "dea+hl[]rd";
let text = "This dea+hl[]rd guy is super annoying.";
let escaped = name.replace(/[^\w\s]/g, "\\$&");
let regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_><_"));
// → This _dea+hl[]rd_ guy is super annoying.

search方法

字符串的indexOf方法不支持以正则表达式为参数。

但还有一个search方法,调用该方法时需要传递一个正则表达式。类似于indexOf,该方法会返回首先匹配的表达式的索引,若没有找到则返回 –1。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

遗憾的是,没有任何方式可以指定匹配的起始偏移(就像indexOf的第二个参数),而指定起始偏移这个功能是很实用的。

lastIndex属性

exec方法同样没提供方便的方法来指定字符串中的起始匹配位置。但我们可以使用一种比较麻烦的方法来实现该功能。

正则表达式对象包含了一些属性。其中一个属性是source,该属性包含用于创建正则表达式的字符串。另一个属性是lastIndex,可以在极少数情况下控制下一次匹配的起始位置。

所谓的极少数情况,指的是当正则表达式启用了全局(g)或者粘性(y),并且使用exec匹配模式的时候。此外,另一个解决方案应该是向exec传递的额外参数,但 JavaScript 的正则表达式接口能设计得如此合理才是怪事。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

如果成功匹配模式,exec调用会自动更新lastIndex属性,来指向匹配字符串后的位置。如果无法匹配,会将lastIndex清零(就像新构建的正则表达式对象lastIndex属性为零一样)。

全局和粘性选项之间的区别在于,启用粘性时,仅当匹配直接从lastIndex开始时,搜索才会成功,而全局搜索中,它会搜索匹配可能起始的所有位置。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

对多个exec调用使用共享的正则表达式值时,这些lastIndex属性的自动更新可能会导致问题。 你的正则表达式可能意外地在之前的调用留下的索引处开始。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

全局选项还有一个值得深思的效果,它会改变match匹配字符串的工作方式。如果调用match时使用了全局表达式,不像exec返回的数组,match会找出所有匹配模式的字符串,并返回一个包含所有匹配字符串的数组。

console.log("Banana".match(/an/g));
// → ["an", "an"]

因此使用全局正则表达式时需要倍加小心。只有以下几种情况中,你确实需要全局表达式即调用replace方法时,或是需要显示使用lastIndex时。这也基本是全局表达式唯一的应用场景了。

循环匹配

一个常见的事情是,找出字符串中所有模式的出现位置,这种情况下,我们可以在循环中使用lastIndexexec访问匹配的对象。

let input = "A string with 3 numbers in it... 42 and 88.";
let number = /\b(\d+)\b/g;
let match;
while (match = number.exec(input)) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40

这里我们利用了赋值表达式的一个特性,该表达式的值就是被赋予的值。因此通过使用match=re.exec(input)作为while语句的条件,我们可以在每次迭代开始时执行匹配,将结果保存在变量中,当无法找到更多匹配的字符串时停止循环。

解析INI文件

为了总结一下本章介绍的内容,我们来看一下如何调用正则表达式来解决问题。假设我们编写一个程序从因特网上获取我们敌人的信息(这里我们实际上不会编写该程序,仅仅编写读取配置文件的那部分代码,对不起)。配置文件如下所示。

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; comments are preceded by a semicolon...
; each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

该配置文件格式的语法规则如下所示(它是广泛使用的格式,我们通常称之为INI文件):

  • 忽略空行和以分号起始的行。

  • 使用[]包围的行表示一个新的节(section)。

  • 如果行中是一个标识符(包含字母和数字),后面跟着一个=字符,则表示向当前节添加选项。

  • 其他的格式都是无效的。

我们的任务是将这样的字符串转换为一个对象,该对象的属性包含没有节的设置的字符串,和节的子对象的字符串,节的子对象也包含节的设置。

由于我们需要逐行处理这种格式的文件,因此预处理时最好将文件分割成一行行文本。我们使用第 6 章中的string.split("\n")来分割文件内容。但是一些操作系统并非使用换行符来分隔行,而是使用回车符加换行符("\r\n")。考虑到这点,我们也可以使用正则表达式作为split方法的参数,我们使用类似于/\r?\n/的正则表达式,这样可以同时支持"\n""\r\n"两种分隔符。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let currentSection = {name: null, fields: []};
  let categories = [currentSection];

  string.split(/\r?\n/).forEach(line => {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
      section = result[match[1]] = {};
    } else if (!/^\s*(;.*)?$/.test(line)) {
      throw new Error("Line '" + line + "' is not valid.");
    }
  });

  return result;
}

console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}

代码遍历文件的行并构建一个对象。 顶部的属性直接存储在该对象中,而在节中找到的属性存储在单独的节对象中。 section绑定指向当前节的对象。

有两种重要的行 - 节标题或属性行。 当一行是常规属性时,它将存储在当前节中。 当它是一个节标题时,创建一个新的节对象,并设置section来指向它。

这里需要注意,我们反复使用^$确保表达式匹配整行,而非一行中的一部分。如果不使用这两个符号,大多数情况下程序也可以正常工作,但在处理特定输入时,程序就会出现不合理的行为,我们一般很难发现这个缺陷的问题所在。

if (match = string.match(...))类似于使用赋值作为while的条件的技巧。你通常不确定你对match的调用是否成功,所以你只能在测试它的if语句中访问结果对象。 为了不打破else if形式的令人愉快的链条,我们将匹配结果赋给一个绑定,并立即使用该赋值作为if语句的测试。

国际化字符

由于 JavaScript 最初的实现非常简单,而且这种简单的处理方式后来也成了标准,因此 JavaScript 正则表达式处理非英语字符时非常无力。例如,就 JavaScript 的正则表达式而言,“单词字符”只是 26 个拉丁字母(大写和小写)和数字,而且由于某些原因还包括下划线字符。像αβ这种明显的单词字符,则无法匹配\w(会匹配大写的\W,因为它们属于非单词字符)。

由于奇怪的历史性意外,\s(空白字符)则没有这种问题,会匹配所有 Unicode 标准中规定的空白字符,包括不间断空格和蒙古文元音分隔符。

另一个问题是,默认情况下,正则表达式使用代码单元,而不是实际的字符,正如第 5 章中所讨论的那样。 这意味着由两个代码单元组成的字符表现很奇怪。

console.log(/\ud83c\udf4e{3}/.test("\ud83c\udf4e\ud83c\udf4e\ud83c\udf4e"));
// → false
console.log(/<.>/.test("<\ud83c\udf39>"));
// → false
console.log(/<.>/u.test("<\ud83c\udf39>"));
// → true

问题是第一行中的"\ud83c\udf4e"(emoji 苹果)被视为两个代码单元,而{3}部分仅适用于第二个。 与之类似,点匹配单个代码单元,而不是组成玫瑰 emoji 符号的两个代码单元。

你必须在正则表达式中添加一个u选项(表示 Unicode),才能正确处理这些字符。 不幸的是,错误的行为仍然是默认行为,因为改变它可能会导致依赖于它的现有代码出现问题。

尽管这是刚刚标准化的,在撰写本文时尚未得到广泛支持,但可以在正则表达式中使用\p(必须启用 Unicode 选项)以匹配 Unicode 标准分配了给定属性的所有字符。

console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false
console.log(/\p{Alphabetic}/u.test("α"));
// → true
console.log(/\p{Alphabetic}/u.test("!"));
// → false

Unicode 定义了许多有用的属性,尽管找到你需要的属性可能并不总是没有意义。 你可以使用\p{Property=Value}符号来匹配任何具有该属性的给定值的字符。 如果属性名称保持不变,如\p{Name}中那样,名称被假定为二元属性,如Alphabetic,或者类别,如Number

本章小结

正则表达式是表示字符串模式的对象,使用自己的语言来表达这些模式:

  • /abc/:字符序列

  • /[abc]/:字符集中的任何字符

  • /[^abc]/:不在字符集中的任何字符

  • /[0-9]/:字符范围内的任何字符

  • /x+/:出现一次或多次

  • /x+?/:出现一次或多次,非贪婪模式

  • /x*/:出现零次或多次

  • /x??/:出现零次或多次,非贪婪模式

  • /x{2,4}/:出现两次到四次

  • /(abc)/:元组

  • /a|b|c/:匹配任意一个模式

  • /\d/:数字字符

  • /\w/:字母和数字字符(单词字符)

  • /\s/:任意空白字符

  • /./:任意字符(除换行符外)

  • /\b/:单词边界

  • /^/:输入起始位置

  • /$/:输入结束位置

正则表达式有一个test方法来测试给定的字符串是否匹配它。 它还有一个exec方法,当找到匹配项时,返回一个包含所有匹配组的数组。 这样的数组有一个index属性,用于表明匹配开始的位置。

字符串有一个match方法来对正确表达式匹配它们,以及search方法来搜索字符串,只返回匹配的起始位置。 他们的replace方法可以用替换字符串或函数替换模式匹配。

正则表达式拥有选项,这些选项写在闭合斜线后面。 i选项使匹配不区分大小写。 g选项使表达式成为全局的,除此之外,它使replace方法替换所有实例,而不是第一个。 y选项使它变为粘性,这意味着它在搜索匹配时不会向前搜索并跳过部分字符串。 u选项开启 Unicode 模式,该模式解决了处理占用两个代码单元的字符时的一些问题。

正则表达式是难以驾驭的强力工具。它可以简化一些任务,但用到一些复杂问题上时也会难以控制管理。想要学会使用正则表达式的重要一点是:不要将其用到无法干净地表达为正则表达式的问题。

习题

在做本章习题时,读者不可避免地会对一些正则表达式的莫名其妙的行为感到困惑,因而备受挫折。读者可以使用类似于 http://debuggex.com/ 这样的在线学习工具,将你想编写的正则表达式可视化,并试验其对不同输入字符串的响应。

RegexpGolf

Code Golf 是一种游戏,尝试尽量用最少的字符来描述特定程序。类似的,Regexp Golf 这种活动是编写尽量短小的正则表达式,来匹配给定模式(而且只能匹配给定模式)。

针对以下几项,编写正则表达式,测试给定的子串是否在字符串中出现。正则表达式匹配的字符串,应该只包含以下描述的子串之一。除非明显提到单词边界,否则千万不要担心边界问题。当你的表达式有效时,请检查一下能否让正则表达式更短小。

  1. carcat

  2. popprop

  3. ferretferryferrari

  4. ious结尾的单词

  5. 句号、冒号、分号之前的空白字符

  6. 多于六个字母的单词

  7. 不包含e(或者E)的单词

需要帮助时,请参考本章总结中的表格。使用少量测试字符串来测试每个解决方案。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["hottentottententen"],
       ["no", "hotten totten tenten"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "learning ape", "BEET"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}

QuotingStyle

想象一下,你编写了一个故事,自始至终都使用单引号来标记对话。现在你想要将对话的引号替换成双引号,但不能替换在缩略形式中使用的单引号。

思考一下可以区分这两种引号用法的模式,并手动调用replace方法进行正确替换。

let text = "'I'm the cook,' he said, 'it's my job.'";
// Change this call.
console.log(text.replace(/A/g, "B"));
// → "I'm the cook," he said, "it's my job."

NumbersAgain

编写一个表达式,只匹配 JavaScript 风格的数字。支持数字前可选的正号与负号、十进制小数点、指数计数法(5e-31E10,指数前也需要支持可选的符号)。也请注意小数点前或小数点后的数字也是不必要的,但数字不能只有小数点。例如.55.都是合法的 JavaScript 数字,但单个点则不是。

// Fill in this regular expression.
let number = /^...$/;

// Tests:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
                 "1.3e2", "1E-4", "1e+12"]) {
  if (!number.test(str)) {
    console.log(`Failed to match '${str}'`);
  }
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
                 ".5.", "1f5", "."]) {
  if (number.test(str)) {
    console.log(`Incorrectly accepted '${str}'`);
  }
}