第 3 版现已发布。 点击此处阅读

第 9 章
正则表达式

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

Jamie Zawinski

元马说:“逆木而伐,功倍其难。逆势而编程,代码倍增。”

元马大师,编程之书

编程工具和技术以一种混乱的、进化的方式生存和传播。最终获胜的并不总是漂亮或精妙的工具,而是那些在合适的利基市场中运行良好的工具——例如,通过与另一件成功的技术集成。

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

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

创建正则表达式

正则表达式是一种对象。 它可以使用 RegExp 构造函数创建,也可以通过将模式括在正斜杠 (/) 字符中来作为字面量值编写。

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

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

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

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

var 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 非空白字符
. \.

除换行符以外的任何字符

var 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

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

这看起来非常糟糕,不是吗? 它有太多反斜杠,产生了背景噪音,使得难以发现实际表达的模式。 我们将在 后面看到这个表达式的稍微改进版本。

这些反斜杠代码也可以用在方括号内。 例如,[\d.] 表示任何数字或句点字符。 但请注意,句点本身在方括号内使用时会失去其特殊含义。 其他特殊字符,例如 +,也是如此。

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

反转一组字符——也就是说,要表达你想要匹配除该组中的字符之外的任何字符——可以在开头的方括号之后写一个插入符号 (^) 字符。

重复模式的一部分

我们现在知道如何匹配单个数字。 如果我们想要匹配一个完整的数字——一个或多个数字的序列,该怎么办?

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

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

星号 (*) 的含义类似,但它也允许模式匹配零次。 带有星号的元素永远不会阻止模式匹配——如果找不到合适的文本进行匹配,它只会匹配零个实例。

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

问号使模式的一部分“可选”,表示它可能出现零次或一次。 在下面的示例中,u 字符允许出现,但模式在该字符缺失时也匹配。

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

var 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,} 表示五次或更多次。

对子表达式进行分组

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

要对一个以上的元素使用 *+ 等运算符,可以使用括号。 只要涉及后面的运算符,用括号括起来的正则表达式的一部分就被视为单个元素。

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

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

匹配项和组

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

test 方法是匹配正则表达式的最简单方法。 它只告诉你是否匹配,没有其他信息。 正则表达式还具有 exec(执行)方法,如果未找到匹配项,则返回 null,否则返回包含有关匹配项信息的 Object。

exec 返回的 Object 具有一个 index 属性,它告诉我们成功匹配在字符串中的位置。 除此之外,该 Object 看起来像(实际上也是)一个字符串数组,其第一个元素是匹配的字符串——在前面的示例中,这是我们正在查找的数字序列。

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

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

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

当正则表达式包含用括号分组的子表达式时,与这些组匹配的文本也将显示在数组中。 整个匹配项始终是第一个元素。 接下来是第一个组匹配的部分(即表达式中开括号最先出现的那一个),然后是第二个组,依此类推。

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

当组最终没有被匹配到(例如,在后面跟着问号时),它在输出数组中的位置将保存 undefined。 同样,当组匹配多次时,只有最后一次匹配会出现在数组中。

组可用于提取字符串的部分。 如果我们不仅想要验证字符串是否包含日期,而且还要提取它并构造一个表示它的 Object,我们可以将括号放在数字模式周围,并直接从 exec 的结果中选取日期。

但首先,我们简要地绕个弯,讨论在 JavaScript 中存储日期和时间值的推荐方法。

日期类型

console.log(new Date());
// → Wed Dec 04 2013 14:24:57 GMT+0100 (CET)

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

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)

你也可以为特定时间创建 Object。

JavaScript 使用一种约定,其中月份编号从零开始(因此十二月是 11),但日期编号从 1 开始。 这很令人困惑也很愚蠢。 注意。

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

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,用来提取其各个组成部分。还有一个getYear方法,它会返回一个没什么用的两位数的年份值(比如9314)。

将表达式中我们感兴趣的部分用括号括起来,我们现在就可以很容易地从字符串中创建日期对象了。

