2024 年 1 月 27 日

捕获组

模式的一部分可以放在括号 (...) 中。这被称为“捕获组”。

这有两个效果

  1. 它允许将匹配的一部分作为结果数组中的单独项获取。
  2. 如果我们在括号后放置一个量词,它将应用于整个括号。

示例

让我们看看括号在示例中的工作方式。

示例:gogogo

没有括号,模式 go+ 表示 g 字符,后跟 o 重复一次或多次。例如,goooogooooooooo

括号将字符分组在一起,因此 (go)+ 表示 gogogogogogo 等等。

alert( 'Gogogo now!'.match(/(go)+/ig) ); // "Gogogo"

示例:域名

让我们做一些更复杂的事情 - 一个用于搜索网站域名的正则表达式。

例如

mail.com
users.mail.com
smith.users.mail.com

正如我们所见,域名由重复的单词组成,每个单词后都有一个点,最后一个单词除外。

在正则表达式中,它是 (\w+\.)+\w+

let regexp = /(\w+\.)+\w+/g;

alert( "site.com my.site.com".match(regexp) ); // site.com,my.site.com

搜索有效,但模式无法匹配包含连字符的域名,例如 my-site.com,因为连字符不属于类 \w

我们可以通过将 \w 替换为 [\w-] 来解决此问题,除了最后一个单词之外,所有单词都替换:([\w-]+\.)+\w+

示例:电子邮件

前面的示例可以扩展。我们可以根据它创建电子邮件的正则表达式。

电子邮件格式为:name@domain。任何单词都可以作为名称,允许使用连字符和点。在正则表达式中,它是 [-.\w]+

模式

let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;

alert("[email protected] @ [email protected]".match(regexp)); // [email protected], [email protected]

该正则表达式并不完美,但大部分情况下有效,有助于修复意外的错误输入。唯一真正可靠的电子邮件检查方法只能通过发送邮件来完成。

匹配中的括号内容

括号从左到右编号。搜索引擎会记住每个括号匹配的内容,并允许在结果中获取它。

方法 str.match(regexp),如果 regexp 没有标志 g,则查找第一个匹配项并将其作为数组返回

  1. 在索引 0 处:完全匹配。
  2. 在索引 1 处:第一个括号的内容。
  3. 在索引 2 处:第二个括号的内容。
  4. …等等…

例如,我们想找到 HTML 标签 <.*?>,并对其进行处理。将标签内容(角度之间的内容)放在单独的变量中会很方便。

让我们将内部内容用括号括起来,如下所示:<(.*?)>

现在,我们将在结果数组中获得整个标签 <h1> 及其内容 h1

let str = '<h1>Hello, world!</h1>';

let tag = str.match(/<(.*?)>/);

alert( tag[0] ); // <h1>
alert( tag[1] ); // h1

嵌套组

括号可以嵌套。在这种情况下,编号也是从左到右进行的。

例如,在 <span class="my"> 中搜索标签时,我们可能对以下内容感兴趣

  1. 整个标签内容:span class="my"
  2. 标签名称:span
  3. 标签属性:class="my"

让我们为它们添加括号:<(([a-z]+)\s*([^>]*))>

以下是它们的编号方式(从左到右,按开括号)

实际应用

let str = '<span class="my">';

let regexp = /<(([a-z]+)\s*([^>]*))>/;

let result = str.match(regexp);
alert(result[0]); // <span class="my">
alert(result[1]); // span class="my"
alert(result[2]); // span
alert(result[3]); // class="my"

result 的零索引始终包含完全匹配。

然后是组,按开括号从左到右编号。第一个组作为 result[1] 返回。这里它包含整个标签内容。

然后在result[2]中存放的是第二个开括号([a-z]+)中的组 - 标签名,然后在result[3]中存放的是标签:([^>]*)

字符串中每个组的内容

可选组

即使一个组是可选的,并且在匹配中不存在(例如,具有量词(...)?),相应的result数组项仍然存在,并且等于undefined

