现已推出第 4 版点击此处阅读

第 9 章正则表达式

有些人面对问题时,会想:“我知道了,我会用正则表达式。” 然后他们就有了两个问题。

Jamie Zawinski

元玛大师说:“逆木而伐,费力甚多。逆题而编,费码甚多。”

元玛大师,编程之书
A railroad diagram

编程工具和技术以混乱的演化方式生存和传播。赢得胜利的并不总是那些漂亮或天才的工具,而是那些在正确的小众领域内功能足够强大或恰好与另一个成功的技术集成在一起的工具。

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

正则表达式既笨拙又非常有用。它们的语法很隐晦,JavaScript 为它们提供的编程接口也很笨拙。但它们是检查和处理字符串的强大工具。正确理解正则表达式将使你成为更有效的程序员。

创建正则表达式

正则表达式是一种对象类型。它可以由 RegExp 构造函数构造,也可以通过将模式括在正斜杠 (/) 字符之间来写成文字值。

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

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

使用 RegExp 构造函数时,模式被写成普通的字符串,因此普通的转义规则适用于反斜杠。

第二种符号,模式出现在正斜杠字符之间,对反斜杠的处理略有不同。首先,由于正斜杠结束模式,因此我们需要在要作为部分模式的任何正斜杠之前添加一个反斜杠。此外,不是特殊字符代码的一部分的反斜杠(如 \n)将被保留,而不是像字符串那样被忽略,并且会改变模式的含义。一些字符,如问号和加号,在正则表达式中具有特殊含义,如果它们用于表示字符本身,则必须在其前面加上反斜杠。

let eighteenPlus = /eighteen\+/;

测试匹配

正则表达式对象有许多方法。最简单的方法是 test。如果你向它传递一个字符串,它将返回一个布尔值,告诉你该字符串是否包含表达式中的模式的匹配项。

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

仅包含非特殊字符的正则表达式只是代表该字符序列。如果abc出现在我们正在测试的字符串中的任何地方(不仅仅是在开头),test 将返回 true

字符集

找出字符串是否包含abc也可以通过调用 indexOf 来完成。正则表达式允许我们表达更复杂的模式。

假设我们想要匹配任何数字。在正则表达式中,将一组字符放在方括号之间,会使表达式的那部分匹配方括号之间的任何字符。

以下两个表达式都匹配包含数字的所有字符串

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

在方括号内,两个字符之间的连字符 (-) 可用于表示字符范围,其中排序由字符的 Unicode 编号决定。字符 0 到 9 在此排序中紧挨在一起(代码 48 到 57),因此 [0-9] 包含所有这些字符,并匹配任何数字。

许多常见的字符组都有自己的内置快捷方式。数字就是其中之一:\d[0-9] 含义相同。

\d任何数字字符
\w字母数字字符(“单词字符”)
\s任何空白字符(空格、制表符、换行符等)
\D不是数字的字符
\W非字母数字字符
\S非空白字符
.除换行符外的任何字符

