正则表达式变得更好:JavaScript 中正则表达式的历史与未来


现代 JavaScript 正则表达式与您可能熟悉的相比,已经取得了长足的进步。正则表达式可以成为搜索和替换文本的强大工具,但它们一直以来(也许这种看法已经过时了,我将在文中展示)都因难以编写和理解而声名狼藉。

这种情况在 JavaScript 中尤为突出,在许多年里,JavaScript 中的正则表达式功能相对较弱,远不及 PCREPerl.NETJavaRubyC++ 和 Python 中的更现代的正则表达式。然而,这些日子已经结束了。

在本文中,我将回顾 JavaScript 正则表达式的改进历程(剧透:ES2018 和 ES2024 是关键节点),展示现代正则表达式功能的实际应用示例,向您介绍一个轻量级的 JavaScript 库,它使 JavaScript 在正则表达式方面能够媲美甚至超越其他现代正则表达式的实现,最后还将预览一些正在积极讨论的提案,这些提案将在未来的 JavaScript 版本中继续改进正则表达式(其中一些功能已经可以在您的浏览器中使用了)。

JavaScript 中正则表达式的历史

1999 年标准化的 ECMAScript 3 将受 Perl 启发的正则表达式引入了 JavaScript 语言。尽管它在很多方面做得足够好,使得正则表达式相当有用(并且大多与其他 Perl 风格的正则表达式兼容),但即使在当时也存在一些重大遗漏。而且在 JavaScript 等待其下一个标准化版本 ES5 的 10 年中,其他编程语言和正则表达式实现增加了许多有用的新功能,使得它们的正则表达式更强大、更易读。

但那是过去的事了。

您知道几乎每一个新的 JavaScript 版本都对正则表达式进行了至少小幅度的改进吗?让我们来看看这些改进。

如果有些功能难以理解,不必担心——我们稍后会更详细地讨论其中几个关键功能。

  • **ES5 (2009)**:通过在每次评估正则表达式字面量时创建一个新对象,修复了不直观的行为,并允许在字符类中使用未转义的正斜杠(/[/]/)。
  • ES6/ES2015:增加了两个新的正则表达式标志:y(粘滞),使得在解析器中使用正则表达式更容易,以及 u(unicode),它增加了几个与 Unicode 相关的重大改进并引入了严格错误。还增加了 RegExp.prototype.flags 获取器、正则表达式子类化支持以及在更改标志时复制正则表达式的功能。
  • ES2018:这个版本终于使 JavaScript 的正则表达式变得相当好用了。它增加了 s(dotAll)标志、后行断言、命名捕获组和 Unicode 属性(通过 \p{...}\P{...},需要 ES6 的 u 标志)。所有这些都是非常有用的功能,我们会进一步探讨。
  • ES2020:增加了字符串方法 matchAll,我们稍后也会进一步介绍。
  • ES2022:增加了 d 标志(hasIndices),提供了匹配子字符串的起始和结束索引。
  • ES2024:增加了 v 标志(unicodeSets),这是对 ES6 中 u 标志的升级。v 标志为 \p{...} 添加了一组多字符的“字符串属性”,通过 \p{...}\q{...} 实现字符类中的多字符元素,嵌套字符类,集合减法 [A--B] 和交集 [A&&B],以及字符类中的不同转义规则。它还修复了 Unicode 属性在否定集合 [^...] 中的不区分大小写匹配。

至于这些功能是否可以在您的代码中安全使用,答案是肯定的!最新的功能 v 标志在 Node.js 20 和 2023 年代的浏览器中受支持。其余功能在 2021 年代或更早的浏览器中都已受支持。
从 ES2019 到 ES2023 的每个版本还增加了可以通过 \p{...}\P{...} 使用的其他 Unicode 属性。为了尽善尽美,ES2021 还增加了字符串方法 replaceAll——尽管在使用正则表达式时,与 ES3 的 replace 唯一不同的是,如果不使用 g 标志,它会抛出错误。

