将改变你写正则的新JavaScript特性

原文信息: 查看原文查看原文

New JavaScript Features That Will Change How You Write Regex

- Faraz Kelhini

简短概括:如果你曾做过任何复杂文本处理和操作这种工作,你会很喜欢ES2018中引入的新特性。在这篇文章中,我们将好好地看一下第九个版本的标准是怎么提升JavaScript中文本处理能力的。

大多数编程语言都支持正则表达式,有一个很好的理由:它们是非常强大的文本操作工具。那些需要很多行代码的文本处理任务,往往只需要一行简单的正则表达式代码就可以实现。现在大多数语言的内置函数通常对于执行文本的搜索和替换操作已经够用了,更多复杂的操作 —— 比如验证文本输入内容 —— 通常需要使用到正则表达式。

从1999年的第三个版本的ECMAScript标准开始,正则表达式就已经成为JavaScript语言的一部分了。ECMAScript 2018(或简写成ES2018)是标准的第九个版本,通过引入四个新特性来提升了JavaScript中文本处理的能力:

  • Lookbehind assertions(后行断言)
  • Named capture groups(命名捕获组)
  • s (dotAll) Flag(s(dotAll)标志)
  • Unicode property escapes(Unicode属性转义)

这些新特性会在接下来的小节中详细解释。

Lookbehind Assertions(后行断言)

匹配一串字符的能力依赖于这串字符后面或前面可以让你丢弃潜在的不想要的匹配项。当你需要处理一个大的字符串时,这一点非常重要,且匹配不想要的匹配项的概率很高。幸好,大部分正则表达式为了这个目的都提供了前行断言和后行断言。

在ES2018之前,JavaScript只支持前行断言。前行断言允许你断定一个模型紧跟在另一个模型后面。

前行断言有两个版本:正向和负向。正向前行断言的语法是(?=...),比如正则/Item(?= 10)/只会匹配Item后面有一个空格,靠近数字10:

const re = /Item(?= 10)/;

console.log(re.exec('Item'));
// → null

console.log(re.exec('Item5'));
// → null

console.log(re.exec('Item 5'));
// → null

console.log(re.exec('Item 10'));
// → ["Item", index: 0, input: "Item 10", groups: undefined]

这段代码使用了exec()函数来搜索一个字符串中的匹配项。如果一个匹配项找到了,exec()会返回一个数组,数组的第一个元素是匹配到的字符串。数组的index属性表示匹配到的字符串的索引,input属性表示搜索执行的整个字符串。最后,如果正则表达式中使用了命名捕获组,它们被放到groups属性上。在这个场景下,groups有一个值是undefined,因为没有命名捕获组。

一个负向前行断言的构造是(?!...)。一个负向前行断言是一个模型不跟随在一个特殊的模型后面。例如,模型/Red(?!head)/只匹配Red后面没有head的字符串:

const re = /Red(?!head)/;

console.log(re.exec('Redhead'));
// → null

console.log(re.exec('Redberry'));
// → ["Red", index: 0, input: "Redberry", groups: undefined]

console.log(re.exec('Redjay'));
// → ["Red", index: 0, input: "Redjay", groups: undefined]

console.log(re.exec('Red'));
// → ["Red", index: 0, input: "Red", groups: undefined]

ES2018通过向JavaScript引入后行断言,对前行断言做了补充。(?<=...)表示,一个后行断言让你只有当它被领域各模型处理时,才匹配一个模型。

我们假设,你需要取出一个产品的欧元价格而不获取欧元符号。使用后行断言,这个任务会变得非常容易:

const re = /(?<=€)\d+(\.\d*)?/;

console.log(re.exec('199'));
// → null

console.log(re.exec('$199'));
// → null

console.log(re.exec('€199'));
// → ["199", undefined, index: 1, input: "€199", groups: undefined]

注意前行断言后行断言经常被称为“前后查找”。

反向的后行断言(?<!...)表示,可以让你匹配一个模型,且没有在后行断言里声明的模型。比如,正则表达式/(?<!\d{3}) meters/匹配“meters”前面没有三个数字的字符:

const re = /(?<!\d{3}) meters/;

console.log(re.exec('10 meters'));
// → [" meters", index: 2, input: "10 meters", groups: undefined]

console.log(re.exec('100 meters'));    
// → null

前行断言一样,你可以使用多个后行断言(反向或正向)组合来创建一个复杂的模型。下面是一个例子:

const re = /(?<=\d{2})(?<!35) meters/;

console.log(re.exec('35 meters'));
// → null

console.log(re.exec('meters'));
// → null

console.log(re.exec('4 meters'));
// → null