例如,让我们考虑正则表达式a(z)?(c)?。它查找"a",后面可选地跟着"z",后面可选地跟着"c"

如果我们在只有一个字母a的字符串上运行它,那么结果是

let match = 'a'.match(/a(z)?(c)?/);

alert( match.length ); // 3
alert( match[0] ); // a (whole match)
alert( match[1] ); // undefined
alert( match[2] ); // undefined

该数组的长度为3,但所有组都是空的。

下面是字符串ac的更复杂的匹配

let match = 'ac'.match(/a(z)?(c)?/)

alert( match.length ); // 3
alert( match[0] ); // ac (whole match)
alert( match[1] ); // undefined, because there's nothing for (z)?
alert( match[2] ); // c

数组长度是固定的:3。但是组(z)?没有内容,所以结果是["ac", undefined, "c"]

使用组搜索所有匹配项:matchAll

matchAll是一个新方法,可能需要 polyfill

matchAll 方法在旧浏览器中不受支持。

可能需要 polyfill,例如 https://github.com/ljharb/String.prototype.matchAll

当我们搜索所有匹配项(标志g)时,match方法不会返回组的内容。

例如,让我们在一个字符串中查找所有标签

let str = '<h1> <h2>';

let tags = str.match(/<(.*?)>/g);

alert( tags ); // <h1>,<h2>

结果是一个匹配项数组,但没有关于每个匹配项的详细信息。但在实践中,我们通常需要结果中捕获组的内容。

要获取它们,我们应该使用str.matchAll(regexp)方法进行搜索。

它是在match之后很久才添加到 JavaScript 语言中的,是它的“新改进版本”。

match一样,它查找匹配项,但有 3 个区别

  1. 它返回的不是数组,而是一个可迭代对象。
  2. 当存在标志g时,它将每个匹配项作为包含组的数组返回。
  3. 如果没有匹配项,它返回的不是null,而是一个空的可迭代对象。

例如

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

// results - is not an array, but an iterable object
alert(results); // [object RegExp String Iterator]

alert(results[0]); // undefined (*)

results = Array.from(results); // let's turn it into array

alert(results[0]); // <h1>,h1 (1st tag)
alert(results[1]); // <h2>,h2 (2nd tag)

正如我们所见,第一个区别非常重要,如(*)行所示。我们无法将匹配项作为results[0]获取,因为该对象是一个伪数组。我们可以使用Array.from将其转换为真正的Array。有关伪数组和可迭代对象的更多详细信息,请参阅文章 可迭代对象

如果我们循环遍历结果,则不需要Array.from

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

for(let result of results) {
  alert(result);
  // first alert: <h1>,h1
  // second: <h2>,h2
}

…或者使用解构

let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

matchAll返回的每个匹配项都具有与match(不带标志g)返回的相同格式:它是一个数组,具有附加属性index(字符串中的匹配项索引)和input(源字符串)

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

let [tag1, tag2] = results;

alert( tag1[0] ); // <h1>
alert( tag1[1] ); // h1
alert( tag1.index ); // 0
alert( tag1.input ); // <h1> <h2>
为什么matchAll的结果是可迭代对象而不是数组?

为什么该方法的设计如此?原因很简单——为了优化。

调用matchAll不会执行搜索。相反,它返回一个可迭代对象,最初没有结果。每次我们迭代它时,例如在循环中,都会执行搜索。

因此,将找到所需的结果,而不是更多。

例如,文本中可能存在 100 个匹配项,但在 for..of 循环中我们只找到了 5 个,然后决定足够了并执行了 break。然后引擎就不会浪费时间寻找其他 95 个匹配项。

命名组

记住组的编号很困难。对于简单的模式来说,这是可行的,但对于更复杂的模式来说,计算括号很不方便。我们有一个更好的选择:给括号命名。

这可以通过在开括号后立即添加 ?<name> 来实现。

例如,让我们查找格式为“年-月-日”的日期

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2019-04-30";

let groups = str.match(dateRegexp).groups;

alert(groups.year); // 2019
alert(groups.month); // 04
alert(groups.day); // 30

