概括
如果你曾用 JavaScript 进行过复杂的文本处理操作,那么你将会喜欢 ES2018 中引入的新特性。本文将详细介绍第9版标准如何提高 JavaScript 的文本处理能力。
大多数编程语言都支持正则表达式
它们是极其强大的文本处理工具。几十行的文本处理代码通常可以通过一行正则表达式来代替。虽然大多数语言中的内置函数足以对字符串执行搜索和替换操作,但更复杂的操作(例如验证文本输入)通常需要使用正则表达式。
自1999年推出 ECMAScript 标准第三版以来,正则表达式就成为 JavaScript 语言的一部分。ECMAScript 2018(简称 ES2018)是该标准的第九版,引入四个新特性进一步提高了 JavaScript 的文本处理能力:
- 后行断言
- 具名组匹配
- s 修饰符:dotAll 模式
- Unicode 属性类
以下小节详细介绍这些新特性
后行断言
断言能够根据之前或之后的内容匹配一系列字符,丢弃可能不需要的匹配。当需要处理大段字符串并且意外匹配的可能性很高时,这一特性尤为重要。幸运的是大多数正则表达式都支持后行断言和先行断言。
在 ES2018 之前,JavaScript 中只支持先行断言。先行断言指的是,x 只有在 y 前面才匹配。
先行断言有两种:肯定和否定。先行肯定断言的语法是 (?=...)
。例如,正则表达式 /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
是因为没有具名组匹配。
先行否定断言的语法是 (?!...)
。先行否定断言指的是,x 只有不在 y 前面才匹配。例如, /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 增加后行断言来完善先行断言。后行断言语法 (?<=...)
表示,x 只有在 y 后面才匹配。
假设以欧元为单位检索产品的价格而不匹配欧元符号。使用后行断言会变得很简单:
代码语言: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]
注意:先行断言和后行断言通常被称为 “lookarounds”。
后行否定断言的语法为 (?<!...)
,x 只有不在 y 后面才匹配。例如, /(?<!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
与先行断言一样,也可以连续使用多个后行断言(肯定或否定)来创建更复杂的模式。举个例子:
代码语言:javascript复制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 扩展名的文件名并提取文件名:
代码语言:javascript复制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
在更复杂的模式中,使用数字索引只会使已经神秘的正则表达式语法更加混乱。假设匹配日期,由于在某些地区日期和月份的位置交换,因此不清楚哪个组指的是月份,哪个组指的是日期:
代码语言:javascript复制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
新语法也适用于解构赋值:
代码语言:javascript复制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
即使正则表达式中没有具名组, exec
方法返回的结果中也始终创建 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
反向引用某个“常规捕获组”,可以在其后使用 的写法。例如,以下代码使用常规组匹配连续重复的两个字母:
代码语言:javascript复制console.log(/(ww)/.test('abab')); // → true
// if the last two letters are not the same
// as the first two, the match will fail
console.log(/(ww)/.test('abcd')); // → false
反向引用某个“具名组匹配”,可以使用 /k<name>/
的写法:
const re = /(?<dup>w )s k<dup>/;
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
此正则表达式在句子中查找连续的重复单词。也可以使用 的写法:
代码语言:javascript复制const re = /(?<dup>w )s /;
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
/k<name>/
和 两种写法可以同时使用:
const re = /(?<digit>d)::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
如果 replace()
方法第二个参数是函数,可以用数字索引的方式引用具名组。该函数的第二个参数为第一个组匹配的值,第三个参数为第二个组匹配的值:
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 模式
默认情况下,点( .
)元字符匹配除行终止符(换行符( )和回车符( ))之外的任何字符:
console.log(/./.test('')); // → false
console.log(/./.test('')); // → false
尽管有这个缺点,JavaScript 开发人员仍然可以通过使用两个相反的字符类来匹配所有字符,例如 [wW]
,表示匹配字符( w
)或非字符( W
):
console.log(/[wW]/.test('')); // → true
console.log(/[wW]/.test('')); // → true
ES2018 通过引入 s
( dotAll
) 修饰符来解决这个问题。使用了此修饰符后,它会更改( .
)元字符的行为使换行符也被匹配:
console.log(/./s.test('')); // → true
console.log(/./s.test('')); // → true
s
修饰符可以使用在所有正则表达式上,且不会改变依赖于点元字符之前的表现。除了 JavaScript 之外,还有许多其他语言,如 Perl 和 PHP 也有 s
修饰符。
Unicode 属性类
ES2015 中引入 Unicode 感知。但是 u
修饰符仍然无法匹配 Unicode 字符。
考虑以下示例:
代码语言:javascript复制const str = '?';
console.log(/d/.test(str)); // → false
console.log(/d/u.test(str)); // → false
?
被认为是一个数字,但 d
只能匹配 ASCII [0-9],所以 test()
方法返回 false
。因为改变字符组的行为会破坏现有的正则表达式的表现,所以引入一种新的转义序列。
在 ES2018 中,当设置 u
修饰符时, p{...}
可以匹配 Unicode 字符。现在要匹配任何 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
请注意, p{...}
中使用不支持的属性会导致 SyntaxError
:
console.log(/p{undefined}/u.test('漢')); // → SyntaxError
兼容性
总结
ES2018 在之前标准上增加正则表达式特性。新特性包括后行断言,具名组匹配,s 修饰符:dotAll 模式,Unicode 属性类。后行断言,x 只有在 y 后面才匹配。与常规捕获组相比,具名组匹配使用更具表现力的语法。 s
( dotAll
)修饰符改变 .
元字符的表现,匹配换行符。最后,Unicode 属性类提供了一种新的转义序列。
在编写复杂正则表达式时,测试正则表达式通常很有好处。一个好的测试工具提供针对字符串测试正则表达式的接口并展示引擎解析每一步。这在理解其他人编写的表达式时很有用。它还可以检测正则表达式中可能出现的语法错误。Regex101 和 RegexBuddy 是两个流行正则表达式测试工具。