console.log(re.exec('14 meters'));
// → ["meters", index: 2, input: "14 meters", groups: undefined]

这个正则匹配一个包含meters的字符串,只有它立即在前面有任何除35之外的两位数字时才会匹配。正向的后行断言确保模型前面有两位数字,然后反向的后行断言确保这两个数字不是35。

Named Capture Groups(命名捕获组)

你可以将字符通过括号封装成一个组,这个组是正则表达式的一部分。这允许你将一个间隔限制成模型的一部分,或者在全组上应用一个量词。此外,你可以通过括号提取匹配值以进行进一步处理。

下面这段代码给我们一个例子,用来在一个字符串里发现一个以.jpg为后缀的文件,并提取出这个文件名:

const re = /(\w+)\.jpg/;
const str = 'File name: cat.jpg';
const match = re.exec(str);
const fileName = match[1];

// 结果数组的第二个元素是括号内匹配到的字符串的一部分
console.log(match);
// → ["cat.jpg", "cat", index: 11, input: "File name: cat.jpg", groups: undefined]

console.log(fileName);
// → cat

在更复杂的模型中,使用一个数字来引用一个组只是让已经晦涩难懂的正则表达式更加令人困惑。比如,假设你想要匹配一个日期。由于在很多区域日和月的位置是交换的,不能清楚地知道哪个组是引用到月或日:

const re = /(\d{4})-(\d{2})-(\d{2})/;
const match = re.exec('2020-03-04');

console.log(match[0]);    // → 2020-03-04
console.log(match[1]);    // → 2020
console.log(match[2]);    // → 03
console.log(match[3]);    // → 04

ES2018针对这个问题的解决方案是命名捕获组(Named Capture Groups),它使用(?<name>...)的这种一个更有表现力的语法:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = re.exec('2020-03-04');

console.log(match.groups);
// → {year: "2020", month: "03", day: "04"}

console.log(match.groups.year);
// → 2020

console.log(match.groups.month);
// → 03

console.log(match.groups.day);
// → 04

因为结果对象可能包含相同名字的属性作为一个命名组,所有命名组定义在一个分开的对象下,叫做groups

新的和传统编程语言中存在相同的构造。比如,Python为命名组使用(?P<name>)语法。不出所料,Perl使用和JavaScript相同语法支持命名组(JavaScript模仿了Perl的正则表达式语法)。Java也使用和Perl相同的语法。

除了可以通过groups对象来获取一个命名组,你还可以使用一个编号引用获取一个组,与正则捕获组类似:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = re.exec('2020-03-04');

console.log(match[0]);    // → 2020-03-04
console.log(match[1]);    // → 2020
console.log(match[2]);    // → 03
console.log(match[3]);    // → 04

这种语法也同样适用于结构赋值:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const [match, year, month, day] = re.exec('2020-03-04');

console.log(match);    // → 2020-03-04
console.log(year);     // → 2020
console.log(month);    // → 03
console.log(day);      // → 04

即使没有命名组存在,正则表达式中也总会创建groups对象:

const re = /\d+/;
const match = re.exec('123');

console.log('groups' in match);
// → true

如果一个匹配中有一个可选的命名组,这个groups对象将仍有那个命名组的属性,但是这个属性将由一个值是undefined

const re = /\d+(?<ordinal>st|nd|rd|th)?/;

let match = re.exec('2nd');

console.log('ordinal' in match.groups);
// → true
console.log(match.groups.ordinal);
// → nd

match = re.exec('2');

console.log('ordinal' in match.groups);
// → true
console.log(match.groups.ordinal);
// → undefined

你可以引用稍后的以\1的形式带反向引用的模型中的正则捕获组。比如,下面的代码使用一个捕获组匹配一行中的两个字母,然后在模型中再召回:

console.log(/(\w\w)\1/.test('abab'));
// → true

// 如果后面两个字母和前面两个不一样,匹配失败
console.log(/(\w\w)\1/.test('abcd'));
// → false

要想在模型中召回一个命名捕获组,你可以使用/\k<name>/语法。下面是一个例子:

const re = /\b(?<dup>\w+)\s+\k<dup>\b/;

const match = re.exec("I'm not lazy, I'm on on energy saving mode");        

console.log(match.index);    // → 18
console.log(match[0]);       // → on on

这个正则表达式从一个句子中找出连续重复的词。如果你喜欢,你还可以使用一个编号后面的引用召回一个命名捕获组:

const re = /\b(?<dup>\w+)\s+\1\b/;

const match = re.exec("I'm not lazy, I'm on on energy saving mode");        

console.log(match.index);    // → 18
console.log(match[0]);       // → on on 