如您所见,组位于匹配的 .groups 属性中。

要查找所有日期,我们可以添加标志 g

我们还需要 matchAll 来获取完整的匹配项,以及组

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30 2020-01-01";

let results = str.matchAll(dateRegexp);

for(let result of results) {
  let {year, month, day} = result.groups;

  alert(`${day}.${month}.${year}`);
  // first alert: 30.10.2019
  // second: 01.01.2020
}

在替换中捕获组

方法 str.replace(regexp, replacement)regexpstr 中替换所有匹配项,允许在 replacement 字符串中使用括号内容。这可以通过 $n 来实现,其中 n 是组号。

例如,

let str = "John Bull";
let regexp = /(\w+) (\w+)/;

alert( str.replace(regexp, '$2, $1') ); // Bull, John

对于命名括号,引用将是 $<name>

例如,让我们将日期从“年-月-日”格式化为“日.月.年”

let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30, 2020-01-01";

alert( str.replace(regexp, '$<day>.$<month>.$<year>') );
// 30.10.2019, 01.01.2020

使用 ? 的非捕获组

有时我们需要括号来正确应用量词,但我们不希望它们的内容出现在结果中。

可以通过在开头添加 ?: 来排除一个组。

例如,如果我们想找到 (go)+,但不想将括号内容 (go) 作为单独的数组项,我们可以写:(?:go)+

在下面的示例中,我们只获得了名称 John 作为匹配项的单独成员

let str = "Gogogo John!";

// ?: excludes 'go' from capturing
let regexp = /(?:go)+ (\w+)/i;

let result = str.match(regexp);

alert( result[0] ); // Gogogo John (full match)
alert( result[1] ); // John
alert( result.length ); // 2 (no more items in the array)

总结

括号将正则表达式的部分内容分组在一起,以便量词可以将其作为一个整体应用。

括号组从左到右编号,并且可以选择使用 (?<name>...) 命名。

组匹配的内容可以在结果中获得

  • 方法 str.match 仅在没有标志 g 的情况下返回捕获组。
  • 方法 str.matchAll 始终返回捕获组。

如果括号没有名称,则它们的内容可以通过其编号在匹配数组中获得。命名括号也可以在 groups 属性中获得。

我们也可以在替换字符串中使用括号中的内容,通过数字$n或名称$<name>

可以通过在组的开头添加?:来排除组的编号。当我们需要对整个组应用量词,但不想将其作为结果数组中的单独项时,就会使用它。我们也不能在替换字符串中引用这样的括号。

任务

MAC 地址由 6 个用冒号分隔的两位十六进制数字组成。

例如:'01:32:54:67:89:AB'

编写一个正则表达式来检查字符串是否为 MAC 地址。

用法

let regexp = /your regexp/;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (no colons)

alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, must be 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ at the end)

两位十六进制数字是[0-9a-f]{2}(假设设置了标志i)。

我们需要那个数字NN,然后:NN重复 5 次(更多数字);

正则表达式是:[0-9a-f]{2}(:[0-9a-f]{2}){5}

现在让我们证明匹配应该捕获所有文本:从开头开始,到结尾结束。这是通过将模式包装在^...$中完成的。

最后

let regexp = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (no colons)

alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, need 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ in the end)

编写一个正则表达式来匹配格式为#abc#abcdef的颜色。也就是说:#后面跟着 3 或 6 个十六进制数字。

用法示例

let regexp = /your regexp/g;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

附注:这应该正好是 3 或 6 个十六进制数字。具有 4 个数字的值,例如#abcd,不应该匹配。

用于搜索 3 位数字颜色#abc的正则表达式:/#[a-f0-9]{3}/i

我们可以添加正好 3 个可选的十六进制数字。我们不需要更多或更少。颜色要么有 3 位数字,要么有 6 位数字。

让我们为此使用量词{1,2}:我们将有/#([a-f0-9]{3}){1,2}/i

这里模式[a-f0-9]{3}被括号包围,以便应用量词{1,2}

实际应用

