Regular expressions(Regex)是用于模式匹配和字符串解析的强大工具。尽管语法有时可能难以理解,但它可以使许多任务更加有效。在这篇文章中,我将演示称为捕获组的正则表达式的功能。
我们开始启动之前的一些笔记:
- JavaScript将用于本文中的示例。正则表达式的原理在其他语言中应相同,但某些用法细节可能有所不同。如果您不使用JavaScript,请参阅您的语言文档以获取细节。
- 关于软件的总体效率的任何讨论都必须考虑开发时间和维护以及代码的运行时性能。如果运行时性能是您的最终优先级,则正则表达式可能并不总是最好的选择,因为它们的性能可能会大大差异,具体取决于输入的特征,正则表达方式和正则表达式引擎。在开发时间和维护方面,正则表达式可以减少完成某些任务所需的代码的数量和复杂性。这通常是平衡的行为,您可以使用像jsbench.me这样的基准应用程序测试解决方案,以确保它们与您的优先级保持一致。我还建议为关键解析器编写单元测试,以防止在将来维护期间进行回归。
什么是捕获组?
a 捕获组是正则表达式中的一种模式,该模式将包括在调用RegExp.prototype.exec,String.prototype.match或String.prototype.matchAll的结果中。
让我们从一些基本模式匹配开始。假设我们想从/items/42
之类的路径名中解析包含一个或多个数字的子字符串。基本的正则表达式将是/\/\d+/g
。此模式将搜索一个正向斜线的所有匹配(g
或全局标志)(\/
-上一个后斜线是逃生),然后是一个或多个数字(\d+
)。如果要测试字符串包含该模式,则可以很好地工作:
const re = /\/\d+/g;
re.test('/items/42'); // true
re.test('/items/42/1'); // true
re.test('/items/42/options/1'); // true
re.test('/items/new'); // false
RegExp.prototype.test对于任何包含前向斜线的字符串,然后是一个或多个数字,而对于任何其他字符串,则返回true。现在,让我们尝试使用RegExp.prototype.exec获取有关比赛的一些信息:
re.exec('/items/42'); // [ '/42', index: 6, input: '/items/42' ]
re.exec('/items/42/1'); // [ '/1', index: 9, input: '/items/42/1' ]
re.exec('/items/42/options/1'); // [ '/1', index: 17, input: '/items/42/options/1' ]
re.exec('/items/new'); // null
RegExp.prototype.exec
返回数组,除非字符串不匹配正则态度,在这种情况下,它返回了null
。每个比赛的数组包含一个项目,即匹配的字符串。附加到数组的其他属性描述了字符串出现的index
和原始的input
字符串。 (您可以通过订阅或点表示法访问这些属性,就像任何对象的属性一样。)如果输入字符串包含多个出现模式/\/\d+/
(例如'/items/42/options/1'
)的情况,请注意,只有最后一次出现已匹配。这是由于在正则时期使用g
标志。如果您只关心第一场比赛,您可能会省略g
标志,但我们会在片刻之内受益。
现在让我们尝试String.prototype.match
:
'/items/42'.match(re); // [ '/42' ]
'/items/42/1'.match(re); // [ '/42', '/1' ]
'/items/42/options/1'.match(re); // [ '/42', '/1' ]
这些结果包括每个字符串中图案的每一次出现。如果我们没有在模式上使用g
标志,那么我们将获得与没有g
标志的RegExp.prototype.exec
相同的结果 - 仅包括第一匹匹配,并且该数组将具有index
和input
的其他属性。您可以使用String.prototype.matchAll
获得两种方法的最佳,该方法将返回每场比赛的输入和索引:
Array.from('/items/42/options/1'.matchAll(re));
// [
// [ '/42', index: 6, input: '/items/42/options/1' ],
// [ '/1', index: 9, input: '/items/42/options/1' ]
// ]
有重要的事情要注意:
- 我们正在使用
Array.from
将结果胁到阵列。String.prototype.matchAll
的返回值是一个iterator,但在许多情况下,使用数组更容易。 -
String.prototype.matchAll
需要全局正则义务,这意味着您必须包括g
标志。
,如果我们想稍微砍掉一点,我们实际上可以处理此结果。以下代码将通过用一个空字符串替换前向斜线来提取每个匹配的数字:
const matches = Array.from('/items/42/options/1'.matchAll(re));
// [
// [ '/42', index: 6, input: '/items/42/options/1' ],
// [ '/1', index: 9, input: '/items/42/options/1' ]
// ]
const params = matches.map((match) => match[0].replace('/', '')); // [ '42', '1' ]
这完成了我们提取数字的基本任务,但不是特别干净。幸运的是,有一个更好的方法。我们可以使用捕获组隔离数字。捕获组由您要捕获的图案的部分围绕括号表示。在这种情况下,\d+
指定的一个或多个数字:
const re = `/\/(\d+)/g`;
如果我们将String.prototype.matchAll
与此正则表达式一起使用,我们将获得每个匹配的第二个数组元素,其中包含我们捕获的数字而没有前向斜线(因为捕获组中未包含前向斜线)。我们可以通过访问每次匹配数组中的第二个元素来提取数字:
const matches = Array.from('/items/42/options/1'.matchAll(re));
// [
// [ '/42', '42', index: 6, input: '/items/42/options/1' ],
// [ '/1', '1', index: 9, input: '/items/42/options/1' ]
// ]
const params = matches.map((match) => match[1]); // [ '42', '1' ]
捕获组为您提供一种有效的方法来提取从较大字符串中匹配某些模式的子字符串。在许多情况下,这足以足够,但是有时结果可能会很尴尬。也许您需要将上一个示例中提取的值与属性名称相关联,其中第一个值应称为itemId
和第二个optionId
。这将需要更多代码来创建一个包含这些属性的对象。一种方法是将匹配项减少到一个对象中,其中每个值都由属性名称键入:
const matches = Array.from('/items/42/options/1'.matchAll(re));
const propertyNames = ['itemId', 'optionId'];
const properties = matches.reduce((obj, match, idx) => {
const name = propertyNames[idx];
const value = match[1];
return { ...obj, [name]: value };
}, {});
// { itemId: '42', optionId: '1' }
再次起作用,但不是很优雅。依靠代码将匹配项与相应的属性名称联系起来,在某些情况下可能会脆弱,具体取决于正在解析的特定正则和字符串。这是命名捕获组可能会有所帮助的地方。
什么是命名的捕获组?
a 命名捕获组与常规捕获组相似,但是该语法允许我们嵌入正则捕获组的每个捕获组的名称。具有id
和Pattern \d+
名称的命名捕获组的语法将为(?<id>\d+)
。该名称放置在角括号内,然后是问号,然后是图案。如果我们将其应用于上一个示例,则可以在每场比赛的groups
属性中获取命名值:
const re = `/\/(?<id>\d+)/g`;
const matches = Array.from('/items/42/options/1'.matchAll(re));
// [
// [ '/42', '42', index: 6, input: '/items/42/options/1', groups: { id: '42' } ],
// [ '/1', '1', index: 9, input: '/items/42/options/1', groups: { id: '1' } ]
// ]
const params = matches.map((match) => match.groups.id); // [ '42', '1' ]
现在,您可能已经注意到,这并不能解决我们需要额外代码以将值解析为属性名称的问题,因为这些值仍在单独的匹配中分布,并且都具有相同的名称。仍然没有直接路由将它们转换为我们想要的对象,即{ itemId: '42', optionId: '1' }
。我们可以通过重新设计正则表达来改变这一点。下面的示例使用了一个更具体的正则表达式,描述了我们期望匹配的完整路径,包括itemId
和optionId
的命名捕获组:
const re = /^\/items\/(?<itemId>\d+)\/options\/(?<optionId>\d+)$/g;
const matches = Array.from('/items/42/options/1'.matchAll(re));
// [
// [
// '/items/42/options/1',
// '42',
// '1',
// index: 0,
// input: '/items/42/options/1',
// groups: { itemId: '42', optionId: '1' }
// ]
// ]
我们只需访问第一个匹配的groups
属性即可获得命名值:
const [ match ] = Array.from('/items/42/options/1'.matchAll(re));
if (match) {
const { groups } = match;
// Work with the groups
}
(可以肯定地假设我们可以参加第一匹匹配,因为将正则施加在使用^
和$
的字符串的开始和结尾。)
在这种情况下使用命名捕获组的好处是,可以在解析时同时将名称分配给值。
选择自己的冒险
在上一个示例中,我们获得了一个镜头中从字符串命名值的命名值的能力,除了执行正则表达式并访问结果所需的代码外,没有其他代码。由于需要为每个值分配一个唯一的名称,因此我们还失去了使用我们开始使用的通用正则表达式的能力。实际上,这两种方法都适合所有情况。未命名的捕获组使您能够使用通用的正则表达式模式,因为您不必担心为每个模式提供一个唯一的名称,但是穿越结果可能会混乱且易用。命名的捕获组可以使解析更加干净,但前提是您必须分析的字符串具有一致的结构。包含命名捕获组的正则表达式也可能更难理解。那你应该选择哪个?提出一系列问题可能会有所帮助:
- 命名的捕获组会带来真正的好处吗?
- 如果是,请转到2。
- 如果不,请转到3。
- 您需要解析的字符串是否具有足够一致的结构以使命名捕获组可行?
- 如果是,请转到4。
- 如果不,请转到5。
- 使用未命名的捕获组的正则表达式通常更简单,更易于理解,因此即使命名捕获组在您的情况下可以使用,也最好使用它们。评估这两个选项。
- 听起来您可能已经找到了一个命名捕获组的好用例!转到6。
- 一致性问题是否有合理的解决方法?例如,您能否设计一个涵盖所有输入案例的正则表达式? (没有为自己和他人创造噩梦吗?)
- 如果是,请转到6。
- 如果不,请转到7。
- 是时候进行实验!比较定期捕获组和您想到的任何其他方法,然后选择哪种方法最好。
- 指定的捕获组可能不可能解决问题,因此请查看未命名的捕获组是否可以完成工作,或者您是否需要找到其他解决方案。
组合和嵌套捕获组
捕获组可以组合和嵌套。考虑以下正则表达式:
const re = /^\/items\/(?<itemId>\d+)(\/options\/(?<optionId>\d+))?$/g
这类似于我们以前的正则言论,但是围绕/options
subpath:(\/options\/(?<optionId>\d+))?
添加了一个捕获组。闭合括号后的问号使捕获组可选,这意味着该正则符合或不带有/options
子路径的路径匹配。 (例如/items/42/options/1
或/items/42
。)
如果我们使用此正则态度匹配完整路径,我们将获得以下结果:
const [ match ] = Array.from('/items/42/options/1'.matchAll(re));
// [
// '/items/42/options/1',
// '42',
// '/options/1',
// '1',
// index: 0,
// input: '/items/42/options/1',
// groups: { itemId: '42', optionId: '1' }
// ]
数组包含四个元素:
- 完整图案的匹配(
'/items/42/options/1'
) -
itemId
的匹配项名为Capture Group('42'
) - 围绕
/options
Subpath('/options/1'
)的未命名捕获组的匹配 -
optionId
的匹配项命名为Capture Group('1'
)
groups
属性包含从路径解析的itemId
和optionId
值。
现在让我们匹配没有/options
子路径的路径:
const [ match ] = Array.from('/items/42'.matchAll(re));
// [
// '/items/42',
// '42',
// undefined,
// undefined,
// index: 0,
// input: '/items/42',
// groups: { itemId: '42', optionId: undefined }
// ]
再次,该数组包含完整图案匹配的四个元素,itemId
,/options
Subpath和optionId
,但是/options
Subpath和optionId
都是undefined
,因为它们是可选的,并且不存在于输入中。 groups
属性包含itemId
和optionId
值,但是optionId
类似于undefined
。
我们还可以在/options
subpath周围使用命名的捕获组将其包括在组中:
const re = /^\/items\/(?<itemId>\d+)(?<subpath>\/options\/(?<optionId>\d+))?$/g
const [ match ] = Array.from('/items/42/options/1'.matchAll(re));
// [
// '/items/42/options/1',
// '42',
// '/options/1',
// '1',
// index: 0,
// input: '/items/42/options/1',
// groups: { itemId: '42', subpath: '/options/1', optionId: '1' }
// ]
const [ match ] = Array.from('/items/42'.matchAll(re));
// [
// '/items/42',
// '42',
// undefined,
// undefined,
// index: 0,
// input: '/items/42',
// groups: { itemId: '42', subpath: undefined, optionId: undefined }
// ]
再次,subpath
组是第二个示例中的undefined
,因为它在输入中不存在。
这些示例中的嵌套捕获组使正则表达式能够匹配路径上的变化,并解析itemId
和optionId
是否存在/options
subpath,仅在几行代码中。尝试将这些技术应用于简单的字符串解析任务,以便您可以感觉到它们的工作方式。
概括
在这篇文章中,我们了解了捕获组如何使许多字符串解析任务更容易,但是请记住,正则表达式不是正确的解决方案。通过用代码操纵字符串,可以完全满足一些可能会尴尬或缓慢的用例。其他人可能会要求像pegjs这样的更强大的解决方案。与往常一样,请尝试使用您可以使用的所有工具,并查看最适合您的应用程序的工具。