旁注:是什么让一种正则表达式实现变得优秀?

在经历了这些变化之后,JavaScript 的正则表达式现在与其他实现相比如何呢?有多种方式可以考虑这个问题,但以下是几个关键方面:

  1. 性能
    这是一个重要的方面,但可能不是最主要的,因为成熟的正则表达式实现通常都很快。JavaScript 在正则表达式性能上表现强劲(至少考虑到 V8 的 Irregexp 引擎,该引擎被 Node.js、基于 Chromium 的浏览器,甚至 Firefox 使用;以及 Safari 使用的 JavaScriptCore 引擎)。但它使用的是一种缺乏回溯控制语法的回溯引擎——这是一个重大限制,使得 ReDoS(正则表达式拒绝服务)漏洞更加常见。

  2. 对处理常见或重要用例的高级功能的支持
    在这方面,JavaScript 在 ES2018 和 ES2024 中提升了自己的水平。JavaScript 在某些功能上现在已经处于最佳水平,例如后行断言(支持无限长度)和 Unicode 属性(支持多字符的“字符串属性”、集合减法和交集、以及脚本扩展)。这些功能在许多其他实现中要么不受支持,要么不如 JavaScript 中的功能强大。

  3. 编写可读且易于维护的模式的能力
    在这方面,原生 JavaScript 一直是主要实现中最糟糕的,因为它缺少 x(“扩展”)标志,无法使用不重要的空白符和注释。此外,它缺少正则表达式子程序和子程序定义组(来自 PCRE 和 Perl),这是一组强大的功能,可以通过组合构建复杂模式,从而编写出符合语法的正则表达式。

因此,JavaScript 正则表达式的表现有些参差不齐。

JavaScript 正则表达式已经变得非常强大,但仍然缺少一些关键功能,这些功能可以使正则表达式更安全、更可读和更易于维护(所有这些都使得某些人对使用这些强大功能心存顾虑)。

好消息是,这些缺陷都可以通过一个 JavaScript 库来弥补,本文稍后将介绍这个库。
使用 JavaScript 的现代正则表达式功能

让我们来看看一些您可能不太熟悉的、更有用的现代正则表达式功能。请注意,这是一篇中高级指南。如果您对正则表达式比较陌生,可以先参考以下几个优秀的教程:

命名抓取

通常,您不仅仅是检查一个正则表达式是否匹配——您还希望从匹配中提取子字符串,并在代码中对它们进行处理。命名捕获组允许您以一种让正则表达式和代码更具可读性和自我文档化的方式来执行此操作。

以下示例匹配一个包含两个日期字段的记录,并捕获这些日期值:

1
2
3
4
5
6
7
8
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = /^Admitted: (?<admitted>\d{4}-\d{2}-\d{2})\nReleased: (?<released>\d{4}-\d{2}-\d{2})$/;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */

不用担心——尽管这个正则表达式可能看起来难以理解,但稍后我们会介绍一种让它更具可读性的方法。这里的关键是,命名捕获组使用语法 (?<name>...),并且其结果存储在匹配项的 groups 对象中。

您还可以使用命名反向引用通过 \k<name> 来重新匹配命名捕获组匹配的内容,并可以在搜索和替换中使用这些值,如下所示:

1
2
3
4
// 将 'FirstName LastName' 更改为 'LastName, FirstName'
const name = 'Shaquille Oatmeal';
name.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>');
// → 'Oatmeal, Shaquille'

对于希望在替换回调函数中使用命名反向引用的高级正则表达式用户,groups 对象作为最后一个参数提供。以下是一个高级示例:

1
2
3
4
5
6
7
8
9
10
11
function fahrenheitToCelsius(str) {
const re = /(?<degrees>-?\d+(\.\d+)?)F\b/g;
return str.replace(re, (...args) => {
const groups = args.at(-1);
return Math.round((groups.degrees - 32) * 5/9) + 'C';
});
}
fahrenheitToCelsius('98.6F');
// → '37C'
fahrenheitToCelsius('May 9 high is 40F and low is 21F');
// → 'May 9 high is 4C and low is -6C'

后行断言

后行断言(在 ES2018 中引入)是前行断言的补充,前行断言一直受到 JavaScript 正则表达式的支持。前行断言和后行断言都是断言(类似于字符串开头的 ^ 或单词边界的 \b),它们不会消耗匹配中的任何字符。后行断言根据其子模式是否可以在当前匹配位置之前立即找到来决定成功或失败。

例如,以下正则表达式使用后行断言 (?<=...) 来匹配单词 “cat”(仅匹配单词 “cat”),前提是它前面有 “fat ”:

1
2
3
const re = /(?<=fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'cat, fat pigeon, brat cat'

您还可以使用负后行断言,写作 (?<!...),以反转断言。这将使正则表达式匹配任何前面没有 “fat ” 的 “cat”:

1
2
3
const re = /(?<!fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'pigeon, fat cat, brat pigeon'

JavaScript 的后行断言实现是最好的之一(仅次于 .NET)。虽然其他正则表达式实现对于何时以及是否允许在后行断言中使用变长模式有不一致和复杂的规则,但 JavaScript 允许您为任何子模式使用后行断言。

matchAll 方法

JavaScript 的 String.prototype.matchAll 在 ES2020 中添加,使得当您需要扩展匹配详情时,在循环中操作正则表达式匹配变得更加容易。虽然之前有其他解决方案,但 matchAll 通常更容易使用,并且避免了诸如在循环中保护正则表达式不返回零长度匹配时可能出现的无限循环等问题。

由于 matchAll 返回一个迭代器(而不是数组),因此可以轻松地在 for...of 循环中使用它。

1
2
3
4
5
6
const re = /(?<char1>\w)(?<char2>\w)/g;
for (const match of str.matchAll(re)) {
const {char1, char2} = match.groups;
// 打印每个完整匹配项和匹配的子模式
console.log(`Matched "${match[0]}" with "${char1}" and "${char2}"`);
}

注意:matchAll 需要其正则表达式使用 g(全局)标志。此外,与其他迭代器一样,您可以使用 Array.from 或数组展开将所有结果作为数组获取。

1
const matches = [...str.matchAll(/./g)];

Unicode 属性

Unicode 属性(在 ES2018 中添加)使您能够使用 \p{...} 及其否定版本 \P{...} 对多语言文本进行强大的控制。您可以匹配数百种不同的属性,这些属性涵盖了各种 Unicode 类别、脚本、脚本扩展和二进制属性。

注意:有关更多详细信息,请查看 MDN 文档

使用 Unicode 属性需要使用 u(unicode)或 v(unicodeSets)标志。

v 标志

v 标志(unicodeSets)在 ES2024 中添加,是对 u 标志的升级——您不能同时使用这两个标志。为了避免通过默认的非 Unicode 感知模式默默引入错误,最好的做法是始终使用这些标志之一。决定使用哪个标志相对简单。如果您只支持具有 v 标志的环境(Node.js 20 和 2023 年代的浏览器),则使用 v 标志;否则,使用 u 标志。

v 标志添加了对多个新正则表达式功能的支持,其中最酷的可能是集合减法和交集。这允许在字符类中使用 A--B 来匹配 A 中但不在 B 中的字符串,或使用 A&&B 来匹配同时在 A 和 B 中的字符串。例如:

1
2
3
4
5
// 匹配除字母 'π' 外的所有希腊符号
/[\p{Script_Extensions=Greek}--π]/v

// 仅匹配希腊字母
/[\p{Script_Extensions=Greek}&&\p{Letter}]/v

有关 v 标志的更多详细信息,包括其其他新功能,请查看 Google Chrome 团队的解释文档

关于匹配表情符号的一句话

表情符号很棒(🤩🔥😎👌),但表情符号在文本中的编码方式非常复杂。如果您尝试使用正则表达式匹配它们,需要意识到一个单独的表情符号可以由一个或多个单独的 Unicode 代码点组成。许多人(和库)在编写自己的表情符号正则表达式时忽略了这一点(或者实现得不好),最终导致了错误。

以下是表情符号 “👩🏻🏫”(女教师:浅肤色)的详细信息,展示了表情符号的复杂性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 代码单元长度
'👩🏻🏫'.length;
// → 7
// 每个天体代码点(高于 \uFFFF)被分为高代理和低代理

// 代码点长度
[...'👩🏻🏫'].length;
// → 4
// 这四个代码点是:\u{1F469} \u{1F3FB} \u{200D} \u{1F3EB}
// \u{1F469} 与 \u{1F3FB} 结合是 '👩🏻'
// \u{200D} 是一个零宽连接符
// \u{1F3EB} 是 '🏫'

// 字素簇长度(用户感知字符)
[...new Intl.Segmenter().segment('👩🏻🏫')].length;
// → 1

幸运的是,JavaScript 添加了一种简单的方法,通过 \p{RGI_Emoji} 来匹配任何单个、完整的表情符号。由于这是一个可以一次匹配多个代码点的高级“字符串属性”,因此需要 ES2024 的 v 标志。

如果您想在不支持 v 标志的环境中匹配表情符号,请查看优秀的库 emoji-regexemoji-regex-xs

让你的正则表达式更易读、易维护且更具弹性

尽管多年来正则表达式的功能有所改进,但足够复杂的原生 JavaScript 正则表达式仍然非常难以阅读和维护。

ES2018 的命名捕获是一个很好的补充,使正则表达式更加自解释,ES6 的 String.raw 标记可以在使用 RegExp 构造函数时避免转义所有反斜杠。但就可读性而言,这些基本上就是全部了。

然而,有一个轻量且高性能的 JavaScript 库 regex,使得正则表达式的可读性显著提升。它通过添加 Perl-Compatible Regular Expressions (PCRE) 中的关键缺失功能来输出原生 JavaScript 正则表达式。您还可以将其作为 Babel 插件使用,这意味着在构建时正则表达式调用将被转译,从而在不增加运行时开销的情况下为开发者提供更好的体验。

PCRE 是一个流行的 C 库,PHP 用它来支持正则表达式,它还在无数其他编程语言和工具中广泛应用。

让我们简要看看 regex 库(提供一个名为 regex 的模板标记)如何帮助您编写复杂但实际可理解和可维护的正则表达式。注意,下面描述的所有新语法在 PCRE 中都是相同的。

无意义的空白和注释

默认情况下,regex 允许您自由地在正则表达式中添加空白和行注释(以 # 开头)以提高可读性。

1
2
3
4
5
6
7
import {regex} from 'regex';
const date = regex`
# 匹配 YYYY-MM-DD 格式的日期
(?<year> \d{4}) - # 年份部分
(?<month> \d{2}) - # 月份部分
(?<day> \d{2}) # 日期部分
`;

这相当于使用 PCRE 的 xx 标志。

子例程和子例程定义组

子例程写作 \g<name>(其中 name 指的是命名组),它将引用的组视为独立的子模式,尝试在当前位置进行匹配。这使得子模式的组合和重用成为可能,从而提高了可读性和可维护性。

例如,以下正则表达式匹配像 “192.168.12.123” 这样的 IPv4 地址:

1
2
3
4
5
6
import {regex} from 'regex';
const ipv4 = regex`\b
(?<byte> 25[0-5] | 2[0-4]\d | 1\d\d | [1-9]?\d)
# 匹配剩余的三个以点分隔的字节
(\. \g<byte>){3}
\b`;

您可以通过仅通过子例程定义组定义子模式以供引用进一步优化这个表达式。以下示例改进了本文前面看到的入院记录正则表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = regex`
^ Admitted:\ (?<admitted> \g<date>) \n
Released:\ (?<released> \g<date>) $

(?(DEFINE)
(?<date> \g<year>-\g<month>-\g<day>)
(?<year> \d{4})
(?<month> \d{2})
(?<day> \d{2})
)
`;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */

现代正则表达式基础

regex 默认包含 v 标志,因此您不会忘记将其打开。而在没有原生 v 标志的环境中,它会自动切换到 u 标志,同时应用 v 的转义规则,使您的正则表达式前后兼容。

它还默认启用了模拟的 x(无意义的空白和注释)和 n(“仅命名捕获”模式)标志,因此您不必反复选择它们的高级模式。而且由于它是一个原始字符串模板标记,您不需要像使用 RegExp 构造函数那样转义反斜杠 \\\\

原子组和贪婪量词可以防止灾难性回溯

原子组和贪婪量词是 regex 库添加的另一组强大功能。虽然它们主要是关于性能和防止灾难性回溯(也称为 ReDoS 或“正则表达式拒绝服务”,一种严重的问题,其中某些正则表达式在搜索特定的、几乎不匹配的字符串时可能会永远运行),但它们还可以通过允许编写更简单的模式来帮助提高可读性。

注意:您可以在 regex 文档中了解更多信息。

接下来是什么?即将推出的 JavaScript 正则表达式改进

目前,有多项关于改进 JavaScript 正则表达式的提案正在积极推进中。以下是三个即将纳入未来语言版本的提案。

重复命名捕获组

这是一个处于第 3 阶段(即将完成)的提案。更好的是,最近它已在所有主流浏览器中生效。

当命名捕获组首次引入时,要求所有 (?<name>...) 捕获组使用唯一的名称。然而,有时在正则表达式中有多个备选路径,若能在每个路径中重用相同的组名,会使代码更简洁。

例如:

1
/(?<year>\d{4})-\d\d|\d\d-(?<year>\d{4})/

此提案正是为了实现这一点,防止像上面的例子出现“重复捕获组名称”的错误。请注意,名称在每个备选路径中仍必须唯一。

模式修饰符(也称为标志组)

这是另一个处于第 3 阶段的提案。它已在 Chrome/Edge 125 和 Opera 111 中得到支持,并且很快会在 Firefox 中推出。Safari 方面尚未有消息。

模式修饰符使用 (?ims:...)(?-ims:...)(?im-s:...) 来打开或关闭正则表达式中某部分的 ims 标志。

例如:

1
2
/hello-(?i:world)/
// 匹配 'hello-WORLD' 但不匹配 'HELLO-WORLD'

使用 RegExp.escape 转义正则表达式特殊字符

这个提案最近也达到了第 3 阶段,并已酝酿了很长时间。它尚未在任何主流浏览器中得到支持。该提案实现了一个名为 RegExp.escape(str) 的函数,该函数返回一个已将所有正则表达式特殊字符转义的字符串,以便可以按字面意义进行匹配。

如果您今天就需要这个功能,最广泛使用的包是 escape-string-regexp,它是一个超轻量级的单一用途工具,每月有超过 5 亿次 npm 下载。它执行了最小化的转义,适用于大多数情况。但如果您需要确保转义后的字符串能够安全地用于正则表达式中的任意位置,escape-string-regexp 推荐了我们在本文中已经提到的 regex 库。该库通过插值的方式在上下文中安全地转义嵌入的字符串。

结语

这就是 JavaScript 正则表达式的过去、现在和未来。

如果您想深入探究正则表达式的世界,可以查看 Awesome Regex 列表,它包含了最佳的正则表达式测试工具、教程、库和其他资源。还可以尝试一下有趣的正则表达式填字游戏 regexle

愿您的解析顺利,愿您的正则表达式清晰可读。