原文:ES proposal: String.prototype.matchAll
Jordan Harband的提案“String.prototype.matchAll”目前处于第3阶段。这篇博客将会解释它如何工作的。
在我们看这个提案前,回顾下现状。
1.用一个正则表达式来得到所有匹配项。
目前,您可以通过几种方式获取给定正则表达式的所有匹配项。
1. RegExp.prototype.exec() 与 /g
如果正则表达式有/g标志,那么多次调用.exec()就会得到所有匹配的结果。如果没有匹配的结果,.exec()就会返回null。在这之前会返回每个匹配的匹配对象。这个对象包含捕获的子字符串和更多信息。
举个例子:得到所有双引号之间的字符串
function collectGroup1(regExp, str) {
const matches = [];
while (true) {
const match = regExp.exec(str);
if (match === null) break;
// 把match中捕获的字符串,加到matches中。
matches.push(match[1]);
}
return matches;
}
// /"([^"]*)"/ug 匹配所有双引号与其之间的内容,并捕获所有引号间的信息。
collectGroup1(/"([^"]*)"/ug,`"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就是一个坏消息,因为如果没有/g标志,函数无法结束运行,此时match就一直是第一次匹配的结果,循环永远无法break。
为什么会这样?
因为正则表达式有一个lastIndex(初始值为0)属性,每次.exec()前,都会根据lastIndex属性的值来决定开始匹配的位置。
如果正则表达式没有/g标志,那么运行一次.exec()时,不会改变lastIndex的值,导致下一次运行exec()时,匹配仍旧是从字符串0的位置开始。
当正则表达式加了/g标志后,运行一次exec(),正则表达式的lastIndex就会改变,下次运行exec()就会从前一次的结果之后开始匹配。
2.String.prototype.match() 与 /g
你可以使用.match()方法和一个带有/g标志的正则表达式,你就可以得到一个数组,包含所有匹配的结果(换句话说,所有捕获组都将被忽略)。
> "abab".match(/a/ug)
[ 'a', 'a' ]
如果/g标志没有被设置,那么.match()与RegExp.prototype.exec()返回的结果一样。
> "abab".match(/a/u)
[ 'a', index: 0, input: 'abab' ]
3.String.prototype.replace() 与 /g
你可以用一个小技巧来收集所有的捕获组——通过.replace()。replace函数接收一个能够返回要替换的值的函数,这个函数能够接收所有的捕获信息。但是,我们不用这个函数去计算替换的值,而是在函数里用一个数组去收集感兴趣的数据。
function collectGroup1(regExp, str) {
const matches = [];
function replacementFunc(all, first) {
matches.push(first);
}
str.replace(regExp, replacementFunc);
return matches;
}
collectGroup1(/"([^"]*)"/ug,`"foo" and "bar" and "baz"`);
// [ 'foo', 'bar', 'baz' ]
对于没有/g标志的正则表达式,.replace()仅访问第一个匹配项。
4.RegExp.prototype.test()
.test()只要正则表达式匹配成功就会返回true。
const regExp = /a/ug;
const str = 'aa';
regExp.test(str); // true
regExp.test(str); // true
regExp.test(str); // false
5.String.prototype.split()
你可以拆分一个字符串并用一个正则表达式去指定分隔符。如果正则表达式包含至少一个捕获组,那么.split()将会返回一个数组,其中结果会跟第一个捕获组互相交替。
const regExp = /<(-+)>/ug;
const str = 'a<--->b<->c';
str.split(regExp);
// [ 'a', '---', 'b', '-', 'c' ]
const regExp = /<(?:-+)>/ug;
const str = 'a<--->b<->c';
str.split(regExp);
//[ 'a', 'b', 'c' ]
2.目前这些方法存在的问题
目前这些方法都有以下几个缺点:
1.它们是冗长且不直观的。
2.如果标志/g被设置了,它们才会工作。有时候,我们会从其他地方接收正则表达式,比如通过一个参数。如果我们想要去确定所有匹配的项都能找到,那么不得不检查/g标志有没有被设置。
3.为了跟踪进程,所有的方法(除了match)改变了正则表达式的属性,.lastIndex记录了上一次匹配的结束为止。这使得在多个为止使用相同的正则表达式会存在风险(因为正则表达式的lastIndex属性改变了,但是你还在别的地方使用这个正则表达式,那么结果可能会和你想要的不一样)。这太可惜了,当你需多次调用.exec()的时候,你不能在一个函数内联一个正则表达式。(因为每次调用,正则表达式都会之重置)。
4.由于属性.lastIndex决定在了在哪继续调用。当我们开始继续收集匹配项的时候,就必须把她始终为0。但是,至少.exec()和其他一些方法会在最后一次匹配后将.lastIndex重置为0。如果它不是零,就会发生这种情况:
const regExp = /a/ug;
regExp.lastIndex = 2;
regExp.exec('aabb'); // null
3.提案:tring.prototype.matchAll
这就是你调用.matchAll()的方式:
const matchIterator = str.matchAll(regExp);
给定一个字符串和一个正则表达式,.matchAll()为所有匹配的匹配对象返回一个迭代器。
你也可以使用一个扩展运算符...把迭代器转换为数组。
> [...'-a-a-a'.matchAll(/-(a)/ug)]
[ [ '-a', 'a' ], [ '-a', 'a' ], [ '-a', 'a' ] ]
现在是否设置/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]);
}
3.1 .matchAll() returns an iterator, not a restartable iterable
.matchAll()返回一个跌倒器,但不是一个真的可重新利用的迭代器。一旦结果耗尽,你不得不再次调用方法,获取一个新的迭代器。
相反,.match()加上 /g 返回一个迭代器即数组,只要你想,你就可以迭代它。
4.Implementing .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.index 不会改变
- regex.index 是0
使用matchAll():
const str = '"fee" "fi" "fo" "fum"';
const regex = /"([^"]*)"/;
for (const match of matchAll(str, regex)) {
console.log(match[1]);
}
// Output:
// fee
// fi
// fo
// fum
5.常见问题
5.1 为什么不是RegExp.prototype.execAll()?
一方面,.matchAll()确实跟批量调用.exec()的工作很像,因此名称.execAll()会有意义。
另一方面,exec()改变了正则表达式,而match()没有。这就解释了,为什么名字matchAll()会被选择。
6.进一步阅读
- Chap. “Iterables and iterators” in “Exploring ES6”
- Sect. “The spread operator (
...
)” in “Exploring ES6” - Sect. “
Array.from()
” in “Exploring ES6”