function findDate(string) {
  var dateTime = /(\d{1,2})-(\d{1,2})-(\d{4})/;
  var match = dateTime.exec(string);
  return new Date(Number(match[3]),
                  Number(match[2]) - 1,
                  Number(match[1]));
}
console.log(findDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

单词和字符串边界

不幸的是,findDate也会很开心地从字符串"100-1-30000"中提取出毫无意义的日期00-1-3000。匹配可以发生在字符串的任何位置,所以在这个例子中,它只会从第二个字符开始,到倒数第二个字符结束。

如果我们想强制匹配必须跨越整个字符串,我们可以添加标记^$。插入符号匹配输入字符串的开头,而美元符号匹配结尾。因此,/^\d+$/匹配完全由一个或多个数字组成的字符串,/^!/匹配任何以感叹号开头的字符串,而/x^/不匹配任何字符串(在字符串开头之前不可能有x)。

另一方面,如果我们只是想确保日期以单词边界开头和结尾,我们可以使用标记\b。单词边界可以是字符串的开头或结尾,也可以是字符串中任何一边是单词字符(如\w),另一边是非单词字符的地方。

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

请注意,边界标记并不代表实际字符。它只是强制要求正则表达式只在模式中出现的地方满足特定条件时才会匹配。

选择模式

假设我们想知道一段文字中是否包含一个数字,以及一个数字后面跟着这三个词中的一个,或者它们复数形式的任意一个。

我们可以编写三个正则表达式并依次测试它们,但还有一个更简洁的方法。管道字符 (|) 表示它左侧的模式和右侧的模式之间的选择。所以我可以这样说

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

括号可以用来限制管道运算符作用的模式部分,你可以将多个这样的运算符并排放置,以表示对两个以上模式的选择。

匹配的机制

正则表达式可以被认为是流程图。这是前一个例子中牲畜表达式的流程图

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

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

因此,如果我们尝试用我们的正则表达式匹配"the 3 pigs",我们通过流程图的进度将如下所示

从概念上讲,正则表达式引擎在字符串中寻找匹配的方式如下:它从字符串的开头开始,尝试在那里进行匹配。在这个例子中,那里确实有一个单词边界,所以它会通过第一个方框——但是没有数字,所以它会在第二个方框处失败。然后它移到字符串中的第二个字符,并尝试从那里开始一个新的匹配……依此类推,直到它找到一个匹配,或者到达字符串的结尾,并决定真的没有匹配。

回溯

正则表达式/\b([01]+b|\d+|[\da-f]+h)\b/匹配一个后面跟着b的二进制数,一个没有后缀字符的普通十进制数,或者一个十六进制数(即以 16 为基数,用字母af代表数字 10 到 15)后面跟着一个h。这是对应的流程图

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

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

因此,匹配器回溯。在进入一个分支时,它会记住它的当前位置(在这个例子中,在字符串的开头,就在流程图中的第一个边界方框之后),以便在当前分支不成功时可以返回并尝试另一个分支。对于字符串"103",在遇到字符 3 之后,它将开始尝试十进制数的分支。这个分支匹配成功,因此最终会报告一个匹配结果。

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

回溯也会发生在像 + 和*这样的重复运算符上。如果你将/^.*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(
  "Hopper, Grace\nMcCarthy, John\nRitchie, Dennis"
    .replace(/([\w ]+), ([\w ]+)/g, "$2 $1"));
// → Grace Hopper
//   John McCarthy
//   Dennis Ritchie

替换字符串中的$1$2分别引用模式中的带括号的组。$1被替换为与第一个组匹配的文本,$2被替换为第二个组匹配的文本,依此类推,直到$9。整个匹配可以通过$&来引用。

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

以下是一个简单的例子

var s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g, function(str) {
  return str.toUpperCase();
}));
// → the CIA and FBI

这是一个更有趣的例子

var 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+——并在只剩下一个或零个时进行一些调整。

贪婪

使用 `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` 构造函数。这是一个示例

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

在创建 `\b` 边界标记时,我们必须使用两个反斜杠,因为我们是在普通字符串中编写它们,而不是在斜杠括起来的正则表达式中。`RegExp` 构造函数的第二个参数包含正则表达式的选项——在本例中为 `“gi”`,表示全局和不区分大小写。

但是如果用户名是 `“dea+hl[]rd”`,因为我们的用户是一个书呆子气的青少年?这将导致一个毫无意义的正则表达式,它实际上不会匹配用户的姓名。

为了解决这个问题,我们可以在任何不信任的字符之前添加反斜杠。在字母字符之前添加反斜杠是一个坏主意,因为像 `\b` 和 `\n` 这样的东西具有特殊含义。但是对所有非字母数字字符或空格进行转义是安全的。

