Skip to content

捕获组

前言

模式的一部分可以用括号括起来 (...)。这称为“捕获组(capturing group)”。

这有两个影响:

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

一些小例子

示例1:匹配括号内重复的字符gogogo

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

括号将字符组合,所以 (go)+ 匹配 gogogogogogo等。

js
console.log('Gogogo now!'.match(/go+/i)); // [ 'Go', index: 0, input: 'Gogogo now!', groups: undefined ]
console.log('Gogogo now!'.match(/(go)+/i)); // [ 'Gogogo', 'go', index: 0, input: 'Gogogo now!', groups: undefined ]

示例2:匹配域名

让我们做些更复杂的事 —— 搜索域名的正则表达式。

例如:

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

正如我们所看到的,一个域名由重复的单词组成,每个单词后面有一个点,除了最后一个单词。

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

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

console.log("site.com my.site.com".match(regexp)); // [ 'site.com', 'my.site.com' ]

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

我们可以通过用 [\w-] 替换 \w 来匹配除最后一个的每个单词:([\w-]+\.)+\w+

示例3:email

前面的示例可以扩展。我们可以基于它为电子邮件创建一个正则表达式。

email 格式为:name@domain。名称可以是任何单词,可以使用连字符和点。在正则表达式中为 [-.\w]+

模式:

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

console.log("my@mail.com @ his@site.com.uk".match(regexp)); // [ 'my@mail.com', 'his@site.com.uk' ]

该正则表达式并不完美的,但多数情况下都可以工作,并且有助于修复意外的错误类型。唯一真正可靠的 email 检查只能通过发送 email 来完成。

匹配括号中的内容

括号从左到右编号。正则引擎会记住它们各自匹配的内容,并允许在结果中获得它。

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

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

例如,我们想找到 HTML 标记 <.*?> 并进行处理。这将很方便的把标签内容(尖括号内的内容)放在单独的变量中。

让我们将内部内容包装在括号中,像这样:<(.*?)>

现在,我们能在结果数组中获取标签的整体 <h1> 及其内容 h1

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

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

console.log(tag);
/* 
[
  '<h1>',
  'h1',
  index: 0,
  input: '<h1>Hello, world!</h1>',
  groups: undefined
]
*/

console.log(tag[0]); // <h1>
console.log(tag[1]); // h1

嵌套组

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

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

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

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

这是它们的编号方式(从左到右,由左括号开始):

image-20211207095104444

比如:

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

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

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

result 的零索引始终保持完全匹配。

然后按左括号将组从左到右编号。第一组返回为 result[1]。它包含了整个标签内容。

然后 result[2] 从第二个开始的括号中进入该组 ([a-z]+) —— 标签名称,然后在 result[3] 标签中:([^>]*)

字符串中每个组的内容:

image-20211207100237754

可选组

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

例如,让我们考虑正则 a(z)?(c)?。它寻找 "a" ,然后是可选的 "z",然后是可选的 "c"

如果我们在单个字母的字符串上运行 a,则结果为:

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

console.log(match.length); // 3
// 即使可选组没有匹配到,match的结果中也会有,会有个undefined
console.log(match);
/* 
[ 'a', undefined, undefined, index: 0, input: 'a', groups: undefined ]
*/
console.log(match[0]); // a(完全匹配)
console.log(match[1]); // undefined
console.log(match[2]); // undefined

数组的长度为 3,但所有组均为空。

这是字符串的一个更复杂的匹配 ac

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

console.log(match.length); // 3
console.log(match);
/* 
[ 'ac', undefined, 'c', index: 0, input: 'ac', groups: undefined ]
*/
console.log(match[0]); // ac(完全匹配)
console.log(match[1]); // undefined,因为 (z)? 没匹配项
console.log(match[2]); // c

数组长度是恒定的:3。但是对于组 (z)? 而言,什么都没有,所以结果是 ["ac", undefined, "c"]

搜索所有具有组的匹配项:matchAll

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

旧的浏览器不支持 matchAll

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

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

例如,让我们查找字符串中的所有标签:

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

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

console.log(tags); // [ '<h1>', '<h2>' ]

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

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

在使用 match 很长一段时间后,它作为“新的改进版本”被加入到 JavaScript 中。

就像 match 一样,它寻找匹配项,但有 3 个区别:

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

