快速总结:尽管JavaScript的正则表达式过去与其他现代语言相比功能较弱,但近年来的众多改进意味着这种情况已不再存在。Steven Levithan评估了JavaScript中正则表达式的发展历程和当前状态,并提供了一些技巧,使您的正则表达式更易于阅读、维护和增强韧性。
与您可能熟悉的相比,现代JavaScript正则表达式已经取得了长足的进步。正则表达式可以成为搜索和替换文本的惊人工具,但它们长期以来(或许已经过时,正如我将展示的)有着难以编写和理解的声誉。
这在JavaScript领域尤其如此,正则表达式多年来相对功能较弱,与其在PCRE、Perl、.NET、Java、Ruby、C++和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
getter,支持RegExp
的子类化,以及在更改其标志的同时复制正则表达式的能力。 - 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的每个版本还增加了额外的Unicode属性,可以通过\p{...}
和\P{...}
使用。为了完整性,ES2021增加了字符串方法replaceAll
——尽管,当给定一个正则表达式时,与ES3的replace
唯一的区别是如果不使用标志g
它会抛出错误。
旁注:什么使正则表达式风格良好?
随着所有这些变化,JavaScript正则表达式现在与其他风格相比如何?有多种思考方式,但这里有一些关键方面:
- 性能。
这是一个重要的方面,但可能不是主要的,因为成熟的正则表达式实现通常都相当快。JavaScript在正则表达式性能方面很强(至少考虑到V8的Irregexp引擎,由Node.js、基于Chromium的浏览器和甚至Firefox使用;以及Safari使用的JavaScriptCore),但它使用了一个回溯引擎,缺少任何回溯控制的语法——这是一个主要限制,使得ReDoS漏洞更常见。 - 支持高级功能来处理常见或重要的用例。 在这里,JavaScript通过ES2018和ES2024加强了它的游戏。JavaScript现在在某些功能上是最好的,比如后视断言(支持无限长度)和Unicode属性(支持多字符的“字符串属性”,集合减法和交集,以及脚本扩展)。这些功能在许多其他风格中要么不支持,要么不够强大。
- 能够编写可读和可维护的模式。
在这里,原生JavaScript长期以来一直是主要风格中最差的,因为它缺少允许无关空白和注释的x
(“扩展”)标志。此外,它还缺少正则表达式子程序和子程序定义组(来自PCRE和Perl),这是一组强大的功能,可以编写语法化的正则表达式,通过组合构建复杂模式。
所以,这是有点混合的。
JavaScript正则表达式已经变得异常强大,但它们仍然缺少可以使正则表达式更安全、更可读、更可维护的关键功能(所有这些功能都让一些人望而却步)。
好消息是所有这些缺陷都可以通过JavaScript库来填补,我们将在本文后面看到。
使用JavaScript的现代正则表达式特性
让我们来看一些您可能不太熟悉的更有用现代正则表达式特性。您应该提前知道这是一个中等高级指南。如果您对正则表达式相对较新,这里有一些优秀的教程您可能想要开始:
- RegexLearn 和 RegexOne 是包括练习问题的交互式教程。
- JavaScript.info的正则表达式 章节是一份详细且特定于JavaScript的指南。
- Demystifying Regular Expressions (视频)是Lea Verou在2017年HolyJS上为初学者提供的一次出色的演讲。
- Learn Regular Expressions In 20 Minutes (视频)是在正则表达式测试器中进行的实时语法演示。
命名捕获
通常,您想做的不仅仅是检查正则表达式是否匹配——您想从匹配中提取子字符串并在代码中对它们进行一些操作。命名捕获组允许您以一种使您的正则表达式和代码更易于阅读和自文档化的方式做到这一点。
以下示例匹配一个包含两个日期字段的记录,并捕获值:
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>
重新匹配命名捕获组匹配的内容,并且您可以在搜索和替换中使用值,如下所示:
// 将 'FirstName LastName' 更改为 'LastName, FirstName'
const name = 'Shaquille Oatmeal';
name.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>');
// → 'Oatmeal, Shaquille'
对于想要在替换回调函数中使用命名后向引用的高级正则表达式用户,groups
对象作为最后一个参数提供。这里有一个花哨的例子:
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 ”:
const re = /(?<=fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'cat, fat pigeon, brat cat'
您也可以使用否定后视——写成(?<!...)
——来反转断言。这将使正则表达式匹配任何不是由“fat ”前面的“cat”实例。
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
循环中使用它。
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
或数组展开来获取其所有结果作为数组。
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中的字符串。例如:
// 匹配所有希腊字母,除了字母'π'
/[\p{Script_Extensions=Greek}--π]/v
// 只匹配希腊字母
/[\p{Script_Extensions=Greek}&&\p{Letter}]/v
有关标志v
的更多详情,包括它的其他新特性,请查看Google Chrome团队的this explainer。
关于匹配表情符号的说明
表情符号是🤩🔥😎👌,但是表情符号在文本中的编码很复杂。如果您尝试使用正则表达式匹配它们,了解一个单一的表情符号可能由一个或多个单独的Unicode代码点组成这一点非常重要。许多(和库!)自行实现表情符号正则表达式的人忽略了这一点(或者实现得很差),最终出现了错误。
以下是表情符号“👩🏻🏫”(浅色皮肤的女性教师)的详细信息,展示了表情符号可能有多复杂:
// 代码单元长度
'👩🏻🏫'.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-regex和emoji-regex-xs。
使您的正则表达式更易于阅读、维护和增强韧性
尽管多年来正则表达式的改进,足够复杂的原生JavaScript正则表达式仍然可能难以阅读和维护。
ES2018的命名捕获是一个很好的补充,使正则表达式更加自文档化,ES6的String.raw
标签允许您在使用RegExp
构造函数时避免转义所有的反斜杠。但就易读性而言,这差不多就是全部了。
然而,有一个轻量级且高性能的JavaScript库名为regex
(由我提供),它通过添加Perl-Compatible Regular Expressions (PCRE)的关键缺失特性,并输出原生JavaScript正则表达式,使正则表达式更容易阅读。您也可以将其用作Babel插件,这意味着regex
调用在构建时被转译,因此您获得了更好的开发体验,而用户不需要支付任何运行时成本。
PCRE是一个流行的C库,被PHP用于其正则表达式支持,并且在其他无数的编程语言和工具中可用。
让我们简要看看regex
库,它提供了一个名为regex
的模板标签,可以帮助您编写实际上可以理解和由凡人维护的复杂正则表达式。请注意,下面描述的所有新语法在PCRE中工作方式相同。
无关紧要的空白和注释
默认情况下,regex
允许您在正则表达式中自由添加空白和行注释(以#
开头),以提高可读性。
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地址:
import {regex} from 'regex';
const ipv4 = regex`\b
(?<byte> 25[0-5] | 2[0-4]\d | 1\d\d | [1-9]?\d)
# 匹配剩余的3个由点分隔的字节
(\. \g<byte>){3}
\b`;
您可以进一步通过子程序定义组仅通过引用定义子模式。下面是一个改进了我们在本文中早些时候看到的入院记录的正则表达式的例子:
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>...)
捕获使用唯一名称。然而,在您有多个正则表达式的替代路径的情况下,重用相同的组名称可以简化您的代码。
例如:
/(?<year>\d{4})-\d\d|\d\d-(?<year>\d{4})/
这个提案使这成为可能,通过这个例子防止了“捕获组名称重复”的错误。请注意,名称仍然必须在每个替代路径内是唯一的。
模式修饰符(也称为标志组)
这是另一个阶段3提案。它已经得到Chrome/Edge 125和Opera 111的支持,并且即将很快支持Firefox。Safari还没有消息。
模式修饰符使用(?ims:...)
,(?-ims:...)
或(?im-s:...)
将标志i
,m
和s
仅对正则表达式的某些部分打开或关闭。
例如:
/hello-(?i:world)/
// 匹配'hello-WORLD',但不匹配'HELLO-WORLD'
使用RegExp.escape
转义正则表达式特殊字符
这个提案最近达到了阶段3,并且已经期待已久。它还没有得到任何主流浏览器的支持。该提案正如其名,提供了函数RegExp.escape(str)
,它返回一个字符串,其中所有正则表达式特殊字符都被转义,以便您可以文字匹配它们。
如果您今天需要这个功能,最广泛使用的包(每月超过5亿npm下载量)是escape-string-regexp,这是一个超轻量级、单一用途的实用工具,它进行最小的转义。对于大多数情况来说这是很好的,但如果您需要确保您的转义字符串可以安全地在正则表达式的任何任意位置使用,escape-string-regexp
推荐我们已经在本文中看过的regex
库。regex
库使用插值以上下文感知的方式转义嵌入的字符串。
结论
所以这就是JavaScript正则表达式的过去、现在和未来。
如果您想更深入地探索正则表达式的土地,可以查看Awesome Regex,这是一个最好的正则表达式测试器、教程、库和其他资源的列表。并且对于一个有趣的正则表达式填字游戏,试试regexle。
愿您的解析繁荣昌盛,您的正则表达式易于阅读。