[译]JavaScript的新功能将改变正则表达式的编写方式

1,529 阅读7分钟

翻译:第一秩序

原文:www.smashingmagazine.com/2019/02/reg…

摘要:如果你曾用 JavaScript 做过复杂的文本处理和操作,那么你将会对 ES2018 中引入的新功能爱不释手。 在本文中,我们将详细介绍第 9 版标准如何提高 JavaScript 的文本处理能力。


有一个很好的理由能够解释为什么大多数编程语言都支持正则表达式:它们是用于处理文本的极其强大的工具。 通常一行正则表达式代码就能完成需要几十行代码才能搞定的文本处理任务。 虽然大多数语言中的内置函数足以对字符串进行一般的搜索和替换操作,但更加复杂的操作(例如验证文本输入)通常需要使用正则表达式。

自从 1999 年推出 ECMAScript 标准第 3 版以来,正则表达式已成为 JavaScript 语言的一部分。ECMAScript 2018(简称ES2018)是该标准的第 9 版,通过引入四个新功能进一步提高了JavaScript的文本处理能力:

下面详细介绍这些新功能。

后行断言

能够根据之后或之前的内容匹配一系列字符,使你可以丢弃可能不需要的匹配。 当你需要处理大字符串并且意外匹配的可能性很高时,这个功能非常有用。 幸运的是,大多数正则表达式都为此提供了 lookbehind 和 lookahead 断言。

在 ES2018 之前,JavaScript 中只提供了先行断言。 lookahead 允许你在一个断言模式后紧跟另一个模式。

先行断言有两种版本:正向和负向。 正向先行断言的语法是 (?=...)。 例如,正则表达式 /Item(?= 10)/ 仅在后面跟随有一个空格和数字 10 的时候才与 Item 匹配:

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)/ 仅在其后不跟随 head 时匹配 Red

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]

注意先行(Lookahead)和后行(lookbehind)断言通常被称为“环视”(lookarounds)

后行断言的反向版本由 (?<!...) 表示,使你能够匹配不在lookbehind中指定的模式之前的模式。 例如,正则表达式 /(?<!\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。

命名捕获组

你可以通过将字符封装在括号中的方式对正则表达式的一部分进行分组。 这可以允许你将规则限制为模式的一部分或在整个组中应用量词。 此外你可以通过括号来提取匹配值并进行进一步处理。

下列代码给出了如何在字符串中查找带有 .jpg 并提取文件名的示例:

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

// The second element in the resulting array holds the portion of the string that parentheses matched
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针对此问题的解决方案名为捕获组,它使用更具表现力的 (?<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

// if the last two letters are not the same 
// as the first two, the match will fail
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

默认情况下,正则表达式模式中的点 (.) 元字符匹配除换行符 (\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旨在通过引入 s (dotAll) 标志来解决这个问题。 设置此标志后,它会更改点 (.)元字符的行为以匹配换行符:

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

s 标志可以在每个正则表达式的基础上使用,因此不会破坏依赖于点元字符的旧行为的现有模式。 除了 JavaScript 之外, s 标志还可用于许多其他语言,如 Perl 和 PHP。

Unicode 属性转义

ES2015中引入的新功能包括Unicode感知。 但是即使设置了 u 标志,速记字符类仍然无法匹配Unicode字符。

请考虑以下案例:

const str = '𝟠';

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

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

在ES2018中,当设置 u 标志时,Unicode属性转义(由 \p{...} 表示)在正则表达式中可用。 现在要匹配任何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

// the \w shorthand cannot match 漢
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

当前规范提案中提供了受支持属性的完整列表。

请注意,使用不受支持的属性会导致 SyntaxError

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

兼容性列表

桌面浏览器

Chrome Firefox Safari Edge
后行断言 62 X X X
命名捕获组 64 X 11.1 X
s (dotAll) Flag 62 X 11.1 X
Unicode 属性转义 64 X 11.1 X

移动浏览器

Chrome For Android Firefox For Android iOS Safari Edge Mobile Samsung Internet Android Webview
后行断言 62 X X X 8.2 62
命名捕获组 64 X 11.3 X X 64
s (dotAll) Flag 62 X 11.3 X 8.2 62
Unicode 属性转义 64 X 11.3 X X 64

NODE.JS

  • 8.3.0 (需要 --harmony 运行时标志)
  • 8.10.0 (支持 s (dotAll) flag 和后行断言)
  • 10.0.0 (完全支持)

总结

通过使正则表达式得到增强,ES2018 继续了以前版本ECMAScript的工作。新功能包括后行断言,命名捕获组, s (dotAll) flag 和 Unicode属性转义。 后行断言允许你在一个模式前面存在另一个模式进行匹配。与常规捕获组相比,命名捕获组使用了更具表现力的语法。 s (dotAll) flag 通过更改点(.)元字符的行为来匹配换行符。最后,Unicode 属性转义在正则表达式中提供了一种新类型的转义序列。

在构建复杂的模式时,使用正则表达式测试程序通常很有帮助。一个好的测试器会提供一个接口来对字符串的正则表达式进行测试,并显示引擎所做的每一步,这在你理解其他人编写的表达式时非常有帮助。它还可以检测正则表达式中可能出现的语法错误。 Regex101 和 RegexBuddy 是两个值得一试的正则表达式测试程序。

除此之外你能推荐其他的工具吗?欢迎在评论中分享!