let regexp = /#([a-f0-9]{3}){1,2}/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef #abc

这里有一个小问题:模式在#abcd中找到了#abc。为了防止这种情况,我们可以在末尾添加\b

let regexp = /#([a-f0-9]{3}){1,2}\b/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

编写一个正则表达式,用于查找所有十进制数字,包括整数、浮点数和负数。

使用示例

let regexp = /your regexp/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) ); // -1.5, 0, 2, -123.4

一个带可选小数部分的正数是:\d+(\.\d+)?

让我们在开头添加可选的 -

let regexp = /-?\d+(\.\d+)?/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) );   // -1.5, 0, 2, -123.4

算术表达式由两个数字和它们之间的运算符组成,例如

  • 1 + 2
  • 1.2 * 3.4
  • -3 / -6
  • -2 - 2

运算符是以下之一:"+""-""*""/"

在开头、结尾或各个部分之间可能存在额外的空格。

创建一个函数 parse(expr),它接受一个表达式并返回一个包含 3 个项目的数组

  1. 第一个数字。
  2. 运算符。
  3. 第二个数字。

例如

let [a, op, b] = parse("1.2 * 3.4");

alert(a); // 1.2
alert(op); // *
alert(b); // 3.4

数字的正则表达式是:-?\d+(\.\d+)?。我们在之前的任务中创建了它。

运算符是 [-+*/]。连字符 - 在方括号中排在首位,因为在中间它表示字符范围,而我们只需要字符 -

斜杠 / 应该在 JavaScript 正则表达式 /.../ 中转义,我们稍后会进行处理。

我们需要一个数字、一个运算符,然后是另一个数字。它们之间可以有可选的空格。

完整的正则表达式:-?\d+(\.\d+)?\s*[-+*/]\s*-?\d+(\.\d+)?

它有 3 个部分,它们之间用 \s* 分隔

  1. -?\d+(\.\d+)? – 第一个数字,
  2. [-+*/] – 运算符,
  3. -?\d+(\.\d+)? – 第二个数字。

为了使这些部分中的每一个都成为结果数组的单独元素,让我们用括号将它们括起来:(-?\d+(\.\d+)?)\s*([-+*/])\s*(-?\d+(\.\d+)?)

实际应用

let regexp = /(-?\d+(\.\d+)?)\s*([-+*\/])\s*(-?\d+(\.\d+)?)/;

alert( "1.2 + 12".match(regexp) );

结果包括

  • result[0] == "1.2 + 12"(完整匹配)
  • result[1] == "1.2"(第一组 (-?\d+(\.\d+)?) – 第一个数字,包括小数部分)
  • result[2] == ".2"(第二组 (\.\d+)? – 第一个小数部分)
  • result[3] == "+"(第三组 ([-+*\/]) – 运算符)
  • result[4] == "12"(第四组 (-?\d+(\.\d+)?) – 第二个数字)
  • result[5] == undefined(第五组 (\.\d+)? – 最后一个小数部分不存在,因此为 undefined)

我们只想要数字和运算符,不需要完整匹配或小数部分,因此让我们稍微“清理”一下结果。

可以通过将数组 result.shift() 左移来删除完整匹配(数组的第一个项目)。

包含小数部分的组(数字 2 和 4)(.\d+) 可以通过在开头添加 ?: 来排除:(?:\.\d+)?

最终解决方案

function parse(expr) {
  let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  if (!result) return [];
  result.shift();

  return result;
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45

作为使用非捕获组 ?: 的替代方案,我们可以为组命名,如下所示

function parse(expr) {
  let regexp = /(?<a>-?\d+(?:\.\d+)?)\s*(?<operator>[-+*\/])\s*(?<b>-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  return [result.groups.a, result.groups.operator, result.groups.b];
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45;
教程地图

评论

在评论之前阅读...
  • 如果您有任何改进建议,请提交一个 GitHub 问题 提交 GitHub 问题 或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行代码,请将它们包装在 <pre> 标签中,对于超过 10 行的代码,请使用沙箱 (plnkrjsbincodepen…)