因此,你可以使用以下表达式匹配像 01-30-2003 15:20 这样的日期和时间格式

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("01-30-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

星号 (*) 的含义类似,但它也允许模式匹配零次。在某事物后面加上星号永远不会阻止模式匹配——如果它找不到任何合适的文本进行匹配,它只会匹配零个实例。

问号使模式的一部分可选,意味着它可以出现零次或一次。在下面的示例中,u 字符可以出现,但模式在它缺失时也匹配。

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

要表示模式应该精确地出现多次,请使用花括号。例如,在元素后面加上 {4} 要求它精确地出现四次。也可以用这种方式指定范围:{2,4} 表示该元素必须至少出现两次,最多出现四次。

这是日期和时间模式的另一个版本,它允许单、双位数的天、月和小时。它也更容易理解。

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

在使用花括号时,也可以通过省略逗号后面的数字来指定开放式范围。因此,{5,} 表示五次或更多次。

分组子表达式

要在多个元素上使用 *+ 等运算符,必须使用括号。在括号中包含的正则表达式的一部分,对于其后的运算符来说,算作单个元素。

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

第一个和第二个 + 字符仅适用于 boohoo 中的第二个o。第三个 + 应用于整个组 (hoo+),匹配一个或多个类似的序列。

示例中表达式末尾的 i 使此正则表达式不区分大小写,允许它匹配输入字符串中的大写 B,即使模式本身都是小写。

匹配项和组

test 方法是最简单的匹配正则表达式的方法。它只告诉你是否匹配,没有其他信息。正则表达式还有一个 exec(执行)方法,如果未找到匹配项,它将返回 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 中表示日期和时间值的内置方法。

Date 类

JavaScript 有一个用于表示日期(或更确切地说是时间点)的标准类,称为 Date。如果只是使用 new 创建一个日期对象,您将获得当前日期和时间。

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 使用一种约定,其中月份数字从零开始(因此十二月为 11),而日期数字从 1 开始。这很令人困惑且荒谬。小心。

最后四个参数(小时、分钟、秒和毫秒)是可选的,如果未给出,则默认为零。

时间戳存储为自 1970 年开始以来的毫秒数,以 UTC 时区为准。这遵循了“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 函数来获得当前毫秒计数。

日期对象提供诸如 getFullYeargetMonthgetDategetHoursgetMinutesgetSeconds 之类的用于提取其组件的方法。除了 getFullYear 之外,还有 getYear,它为您提供年份减去 1900(98119),基本上是无用的。

将我们感兴趣的表达式部分括起来,我们现在可以从字符串中创建一个日期对象。

function getDate(string) {
  let [_, month, day, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("1-30-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 时,正则表达式引擎会通过首先尝试从字符串开头匹配表达式,然后从第二个字符匹配,依此类推,直到找到匹配项或到达字符串末尾,来查找字符串中的匹配项。它将返回找到的第一个匹配项,或者完全无法找到匹配项。

为了进行实际匹配,引擎将正则表达式视为类似于流程图。这是上一个示例中牲畜表达式的流程图

Visualization of /\b\d+ (pig|cow|chicken)s?\b/

如果我们能找到从流程图左侧到右侧的路径,我们的表达式就匹配。我们在字符串中保留当前位置,并且每次通过一个框时,我们都会验证字符串中当前位置之后的部位是否与该框匹配。

因此,如果我们尝试从位置 4 匹配 "the 3 pigs",我们在流程图中的进度将如下所示

回溯

正则表达式 /\b([01]+b|[\da-f]+h|\d+)\b/ 匹配以下内容之一:以 b 结尾的二进制数、以 h 结尾的十六进制数(即以 16 为基数,其中字母 af 代表数字 10 到 15),或者没有后缀字符的普通十进制数。这是相应的流程图

Visualization of /\b([01]+b|\d+|[\da-f]+h)\b/

当匹配此表达式时,通常会发生进入顶部(二进制)分支的情况,即使输入实际上不包含二进制数。例如,当匹配字符串 "103" 时,只有在遇到数字 3 时,我们才会发现自己走错了分支。该字符串确实与表达式匹配,只是与我们当前所处的分支不匹配。

因此,匹配器会回溯。在进入一个分支时,它会记住当前位置(在本例中,位于字符串的开头,位于流程图中第一个边界框之后),以便如果当前分支不可行,它可以返回并尝试另一个分支。对于字符串 "103",在遇到字符 3 后,它将开始尝试十六进制数的分支,但这又会失败,因为数字后面没有 h。因此,它会尝试十进制数分支。这个分支符合条件,并且最终会报告成功匹配。

匹配器一找到完整匹配就会停止。这意味着,如果多个分支都可能匹配一个字符串,则只使用第一个分支(按分支在正则表达式中的出现顺序排列)。

回溯也发生在诸如 + 和 * 之类的重复运算符上。如果您将 /^.*x/"abcxe" 匹配,则 .* 部分将首先尝试消耗整个字符串。然后,引擎会意识到它需要一个 x 来匹配模式。由于字符串末尾没有 x,因此星号运算符尝试少匹配一个字符。但是,匹配器在 abcx 之后也找不到 x,因此它再次回溯,将星号运算符匹配到 abc现在,它在需要的位置找到了 x,并报告了从位置 0 到 4 的成功匹配。

有可能编写会进行大量回溯的正则表达式。当一个模式可以以多种不同的方式匹配一段输入时,就会出现此问题。例如,如果我们在编写二进制数正则表达式时感到困惑,我们可能会意外地编写类似 /([01]+)+b/ 的内容。

Visualization of /([01]+)+b/

如果它尝试匹配一些没有尾随 b 字符的零和一的长序列,则匹配器将首先通过内部循环,直到它用完所有数字。然后,它会注意到没有 b,因此它会回溯一个位置,再通过外部循环一次,然后再次放弃,尝试再次回溯到内部循环。它将继续尝试通过这两个循环的所有可能路径。这意味着工作量将随着每个附加字符的增加而翻倍。即使只有几十个字符,结果匹配也几乎需要永远的时间。

replace 方法

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

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

第一个参数也可以是正则表达式,在这种情况下,将替换正则表达式的第一个匹配项。当在正则表达式中添加g选项(代表全局)时,字符串中的所有匹配项都将被替换,而不仅仅是第一个。

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

如果通过replace的附加参数或提供不同的方法replaceAll来选择替换一个匹配项还是所有匹配项,这将是明智的。但由于某些不幸的原因,该选择依赖于正则表达式的属性。

使用正则表达式与replace的真正力量在于,我们可以使用替换字符串引用匹配的组。例如,假设我们有一个包含人员姓名的大型字符串,每行一个姓名,格式为Lastname, Firstname。如果我们想交换这些姓名并删除逗号以获得Firstname Lastname格式,我们可以使用以下代码

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。整个匹配可以使用$&引用。

可以将函数(而不是字符串)作为replace的第二个参数传递。对于每个替换,该函数将使用匹配的组(以及整个匹配项)作为参数被调用,并且它的返回值将被插入到新字符串中。

这是一个小例子

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

这将获取一个字符串,查找数字后跟字母数字单词的所有出现情况,并返回一个字符串,其中每个这样的出现情况都减 1。

(\d+)组最终成为函数的amount参数,(\w+)组绑定到unit。该函数将amount转换为数字——这始终有效,因为它匹配\d+——并在只有 1 或 0 剩余时进行一些调整。

贪婪

可以使用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(/[\\[.+*?(){|^$]/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开始时成功,而在全局的情况下,它将向前搜索匹配可以开始的位置。

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返回的数组,而是会找到字符串中模式的所有匹配项,并返回一个包含匹配字符串的数组。

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

这利用了赋值表达式 (=) 的值是赋值的值这一事实。因此,通过在while语句中使用match = number.exec(input)作为条件,我们在每次迭代开始时执行匹配,将结果保存在一个绑定中,并在找不到更多匹配项时停止循环。

解析 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文件)的具体规则如下

我们的任务是将这样的字符串转换为一个对象,该对象的属性保存第一个节标题之前写入的设置的字符串,以及节的子对象,这些子对象保存节的设置。

由于格式必须逐行处理,因此将文件拆分为单独的行是一个好的开始。我们在第 4 章中看到了 `split` 方法。但是,某些操作系统不仅使用换行符来分隔行,还使用回车符后跟换行符 ( `"\r\n"` )。鉴于 `split` 方法也允许正则表达式作为其参数,我们可以使用类似于 `/\r?\n/` 的正则表达式来进行拆分,这种拆分方式允许在行之间使用 `"\n"` 和 `"\r\n"`。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let result = {};
  let section = result;
  string.split(/\r?\n/).forEach(line => {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
    } else if (match = line.match(/^\[(.*)\]$/)) {
      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` 语句的测试。

如果一行不是节标题或属性,则该函数使用表达式 `/^\s*(;.*)?$/` 检查它是否是注释或空行。您是否明白它是如何工作的?括号之间的部分将匹配注释,而 `?` 确保它也匹配仅包含空格的行。当一行不匹配任何预期的形式时,该函数将抛出一个异常。

国际字符

由于 JavaScript 的初始实现过于简单,以及这种简单方法后来被设定为标准行为,JavaScript 的正则表达式对于不在英语中的字符来说相当愚蠢。例如,就 JavaScript 的正则表达式而言,“单词字符” 仅是拉丁字母中的 26 个字符之一(大写或小写)、十进制数字以及(出于某种原因)下划线字符。像 *é* 或 *β* 这样的东西,它们绝对是单词字符,不会匹配 `\w`(并且会匹配大写 `\W`,即非单词类别)。

由于一个奇怪的历史巧合,`\s`(空格)没有这个问题,并且匹配 Unicode 标准认为是空格的所有字符,包括不间断空格和蒙古语元音分隔符。

另一个问题是,默认情况下,正则表达式在代码单元上运行,如第 5 章中所述,而不是实际字符。这意味着由两个代码单元组成的字符的行为很奇怪。

console.log(/🍎{3}/.test("🍎🍎🍎"));
// → false
console.log(/<.>/.test("<🌹>"));
// → false
console.log(/<.>/u.test("<🌹>"));
// → true

问题是第一行中的 🍎 被视为两个代码单元,而 ` {3}` 部分仅应用于第二个代码单元。同样,点匹配单个代码单元,而不是构成玫瑰表情符号的两个代码单元。

您必须在正则表达式中添加 `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?/零个或一个出现
/x{2,4}/两个到四个出现
/(abc)/一个组
/a|b|c/几种模式中的任何一种
/\d/任何数字字符
/\w/字母数字字符(“单词字符”)
/\s/任何空格字符
/./除换行符外的任何字符
/\b/一个词边界
/^/输入的开头
/$/输入的结尾

正则表达式有一个方法 `test` 来测试给定字符串是否与其匹配。它还有一个方法 `exec`,当找到匹配项时,它将返回一个包含所有匹配组的数组。这样的数组具有一个 `index` 属性,该属性指示匹配从何处开始。

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

正则表达式可以具有选项,这些选项写在结束斜杠之后。`i` 选项使匹配不区分大小写。`g` 选项使表达式 *全局*,这在其他事情中,会导致 `replace` 方法替换所有实例,而不仅仅是第一个实例。`y` 选项使它变得粘性,这意味着它不会向前搜索并跳过字符串的一部分以寻找匹配项。`u` 选项打开 Unicode 模式,它修复了围绕占用两个代码单元的字符处理的许多问题。

正则表达式是一种锋利的工具,但手柄笨拙。它们极大地简化了一些任务,但在应用于复杂问题时会很快变得难以管理。知道如何使用它们的一部分是抵制将它们无法干净地表达的东西强塞到它们中的冲动。

练习

在完成这些练习的过程中,几乎不可避免地会因某个正则表达式难以解释的行为而感到困惑和沮丧。有时,将表达式输入到像https://debuggex.com这样的在线工具中会有所帮助,以查看其可视化是否符合您的预期,以及尝试它对各种输入字符串的响应方式。

正则表达式高尔夫

代码高尔夫 是用来形容尝试用尽可能少的字符来表达特定程序的一种游戏。类似地,正则表达式高尔夫 是一种编写尽可能小的正则表达式来匹配给定模式(仅匹配该模式)的做法。

对于以下每一项,编写一个正则表达式来测试给定子字符串中的任何一个是否出现在字符串中。正则表达式应仅匹配包含所述子字符串之一的字符串。除非明确提到,否则不要担心词边界。当您的表达式起作用时,看看是否可以使其更小。

  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(/.../,
       ["Siebentausenddreihundertzweiundzwanzig"],
       ["no", "three small words"]);

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}'`);
  }
}

引用风格

假设您写了一个故事,并且在整个故事中都使用单引号来标记对话部分。现在您想将所有对话引号替换为双引号,同时保留缩写词(如 *aren't*)中使用的单引号。

想想区分这两种引号使用方法的模式,并构建一个对 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."

最明显的解决方案是仅替换两侧至少有一个非单词字符的引号 - 例如 /\W'|'\W/。但您还需要考虑行的开头和结尾。

此外,您必须确保替换还包括由 \W 模式匹配的字符,以避免这些字符被删除。这可以通过将它们括在括号中并将它们的分组包含在替换字符串 ($1, $2) 中来实现。未匹配的分组将被替换为空。

数字再次

编写一个仅匹配 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}'`);
  }
}

首先,不要忘记句点前面的反斜杠。

匹配数字前面的可选符号以及指数前面的可选符号可以使用 [+\-]?(\+|-|)(加号、减号或无)。

练习中更复杂的部分是匹配 "5."".5" 但不匹配 "." 的问题。对于此,一个好的解决方案是使用 | 运算符来分隔两种情况 - 或者一个或多个数字后跟一个点和零个或多个数字 *或* 一个点后跟一个或多个数字。

最后,为了使 *e* 不区分大小写,要么在正则表达式中添加一个 i 选项,要么使用 [eE]