例如:

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

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

console.log(results[0]); // undefined (*)

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

// matchAll 把每一项的匹配组,赛到了一个数组中返回了出来
console.log(results[0]); // [ '<h1>', 'h1', index: 0, input: '<h1> <h2>', groups: undefined ]
console.log(results[1]); // [ '<h2>', 'h2', index: 5, input: '<h1> <h2>', groups: undefined ]

我们可以看到,第一个区别非常重要,如 (*) 行所示。我们无法获得 results[0] 的匹配内容,因为该对象是可迭代对象,并不是一个数组。我们可以使用 Array.from 把它变成一个真正的 Array。在可迭代与类数组一文中有关于类数组和可迭代对象的更多详细信息。

如果我们不需要遍历结果,则 Array.from 没有必要:

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

for (let result of results) {
    console.log(result);
    // 第一个结果: [ '<h1>', 'h1', index: 0, input: '<h1> <h2>', groups: undefined ]
    // 第二个结果: [ '<h2>', 'h2', index: 5, input: '<h1> <h2>', groups: undefined ]
}

……或使用解构:

js
let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi);
console.log(tag1); // [ '<h1>', 'h1', index: 0, input: '<h1> <h2>', groups: undefined ]
console.log(tag2); // [ '<h2>', 'h2', index: 5, input: '<h1> <h2>', groups: undefined ]

matchAll 所返回的每个匹配,其格式与不带标志 gmatch 所返回的格式相同:它是一个具有额外的 index(字符串中的匹配索引)属性和 input(源字符串)的数组:

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

let [tag1, tag2] = results;
console.log(tag1); // [ '<h1>', 'h1', index: 0, input: '<h1> <h2>', groups: undefined ]
console.log(tag2); // [ '<h2>', 'h2', index: 5, input: '<h1> <h2>', groups: undefined ]

console.log( tag1[0] ); // <h1>
console.log( tag1[1] ); // h1
console.log( tag1.index ); // 0
console.log( tag1.input ); // <h1> <h2>

为什么 matchAll 的结果是可迭代对象而不是数组?

为什么这个方法这样设计?原因很简单 — 为了优化。

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

因此,这将根据需要找到尽可能多的结果,而不是全部。

例如,文本中可能有 100 个匹配项,但是在一个 for..of 循环中,我们已经找到了 5 个匹配项,然后觉得足够了并做出一个 break。这时引擎就不会花时间查找其他 95 个匹配。

注意:matchAll在匹配正则时,正则必须带有g标志,否则会报错

js
let result = '<h1> <h2>'.matchAll(/<(.*?)>/i);
// TypeError: String.prototype.matchAll called with a non-global RegExp argument

命名组

用数字记录组很困难。对于简单模式,它是可行的,但对于更复杂的模式,计算括号很不方便。我们有一个更好的选择:给括号起个名字。

这是通过在开始括号之后立即放置 ?<name> 来完成的。

例如,让我们查找 “year-month-day” 格式的日期:

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

console.log(str.match(dateRegexp));
/* 
[
  '2019-04-30',
  '2019',
  '04',
  '30',
  index: 0,
  input: '2019-04-30',
  groups: [Object: null prototype] { year: '2019', month: '04', day: '30' }
]
*/

let groups = str.match(dateRegexp).groups;
console.log(groups); // [Object: null prototype] { year: '2019', month: '04', day: '30' }
console.log(groups.year); // 2019
console.log(groups.month); // 04
console.log(groups.day); // 30

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

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

我们还需要 matchAll 获取完整的组匹配:

js
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);

console.log(results); // Object [RegExp String Iterator] {}

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

    console.log(`${day}.${month}.${year}`);
    // 第一个 30.10.2019
    // 第二个:01.01.2020
}

替换捕获组

方法 str.replace(regexp, replacement)replacement 替换 str 中匹配 regexp 的所有捕获组。这使用 $n 来完成,其中 n 是组号。

例如,

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

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

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

例如,让我们将日期格式从 “year-month-day” 更改为 “day.month.year”:

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

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

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

本章主要是为了讲解正则表达式中捕获组的概念,对于涉及到的几个字符串方法,match、matchAll,replace,讲解的并不是很细致,会另写一篇文章介绍这几个方法,正则表达式(RegExp)和字符串(String)的方法

