ES 提案:String.prototype.matchAll

这篇博客文章解释由 Jordan Harband 提出的提案 “String.prototype.matchAll” 是如何工作的。

在了解提案以前,让我们复习现状。

使用正则表达式获取所有匹配

目前,有几种方式获取正则表达式的所有匹配项。

RegExp.prototype.exec() 配合 /g

如果正则表达式带有 /g 标志,使用 .exec() 多次获取匹配项。在最后匹配后,它返回 null。在那之前,它返回每一个匹配的匹配对象。每一个对象包含包含捕获的子字符串。

在下面的示例中,我们收集了数组匹配中第 1 组的所有捕获:

function collectGroup1(regExp, str) {
  const matches = [];
  while (true) {
    const match = regExp.exec(str);
    if (match === null) break;
    // Add capture of group 1 to `matches`
    matches.push(match[1]);
  }
  return matches;
}

collectGroup1(/"([^"]*)"/gu, `"foo" and "bar" and "baz"`);
// [ 'foo', 'bar', 'baz' ]

没有 /g 标示,.exec() 总是仅仅返回第一次匹配项:

let re = /[abc]/;
re.exec('abc');
// -> [ 'a', index: 0, input: 'abc' ]
re.exec('abc');
// -> [ 'a', index: 0, input: 'abc' ]

这对于 collectGroup1() 是坏消息,因为如果 regExp 没有标志 /g,它将绝不会完成。

String.prototype.match() 配合 /g

如果使用设置有标志 /g 的正则表达式配合 .match() 方法,将得到包含所有匹配项的数组(换句话说,捕获分组被无视):

'abab'.match(/a/gu);
// -> [ 'a', 'a' ]

如果 /g 没有设置,.match() 方法与 RegExp.prototype.exec() 运行结果一样:

'abab'.match(/a/u);
// -> [ 'a', index: 0, input: 'abab' ]

String.prototype.replace() 配合 /g

可以使用技巧通过 .replace() 收集捕获:使用函数计算分割值。这个函数获得所有的捕获信息。然而,通过计算分割值来替代,收集数据很有意思,来看数组匹配:

function collectGroup1(regExp, str) {
  const matches = [];
  function replacementFunc(all, first) {
    matches.push(first);
  }
  str.replace(regExp, replacementFunc);
  return matches;
}

collectGroup1(/"([^"]*)"/gu, `"foo" and "bar" and "baz"`);
// -> [ 'foo', 'bar', 'baz' ]

正则表达式没有标志 /g 的话,.replace() 方法同样仅访问第一次匹配。

RegExp.prototype.test()

.test() 只要正则表达式匹配就返回 true

const regExp = /a/gu;
const str = 'aa';
regExp.test(str); // true
regExp.test(str); // true
regExp.test(str); // false

String.prototype.split()

可以使用正则表达式分割器分割字符串。如果正则表达式包含捕获分组,那么 .split() 方法返回分组捕获的子字符串:

const regExp = /<(-+)>/gu;
const str = 'a<--->b<->c';
str.split(regExp);
// -> [ 'a', '---', 'b', '-', 'c' ]

这些方法的问题

这些方法有几个不利因素:

  • 冗长且不直观
  • 只有设置标志 /g 时才作用,有时我们会从其它地方收到正则表达式,例如通过参数。 然后如果我们要确保找到所有匹配项,则必须检查是否设置了此标志。
  • 为了跟踪进度,所有方法(.match() 除外)都会更改正则表达式:.lastIndex 属性记录上一个匹配项的结束位置。 这使得在多个位置使用相同的正则表达式具有风险。尽管通常不建议这样做,但遗憾的是,当多次使用 .exec 时,不能内联正则表达式(因为每次调用都会重置正则表达式):
    // 没有用
    const match = /abc/gu.exec(str);
    
  • 由于属性 .lastIndex 确定匹配在何处继续,因此当我们开始收集匹配项时,该值必须始终为零。但是,至少 .exec() 在最后一次匹配之后将其重置为零。如果不为零,会发生以下情况:
    const regExp = /a/gu;
    regExp.lastIndex = 2;
    regExp.exec('aabb'); // null
    

String.prototype.matchAll() 提案

这是如何调用 .matchAll()

const matchIterator = str.matchAll(regExp);

给定字符串和正则表达式,.matchAll() 返回包含所有匹配项的配对对象迭代器。

可以使用扩展运算符(...)转化这个迭代器为数组:

[...'-a-a-a'.matchAll(/-(a)/gu)];
// -> [ [ '-a', 'a' ], [ '-a', 'a' ], [ '-a', 'a' ] ]

.matchAll() 不关心是否设置标志 /g

[...'-a-a-a'.matchAll(/-(a)/u)];
// -> [ [ '-a', 'a' ], [ '-a', 'a' ], [ '-a', 'a' ] ]

使用 .matchAll(),函数 collectGroup1() 变得更短且容易理解:

function collectGroup1(regExp, str) {
  let results = [];
  for (const match of str.matchAll(regExp)) {
    results.push(match[1]);
  }
  return results;
}

让我们使用扩展运算符和 .map() 使这个函数更简洁:

function collectGroup1(regExp, str) {
  let arr = [...str.matchAll(regExp)];
  return arr.map((x) => x[1]);
}

另一个选择是使用 Array.from(),转化为数组并且同时映射。因此,不再需要中间值 arr

function collectGroup1(regExp, str) {
  return Array.from(str.matchAll(regExp), (x) => x[1]);
}

.matchAll() 返回一个不可重新开始的迭代器

.matchAll() 返回一个迭代器,并且不可重新开始。因此,一旦结果用尽,需要再次调用该方法并创建一个新的迭代器。

相反,.match()/g 返回一个可迭代的数组,可以根据需要对其进行多次迭代。

实现一个 .matchAll() 方法

这是 matchAll 的实现:

function ensureFlag(flags, flag) {
  return flags.includes(flag) ? flags : flags + flag;
}
function* matchAll(str, regex) {
  const localCopy = new RegExp(regex, ensureFlag(regex.flags, 'g'));
  let match;
  while ((match = localCopy.exec(str))) {
    yield match;
  }
}

进行功能复制需确保以下几点:

  • /g 被设置。
  • regex.lastIndex 没有改变。
  • localCopy.lastIndex 是零。

使用 matchAll()

const str = '"fee" "fi" "fo" "fum"';
const regex = /"([^"]*)"/;

for (const match of matchAll(str, regex)) {
  console.log(match[1]);
}

// 输出:
// fee
// fi
// fo
// fum

FAQ

为什么不是 RegExp.prototype.execAll()?

一方面,.matchAll() 运行就像批量版本的 .exec(),因此名称 .execAll() 很有意义。

另一方面,exec() 更改正则表达式 match() 则不更改(exec()matchAll() 正则表达式相同)。这就解释了为什么选择名称 matchAll()

延伸阅读


英文链接:https://2ality.com/2018/02/string-prototype-matchall.html

Copyright © 2019 by Wu WenJun