同时使用一个编号后面的引用和一个命名反向引用,也是有可能的:

const re = /(?<digit>\d):\1:\k<digit>/;

const match = re.exec('5:5:5');        

console.log(match[0]);    // → 5:5:5

与编号捕获组类似,replace()方法替换的值可以插入命名捕获组。要做到这样,你将需要使用$<name>构造。例如:

const str = 'War & Peace';

console.log(str.replace(/(War) & (Peace)/, '$2 & $1'));
// → Peace & War

console.log(str.replace(/(?<War>War) & (?<Peace>Peace)/, '$<Peace> & $<War>'));
// → Peace & War

如果你想使用一个函数来执行替换操作,你可以像引用编号组一样引用命名组。第一个捕获组的值将作为函数的第二个参数,第二个捕获组的值将作为函数的第三个参数:

const str = 'War & Peace';

const result = str.replace(/(?<War>War) & (?<Peace>Peace)/, function(match, group1, group2, offset, string) {
    return group2 + ' & ' + group1;
});

console.log(result);    // → Peace & War

s (dotAll) Flag(s(dotAll)标志)

默认,正则模型中的点(.)元符号会匹配任何不带有换行符的字符,包括换行符(\n)和回车(\r):

console.log(/./.test('\n'));
// → false

console.log(/./.test('\r'));
// → false

尽管有这个缺点,JavaScript开发者仍可以使用两个像[\w\W]相反速记字符类匹配所有的字符,这指令正则引擎去匹配一个文字字符(\w)或非文字字符(\W):

console.log(/[\w\W]/.test('\n'));
// → true

console.log(/[\w\W]/.test('\r'));
// → true

ES2018旨在通过引入解决这个问题sdotAll)标志。当设置了这个标志,将改变点(.)元字符的行为也去匹配换行符号:

console.log(/./s.test('\n'));
// → true

console.log(/./s.test('\r'));
// → true

这个s标志可以被使用在每个基础正则上,依靠点符号的旧行为而不会破坏已经存在的模型。除了JavaScript,像很多类似PerlPHP这样的语言中也可以使用s标志。

推荐阅读: Recommended reading: An Abridged Cartoon Introduction To WebAssembly

Unicode Property Escapes(Unicode属性转义)

在ES2015中引入的新特性是Unicode特性。然而,速记符号类即使设置了u标志,仍不能匹配Unicode字符。

看一下下面这个例子:

const str = '𝟠';

console.log(/\d/.test(str));     // → false
console.log(/\d/u.test(str));    // → false

8被认为是一个数字,但是\d只能匹配ASCII[0-9],所以test()方法返回false。因为改变速记字符类的行为将改变已存在正则表达式模型,故决定引入一个新的转义序列的类型。

在ES2018中,Unicode属性转义,表示为\p{...},当设置了u标志之后在正则表达式中才是有效的。现在想要匹配任何Unicode数字,你可以简单地使用\p{Number},就像下面这样:

const str = '𝟠';
console.log(/\p{Number}/u.test(str));
// → true

要匹配任何Unicode拼音符号,你可以使用\p{Alphabetic}

const str = '漢';

console.log(/\p{Alphabetic}/u.test(str));
// → true

// 这个 \w 简写不能匹配 漢
console.log(/\w/u.test(str));
// → false

\P{...}\p{...}的否定版本,匹配任何\p{...}不能匹配的字符:

console.log(/\P{Number}/u.test('𝟠'));
// → false
console.log(/\P{Number}/u.test('漢'));
// → true

console.log(/\P{Alphabetic}/u.test('𝟠'));
// → true
console.log(/\P{Alphabetic}/u.test('漢'));
// → false

已经支持的属性的完整列表在当前规范提议中已经可用了。

注意,使用未被支持的属性会引起一个语法错误:

console.log(/\p{undefined}/u.test('漢'));
// → SyntaxError

兼容性表

桌面浏览器

ChromeFirefoxSafari
Lookbehind Assertions62XX
Named Capture Groups64X11.1
s (dotAll) Flag62X11.1
Unicode Property Escapes64X11.1

移动端浏览器

ChromeFor AndroidFirefoxFor AndroidiOS SafariEdge MobileSamsung Internet
Lookbehind Assertions62XXX8.2
Named Capture Groups64X11.3XX
s (dotAll) Flag62X11.3X8.2
Unicode Property Escapes64X11.3XX

NODE.JS

  • 8.3.0 (需要--harmony运行标志)
  • 8.10.0 (支持sdotAll)标志和后行断言)
  • 10.0.0 (全部支持)
分享于 2019-02-19

访问量 1705

预览图片