非捕获组?:

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

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

例如,如果我们要查找 (go)+,但不希望括号内容(go)作为一个单独的数组项,则可以编写:(?:go)+

在下面的示例中,我们仅将名称 John 作为匹配项的单独成员:

js
let str = "Gogogo John!";

// ?: 从捕获组中排除 'go'
let regexp = /(?:go)+ (\w+)/i;

let result = str.match(regexp);
console.log(result);
/* 
// 结果中只有 捕获组 (\w+) 的结果 John  (?:go) 被排除掉了
[
  'Gogogo John',
  'John',
  index: 0,
  input: 'Gogogo John!',
  groups: undefined
]
*/

console.log(result[0]); // Gogogo John(完全匹配)
console.log(result[1]); // John
console.log(result.length); // 2(数组中没有更多项)

总结

  1. 正则表达式中的括号()有两个作用:

    1. 如果括号后面紧跟一个量词,比如(xyz)+,则括号里的内容会视作为一个整体。
    2. 括号里的内容其实就是一个捕获组,当在进行正则匹配时,捕获组中匹配到的结果会作为返回数组的单独一项,也就是说,我们可以获取到括号中(每一个捕获组中)匹配到的结果。
  2. 使用match方法匹配正则时

    1. 表达式不带g标志

      1. 会返回一个数组
      2. 数组的第一项是正则表达式全匹配的结果
      3. 数组的第二项是捕获组1的结果
      4. 第三项是捕获组2的结果(假如有的话)
      5. ...
      6. 然后是匹配到全匹配结果第一个字符的索引
      7. 然后是输入的字符串
      8. 最后是所有命名捕获组匹配到的结果(就相当于给捕获组起的别名,把对应别名的匹配结果在此处存储一遍,方便获取值)
    2. g标志

      1. 会返回一个数组

      2. 数组中是每一项全匹配的结果(捕获组中的信息就没有了,只有匹配结果)

        所以,为了补充这个不足,就出现了matchAll方法

  3. 使用matchAll方法匹配

    1. 返回的是一个可迭代对象
    2. 使用for of输出时,每一项是带有捕获组格式的数组,与不带g标志的match方法返回的结果一致。
    3. matchAll方法匹配正则时必须得带有g标识,否则会报错
  4. 嵌套组:捕获组是可以嵌套的,比如:((xxx)_(yyy)),嵌套的捕获组在标记组号时,会按照括号的先后进行标记,由外向里一个一个的标记,最外层就是捕获组1,然后里面的是捕获组2、然后捕获组3等等。

  5. 可选组:在捕获组后面加个?,比如(xxx)?,那这个捕获组就变为了可选的,可有可没有(这其实就是量词?的特性,?表示{0, 1},可有可没有),不过在使用match匹配时,对应的捕获组如果没有会赛进返回数组对应位置处一个undefined

  6. 命名组:当对复杂的正则使用捕获组时,自己人为的去查捕获组的编号是很复杂的(虽然有规律,由外向里,由左向右,但是特别多的时候也很复杂,眼容易瞅花),这时候就可以对捕获组进行命名,命名方式就是在左括号(后面紧跟一个名称?<name>,比如(?<xxx>)

    命名捕获组的匹配结果可以在match方法返回的groups中获取到,返回结果格式为:

    js
    [
      '2019-04-30',
      '2019',
      '04',
      '30',
      index: 0,
      input: '2019-04-30',
      groups: [Object: null prototype] { year: '2019', month: '04', day: '30' }
    ]
  7. 非捕获组:如果不想让括号中的匹配结果出现在match方法返回的数组项中,假如这个括号只是为了量词使用,我们可以将它定义为一个非捕获组,定义方式为,在左括号(后面紧跟一个?:,比如(?:xxx),这样这个括号里面的正则匹配到的内容就不会出现在match的匹配结果中(这样也是非常方便的,万一有一个捕获组匹配到了特别多的内容,我们还不想要它,耽误我们看别的捕获组的内容时,就可以把这个干扰项排除掉)。

  8. 替换捕获组:有一个强大的替换方法,replace,结合正则的捕获组可用各种方法去替换字符串,非常灵活,方法的详解放到单独一篇文章讲解,正则表达式(RegExp)和字符串(String)的方法

参考

https://zh.javascript.info/regexp-groups