正则表达式

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

Jamie Zawinski

逆着木纹的纹理切割,需要很大的力量。逆着问题的本质编程,需要大量的代码。

袁马大师,《编程之书》
Illustration of a railroad system representing the syntactic structure of regular expressions

编程工具和技术以混乱的演化方式生存和传播。最终获胜的并不总是最好的或最出色的工具,而是那些在正确的小众领域内运作良好,或恰好与另一个成功的技术相结合的工具。

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

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

创建正则表达式

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

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

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

使用 RegExp 构造函数时,模式被写成一个普通字符串,因此正常的转义规则适用。

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

let aPlus = /A\+/;

测试匹配

正则表达式对象有许多方法。最简单的方法是 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 nonBinary = /[^01]/;
console.log(nonBinary.test("1100100010100110"));
// → false
console.log(nonBinary.test("0111010112101001"));
// → true

国际字符

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

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

可以在正则表达式中使用 \p 匹配 Unicode 标准为其分配给定属性的所有字符。这使我们能够以更国际化的方式匹配诸如字母之类的字符。然而,同样由于与原始语言标准的兼容性,只有在正则表达式之后添加了 u 字符(表示 Unicode)时,这些字符才会被识别。

\p{L}任何字母
\p{N}任何数字字符
\p{P}任何标点符号字符
\P{L}任何非字母(大写 P 表示反转)
\p{Script=Hangul}来自给定脚本的任何字符(参见 第 5 章

使用 \w 进行可能需要处理非英语文本(甚至包含“cliché”等外来词的英语文本)的文本处理是一个风险,因为它不会将“é”之类的字符视为字母。尽管它们往往比较冗长,但 \p 属性组更加健壮。

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

另一方面,如果要匹配数字以对它们执行某些操作,通常需要 \d 表示数字,因为将任意数字字符转换为 JavaScript 数字不是像 Number 这样的函数可以为你做的事情。

重复模式的一部分

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

在正则表达式中,在某个元素后面添加加号 (+) 表示该元素可以重复多次。因此,/\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

第一个和第二个 `+` 字符只分别应用于 `boo` 和 `hoo` 中的第二个 `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"]

如果您只想使用括号进行分组,而不希望它们出现在匹配项数组中,可以在左括号后添加 `?:`。

console.log(/(?:na)+/.exec("banana"));
// → ["nana"]

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

但首先,我们将简要介绍一下在 JavaScript 中表示日期和时间值的内置方法。

Date 类

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

console.log(new Date());
// → Fri Feb 02 2024 18:03:06 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),但日期编号从一开始。这很令人困惑而且很愚蠢。请注意。

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

时间戳存储为自 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` 函数来获取当前的毫秒计数。

日期对象提供诸如 `getFullYear`、`getMonth`、`getDate`、`getHours`、`getMinutes` 和 `getSeconds` 等方法来提取其组件。除了 `getFullYear` 之外,还有 `getYear`,它为您提供年份减去 1900 的结果(例如 `98` 或 `125`),基本上没什么用。

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

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` 中提取日期。匹配可能发生在字符串中的任何位置,因此在这种情况下,它只会从第二个字符开始,到倒数第二个字符结束。

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

还有一个 `\b` 标记,它匹配 *单词边界*,即一侧是单词字符,另一侧是非单词字符的位置。不幸的是,这些使用与 `\w` 相同的简单单词字符概念,因此不可靠。

请注意,这些边界标记不匹配任何实际字符。它们只是强制在模式中出现的位置满足给定条件。

*前瞻* 测试执行类似的操作。它们提供一个模式,如果输入与该模式不匹配,则会使匹配失败,但实际上不会将匹配位置向前移动。它们写成 `(?=` 和 `)` 之间。

console.log(/a(?=e)/.exec("braeburn"));
// → ["a"]
console.log(/a(?! )/.exec("a b"));
// → null

第一个示例中的 `e` 是必须匹配的,但不是匹配字符串的一部分。`(?! )` 符号表示 *否定* 前瞻。这仅在括号中的模式 *不* 匹配时才会匹配,导致第二个示例仅匹配后面没有空格的 `a` 字符。

选择模式

假设我们想知道一段文本是否不仅包含数字,还包含数字后跟单词 *pig*、*cow* 或 *chicken* 之一,或其复数形式。

我们可以编写三个正则表达式并依次测试它们,但有一个更好的方法。管道字符 (|) 表示左侧模式和右侧模式之间的选择。我们可以在以下表达式中使用它

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

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

匹配的机制

从概念上讲,当您使用 `exec` 或 `test` 时,正则表达式引擎会通过首先尝试从字符串开头匹配表达式,然后从第二个字符匹配,依此类推,直到找到匹配项或到达字符串末尾,来查找字符串中的匹配项。它要么返回找到的第一个匹配项,要么根本无法找到匹配项。

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

Railroad diagram that first passes through a box labeled 'digit', which has a loop going back from after it to before it, and then a box for a space character. After that, the railroad splits in three, going through boxes for 'pig', 'cow', and 'chicken'. After those it rejoins, and goes through a box labeled 's', which, being optional, also has a railroad that passes it by. Finally, the line reaches the accepting state.

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

回溯

正则表达式 `/^([01]+b|[\da-f]+h|\d+)$/` 匹配以 `b` 结尾的二进制数、以 `h` 结尾的十六进制数(即以 16 为底,字母 `a` 到 `f` 代表数字 10 到 15)或没有后缀字符的普通十进制数。这是相应的图表

Railroad diagram for the regular expression '^([01]+b|\d+|[\da-f]+h)$'

在匹配此表达式时,通常会进入顶部(二进制)分支,即使输入实际上不包含二进制数。例如,在匹配字符串 `103` 时,只有在 `3` 处才能清楚地知道我们处于错误的分支。该字符串 *确实* 与表达式匹配,只是不与我们当前所处的分支匹配。

因此,匹配器会 *回溯*。在进入分支时,它会记住其当前位置(在本例中,在字符串开头,就在图表中的第一个边界框之后),以便如果当前分支不起作用,它可以返回并尝试另一个分支。对于字符串 `103`,在遇到 `3` 字符后,匹配器开始尝试十六进制数字的分支,该分支再次失败,因为数字后面没有 `h`。然后,它尝试十进制数字分支。这个适合,最终报告了匹配项。

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

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

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

Railroad diagram for the regular expression '([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 一起使用真正的强大之处在于我们可以引用替换字符串中的匹配组。例如,假设我们有一个包含人名的大字符串,每行一个姓名,格式为 Lastname, Firstname。如果我们要交换这些姓名并删除逗号以获得 Firstname Lastname 格式,我们可以使用以下代码

console.log(
  "Liskov, Barbara\nMcCarthy, John\nMilner, Robin"
    .replace(/(\p{L}+), (\p{L}+)/gu, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Robin Milner

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

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

以下是一个示例

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+) (\p{L}+)/gu, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

这段代码获取一个字符串,查找所有数字后跟字母数字词的出现情况,并返回一个字符串,该字符串中每种数量少一个。

(\d+) 组最终成为函数的 amount 参数,而 (\p{L}+) 组绑定到 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 构造函数。

let name = "harry";
let regexp = new RegExp("(^|\\s)" + name + "($|\\s)", "gi");
console.log(regexp.test("Harry is a dodgy character."));
// → true

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

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

为了解决这个问题,可以在任何具有特殊含义的字符之前添加反斜杠。

let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)",
                        "gi");
let text = "This dea+hl[]rd guy is super annoying.";
console.log(regexp.test(text));
// → true

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 将被重置为 0,这也是它在新建的正则表达式对象中的值。

全局选项和粘性选项之间的区别在于,当启用粘性选项时,只有匹配从 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 的地方——通常是您想要使用它们的情况。

一个常见的操作是在字符串中查找所有正则表达式的匹配项。我们可以使用 matchAll 方法来实现。

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

此方法返回一个匹配数组的数组。传递给 matchAll 的正则表达式**必须**启用 g 标志。

解析 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;
  for (let line of string.split(/\r?\n/)) {
    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(...)) 利用了赋值表达式 (=) 的值是赋值值这一事实。你通常不确定对 match 的调用是否会成功,因此你只能在测试此操作的 if 语句中访问生成的 objects。为了不破坏令人愉快的 else if 形式链,我们将匹配的结果分配给一个绑定,并立即使用该赋值作为 if 语句的测试。

如果一行不是节标题或属性,则函数将使用表达式 /^\s*(;|$)/ 检查它是否是一个注释或空行,以匹配仅包含空格的行,或包含空格后跟分号的行(使行的其余部分成为注释)。当一行不匹配任何预期的形式时,函数将抛出一个异常。

代码单元和字符

JavaScript 正则表达式中另一个已被标准化的设计错误是,默认情况下,像 .? 这样的运算符作用于代码单元(如第 5 章中所述),而不是实际的字符。这意味着由两个代码单元组成的字符行为异常。

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

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

你必须在正则表达式中添加 u(Unicode)选项才能使其正确地处理此类字符。

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

摘要

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

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

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

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

正则表达式可以有选项,这些选项写在结束斜杠之后。i 选项使匹配不区分大小写。g 选项使表达式全局,这意味着,除其他事项外,它将导致 replace 方法替换所有实例,而不仅仅是第一个实例。y 选项使表达式粘性,这意味着它不会提前搜索并跳过字符串的一部分以查找匹配项。u 选项开启 Unicode 模式,它使 \p 语法生效,并解决了许多与处理占用两个代码单元的字符有关的问题。

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

练习

在进行这些练习的过程中,几乎不可避免地,你会对某个正则表达式的莫名其妙的行为感到困惑和沮丧。有时,将你的表达式输入到像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", "bedrøvet abe", "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."
显示提示…

最明显的解决方案是只替换两侧至少有一个非字母字符的引号——类似于 /\P{L}'|'\P{L}/u。但你也要考虑到行的开头和结尾。

此外,您必须确保替换还包括与\P{L}模式匹配的字符,以防止这些字符被删除。这可以通过将它们括在括号中并将它们的分组包含在替换字符串($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]