var name = "dea+hl[]rd";
var text = "This dea+hl[]rd guy is super annoying.";
var escaped = name.replace(/[^\w\s]/g, "\\$&");
var regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → 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`)选项,并且匹配必须通过 `exec` 方法进行。再说一次,更明智的解决方案应该是允许将额外的参数传递给 `exec`,但明智并非 JavaScript 正则表达式接口的定义特征。

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

如果匹配成功,对 `exec` 的调用会自动更新 `lastIndex` 属性,使其指向匹配后的位置。如果未找到匹配项,`lastIndex` 将重置为零,这也是它在新建的正则表达式对象中的值。

在对多个 `exec` 调用使用全局正则表达式值时,这些对 `lastIndex` 属性的自动更新可能会导致问题。您的正则表达式可能会意外地从前一次调用的剩余索引开始。

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

全局选项的另一个有趣影响是它改变了字符串的 `match` 方法的工作方式。当使用全局表达式调用时,它不会返回类似于 `exec` 返回的数组,而是会找到字符串中模式的所有匹配项,并返回包含匹配字符串的数组。

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

所以要谨慎使用全局正则表达式。它们必要的场合——对 `replace` 的调用以及您想要显式使用 `lastIndex` 的地方——通常是您唯一想要使用它们的地方。

循环遍历匹配项

一个常见的模式是通过使用 `lastIndex` 和 `exec`,以一种让我们在循环体中访问匹配对象的方式扫描字符串中模式的所有出现。

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

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

解析 INI 文件

为了结束本章,我们将研究一个需要正则表达式的問題。想象一下,我们正在编写一个程序,用于自动从互联网上收集有关我们敌人的信息。(我们不会在这里实际编写那个程序,只编写读取配置文件的部分。抱歉让您失望了。)配置文件看起来像这样

searchengine=http://www.google.com/search?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

[gargamel]
fullname=Gargamel
type=evil sorcerer
outputdir=/home/marijn/enemies/gargamel

此格式的具体规则(实际上是一种广泛使用的格式,通常称为INI 文件)如下

我们的任务是将这样的字符串转换为一个对象数组,每个对象都有一个 `name` 属性和一个设置数组。我们需要为每个部分和顶部的全局设置创建一个这样的对象。

由于格式必须逐行处理,因此将文件拆分成单独的行是一个好的开始。我们在第 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
  var currentSection = {name: null, fields: []};
  var categories = [currentSection];

  string.split(/\r?\n/).forEach(function(line) {
    var match;
    if (/^\s*(;.*)?$/.test(line)) {
      return;
    } else if (match = line.match(/^\[(.*)\]$/)) {
      currentSection = {name: match[1], fields: []};
      categories.push(currentSection);
    } else if (match = line.match(/^(\w+)=(.*)$/)) {
      currentSection.fields.push({name: match[1],
                                  value: match[2]});
    } else {
      throw new Error("Line '" + line + "' is invalid.");
    }
  });

  return categories;
}

这段代码遍历文件中的每一行,并根据需要更新“当前部分”对象。首先,它使用表达式 `/^\s*(;.*)?$/` 检查该行是否可以忽略。您明白它是怎么工作的吗?括号之间的部分将匹配注释,而 `?` 将确保它也能匹配仅包含空格的行。

如果该行不是注释,代码将检查该行是否开始一个新的部分。如果是,它将创建一个新的当前部分对象,后续设置将被添加到其中。

最后一个有意义的可能性是该行是一个正常的设置,代码将将其添加到当前部分对象中。

如果一行不匹配任何这些形式,则该函数将抛出一个错误。

请注意 `^` 和 `$` 的反复使用,以确保表达式匹配整行,而不仅仅是部分内容。省略它们会导致代码在大多数情况下都能正常工作,但对于某些输入会导致奇怪的行为,这可能是一个难以追踪的错误。

模式 `if (match = string.match(...))` 类似于使用赋值作为 `while` 条件的技巧。您通常不确定对 `match` 的调用是否会成功,因此您只能在测试此调用的 `if` 语句中访问结果对象。为了不破坏 `if` 形式的良好链条,我们将匹配的结果分配给一个变量,并立即使用该赋值作为 `if` 语句中的测试。

国际字符

由于 JavaScript 最初的实现过于简单,而且这种简单的做法后来被固定为标准行为,因此 JavaScript 的正则表达式对非英语字符非常不敏感。例如,就 JavaScript 的正则表达式而言,“单词字符”只包括拉丁字母表中的 26 个字符(大写或小写)以及下划线。像 éβ 这样的字符,毫无疑问是单词字符,但它们不匹配 \w(而且会匹配大写的 \W,即非单词类别)。

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

其他编程语言中的一些正则表达式实现具有语法来匹配特定的 Unicode 字符类别,例如“所有大写字母”、“所有标点符号”或“控制字符”。JavaScript 计划添加对这些类别的支持,但不幸的是,它们在短期内似乎无法实现。

总结

正则表达式是表示字符串中模式的对象。它们使用自己的语法来表达这些模式。

/abc/ 一系列字符
/[abc]/ 一组字符中的任何字符
/[^abc]/ 不在一组字符中的任何字符
/[0-9]/ 字符范围内的任何字符
/x+/ 模式 x 的一次或多次出现
/x+?/p> 一次或多次出现,非贪婪
/x*/p> 零次或多次出现
/x?/p> 零次或一次出现
/x{2,4}/p> 两次到四次出现
/(abc)/ 一个分组
/a|b|c/ 几种模式中的任何一个
/\d/ 任何数字字符
/\w/ 字母数字字符(“单词字符”)
/\s/ 任何空格字符
/./ 除换行符外的任何字符
/\b/ 单词边界
/^/ 输入开头
/$/ 输入结尾

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

字符串有一个 match 方法来将它们与正则表达式匹配,还有一个 search 方法来搜索一个匹配项,只返回匹配项的起始位置。它们的 replace 方法可以将模式的匹配项替换为替换字符串。或者,您可以向 replace 传递一个函数,该函数将用于根据匹配文本和匹配组构建替换字符串。

正则表达式可以有选项,这些选项写在结束斜杠之后。i 选项使匹配不区分大小写,而 g 选项使表达式全局,这在其他事情中,会导致 replace 方法替换所有实例,而不仅仅是第一个实例。

RegExp 构造函数可用于从字符串创建正则表达式值。

正则表达式是一把锋利的工具,但握柄却很笨拙。它们极大地简化了一些任务,但当应用于复杂问题时,它们很快就会变得难以管理。了解如何使用它们的部分原因是,要抵制将无法合理地用它们表达的东西硬塞进去的冲动。

练习

在完成这些练习的过程中,你几乎不可避免地会对某些正则表达式的莫名其妙的行为感到困惑和沮丧。有时,将表达式输入到像 debuggex.com 这样的在线工具中,看看它的可视化效果是否符合你的预期,并尝试它对各种输入字符串的响应方式,会有所帮助。

正则表达式高尔夫

代码高尔夫是一个用于尝试用尽可能少的字符表达特定程序的游戏的术语。类似地,正则表达式高尔夫是指编写尽可能小的正则表达式以匹配给定模式,并且只有该模式的实践。

对于以下每个项目,编写一个正则表达式来测试字符串中是否出现任何给定的子字符串。正则表达式应该只匹配包含所描述的子字符串之一的字符串。除非明确提及,否则不要担心单词边界。当你的表达式起作用时,看看你是否可以把它做得更小。

  1. carcat

  2. popprop

  3. ferret, ferryferrari

  4. 任何以 ious 结尾的单词

  5. 空格字符后跟句点、逗号、冒号或分号

  6. 长度超过六个字母的单词

  7. 不包含字母 e 的单词

参考 章节总结 中的表格以获得帮助。用几个测试字符串测试每个解决方案。

// Fill in the regular expressions

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

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

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

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

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

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

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


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  yes.forEach(function(s) {
    if (!regexp.test(s))
      console.log("Failure to match '" + s + "'");
  });
  no.forEach(function(s) {
    if (regexp.test(s))
      console.log("Unexpected match for '" + s + "'");
  });
}

引用风格

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

想出一个模式来区分这两种引号用法,并创建一个对 replace 方法的调用,以进行正确的替换。

var 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)来完成。未匹配的分组将被替换为空。

数字再现

一系列数字可以用简单的正则表达式 /\d+/ 匹配。

编写一个仅匹配 JavaScript 风格数字的表达式。它必须支持数字前面的可选减号加号、小数点以及指数符号(5e-31E10),以及指数前面的可选符号。还要注意,在点前面或后面不必有数字,但数字不能是单独的点。也就是说,.55. 是有效的 JavaScript 数字,但单独的点不是

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

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

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

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

练习中比较复杂的部分是匹配 "5."".5" 而不匹配 "." 的问题。为此,一个很好的解决方案是使用 | 运算符来区分这两种情况,要么是一个或多个数字,后面可选地跟一个点和零个或多个数字,要么是一个点,后面跟一个或多个数字。

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