smartsrh

后 ES6 时代的正则匹配

smartsrh · 2017-06-10翻译 · 717阅读 原文链接

在本文中,我们将看看 ES6 及未来的正则表达式。有一些在 ES6 中引入的新正则表达式标志:粘贴匹配标志 /y 和 Unicode 标志 /u。 然后我们将讨论 TC39 的 ECMAScript规范开发过程 上的五个提案。

粘贴匹配标志 /y

在 ES6 中引入的粘性匹配 y 标志与全局标志 g 类似。像全局正则表达式一样,粘性通常用于匹配多次,直到输入字符串的结尾。粘性正则表达式将 lastIndex 移动到上一个匹配之后的位置,就像全局正则表达式一样。唯一的区别是,粘性正则表达式必须从前一个匹配结束的位置开始匹配,不同于全局正则表达式在任何给定位置不匹配时会移动到输入字符串的其余部分继续匹配。

以下示例说明了两者之间的区别。给出一个输入字符串如 'haha haha haha' 和正则表达式 /ha/,全局标志将匹配每一个 'ha',而粘标志只匹配前两个,因为第三次出现不在起始索引 4 ,而是索引 5

function matcher (regex, input) {
    return () => { 
        const match = regex.exec(input) 
        const lastIndex = regex.lastIndex 
        return { lastIndex, match } 
    }
}
const input = 'haha haha haha'
const nextGlobal = matcher(/ha/g, input) 
console.log(nextGlobal()) // <- { lastIndex: 2, match: ['ha'] }
console.log(nextGlobal()) // <- { lastIndex: 4, match: ['ha'] } 
console.log(nextGlobal()) // <- { lastIndex: 7, match: ['ha'] } 
const nextSticky = matcher(/ha/y, input) 
console.log(nextSticky()) // <- { lastIndex: 2, match: ['ha'] } 
console.log(nextSticky()) // <- { lastIndex: 4, match: ['ha'] } 
console.log(nextSticky()) // <- { lastIndex: 0, match: null }

如果我们用下一个代码强力移动 lastIndex ,我们可以验证粘性匹配器是可以正常工作的。

const rsticky = /ha/y 
const nextSticky = matcher(rsticky, input) 
console.log(nextSticky()) // <- { lastIndex: 2, match: ['ha'] } 
console.log(nextSticky()) // <- { lastIndex: 4, match: ['ha'] } 
rsticky.lastIndex = 5 console .log(nextSticky()) // <- { lastIndex: 7, match: ['ha'] }

将粘性匹配添加到 JavaScript 中是为了改进编译器中的性能,因为词法分析器严重依赖正则表达式。

Unicode 标志 /u

ES6 还引入了一个 u 标志。 u代表 Unicode,但是这个标志也可以被认为是更严格的正则表达式。

没有 u 标志,以下代码段是一个包含不必要转义的 'a' 字符文字的正则表达式。

/\a/.test('ab') // <- true

在带有 u 标志的正则表达式中使用非保留字符像 a 的转义形式会导致错误,如下面的代码位所示。

/\a/u.test('ab') // <- SyntaxError: Invalid regular expression: /\a/: Invalid escape

ES6 中增加了像 '\u{1f40e}' 等字符串,以下示例尝试通过用 \u{1f40e} 将马表情符号嵌入正则表达式中,但正则表达式无法与马表情符号匹配。没有 u 标志,\u{…} 模式被解释为具有不必要的转义的 u 字符和其后的其余部分。

/\u{1f40e}/.test('🐎') // <- false 
/\u{1f40e}/.test('u{1f40e}') // <- true

u 标志支持了正则表达式中 Unicode 代码转义,如 \u{1f40e} 马表情符号。

/\u{1f40e}/u.test('🐎') // <- true

没有 u 标志,. 会匹配任何 BMP 符号,除了行终止符和 astral 字符。以下示例是音乐中的高音谱号 𝄞,这是一种在普通正则表达式中不会被 . 匹配的 astral 符号。

const rdot = /^.$/
rdot.test('a') // <- true 
rdot.test('\n') // <- false 
rdot.test('\u{1d11e}') // <- false

当使用 u 标志时,不属于 BMP 的 Unicode 符号也会被匹配。下一个片段显示了 𝄞 符号在设置标志后如何被匹配。

const rdot = /^.$/u 
rdot.test('a') // <- true 
rdot.test('\n') // <- false 
rdot.test('\u{1d11e}') // <- true

u 标志被设置时,可以在数字和字符类中找到 Unicode 字符,这两者都将每个 Unicode 代码视为单个符号,而不是仅在第一个字符单元上进行匹配。i 标志匹配对大小写不敏感可以 u 标志设置时匹配 Unicode 的字母,用于对输入字符串和正则表达式中的代码进行归一化。

有关正则表达式中 u 标志的更多详细信息,请参阅 Mathias Bynens 的文章

命名捕获组

到目前为止,JavaScript 正则表达式可以对编号捕获组和非捕获组中的匹配进行分组。 在下一个片段中,我们使用分组来从包含由 '=' 分隔的键值对的输入字符串中提取键和值。

function parseKeyValuePair(input) { 
    const rattribute = /([a-z]+)=([a-z]+)/ 
    const [, key, value] = rattribute.exec(input) 
    return { key, value } 
} 
parseKeyValuePair( 'strong=true' ) 
// <- { key: 'strong', value: 'true' }

还有被丢弃的非捕获组,不存在于最终结果中,但仍然可用于匹配。以下示例支持使用 ' is ''=' 分隔的键值对的匹配。

function parseKeyValuePair(input) {
    const rattribute = /([a-z]+)(?:=|\sis\s)([a-z]+)/ 
    const [, key, value] = rattribute.exec(input) 
    return { key, value } 
} 
parseKeyValuePair( 'strong is true' ) // <- { key: 'strong', value: 'true' } 
parseKeyValuePair( 'flexible=too' ) // <- { key: 'flexible', value: 'too' }

尽管上一个示例中的数组解构隐藏了我们的代码对神奇的数组索引的依赖,但事实仍然是匹配是被放置在有序数组中的。命名捕获组提案 (在撰写本文时已处于第 3 阶段) 添加了类似 (?<groupName>) 的语法,我们可以在其中命名捕获组,然后其将返回到返回的匹配对象的 groups 属性中。当调用 RegExp#execString#match 时,groups 属性可以从结果对象中进行解析。

function parseKeyValuePair (input) { 
    const rattribute = /(?<key>[a-z]+)(?:=|\sis\s)(?<value>[a-z]+)/u 
    const { groups } = rattribute.exec(input) 
    return groups 
} 
parseKeyValuePair( 'strong=true' ) // <- { key: 'strong', value: 'true' } 
parseKeyValuePair( 'flexible=too' ) // <- { key: 'flexible', value: 'too' }

JavaScript 正则表达式支持反向引用,捕获的组可以重用于查找重复项。以下代码段使用第一个捕获组的反向引用来识别用户名与 'user:password' 输入中的密码相同的情况。

function hasSameUserAndPassword(input) { 
    const rduplicate = /([^:]+):\1/ 
    return rduplicate.exec(input) !== null
} 
hasSameUserAndPassword('root:root') // <- true
hasSameUserAndPassword('root:pF6GGlyPhoy1!9i') // <- false

命名的捕获组提案增加了对反向引用命名的支持。

function hasSameUserAndPassword(input) { 
    const rduplicate = /(?<user>[^:]+):\k<user>/u 
    return rduplicate.exec(input) !== null 
} 
hasSameUserAndPassword('root:root') // <- true 
hasSameUserAndPassword('root:pF6GGlyPhoy1!9i') // <- false

\k<groupName> 引用可以与编号引用一起使用,已经使用命名引用时就尽量避免使用后者。

最后,可以在传递给 String#replace 的替换中引用命名组。在下一个代码片段中,我们使用 String#replace 和命名组来把美国的日期字符串更改成匈牙利格式。

function americanDateToHungarianFormat(input) { 
    const ramerican = /(?<month>\d{2})\/(?<day>\d{2})\/(?<year>\d{4})/u 
    const hungarian = input.replace(ramerican, '$<year>-$<month>-$<day>') 
    return hungarian 
} 
americanDateToHungarianFormat('06/09/1988') // <- '1988-09-06'

如果 String#replace 的第二个参数是一个函数,则可以通过参数列表末尾的 groups 来访问命名组。该功能的要求参数现在是 (match, ...captures, groups)。在以下示例中,请注意我们如何使用类似于上一个示例中替换字符串的模板文字。事实上,替换字符串遵循 $<groupName> 语法而不是 ${groupName} 语法,这意味着如果我们使用模板文字,我们可以在替换字符串中命名组,而无需使用转义代码。

function americanDateToHungarianFormat(input) { 
    const ramerican = /(?<month>\d{2})\/(?<day>\d{2})\/(?<year>\d{4})/u 
    const hungarian = input.replace(ramerican, (match, capture1, capture2, capture3, groups) => { 
        const { month, day, year } = groups 
        return ${ year }-${ month }-${ day } 
    }) 
    return hungarian 
} 
americanDateToHungarianFormat( '06/09/1988' ) // <- '1988-09-06'

Unicode 属性转义

Unicode属性转义提案 (目前在第 3 阶段)是一种新的转义序列,可在有 u 标志的正则表达式中使用。该提案以\p{LoneUnicodePropertyNameOrValue} 的形式为二进制 Unicode 属性和\p{UnicodePropertyName=UnicodePropertyValue} 为非二进制 Unicode 属性添加了转义。另外,\P\p 转义序列的否定版本。

Unicode 标准为每个符号定义了属性。拥有这些属性,可以对 Unicode 字符进行高级查询。例如,希腊字母表中的符号具有设置为 GreekScript 属性。我们可以使用新的转义来匹配任何希腊语 Unicode 符号。

function isGreekSymbol(input) { 
    const rgreek = /^\p{Script=Greek}$/u 
    return rgreek.test(input) 
} 
isGreekSymbol('π') // <- true

或者,使用 \P ,我们可以匹配非希腊语 Unicode 符号。

function isNonGreekSymbol(input) {
    const rgreek = /^\P{Script=Greek}$/u 
    return rgreek.test(input) 
} 
isNonGreekSymbol('π') // <- false

当我们需要匹配每个 Unicode 十进制数字符号,而不只是像 \d 这样的 [0-9],我们可以使用 \p{Decimal_Number} 如下所示。

function isDecimalNumber(input) { 
    const rdigits = /^\p{Decimal_Number}+$/u 
    return rdigits.test(input) 
} 
isDecimalNumber( '𝟏𝟐𝟑𝟜𝟝𝟞𝟩𝟪𝟫𝟬𝟭𝟮𝟯𝟺𝟻𝟼' ) // <- true

下面的链接是支持的 Unicode 属性和值的完整列表。

后行(Lookbehind)断言

JavaScript 很早就有阳性的先行断言(lookahead assertions)。该功能允许我们匹配一个表达式,并且它的后面是另一个表达式。这些断言表示为 (?=…) 。无论先行断言是否匹配,该匹配的结果将被丢弃,并且不会输入输入字符串的字符。

以下示例使用一个阳性的先行断言测试输入字符串是否以 .js 结尾,在是的情况下,它将返回没有 .js 部分的文件名。

function getJavaScriptFilename(input) { 
    const rfile = /^(?<filename>[a-z]+)(?=\.js)\.[a-z]+$/u 
    const match = rfile.exec(input) 
    if (match === null ) { 
        return null 
    } 
    return match.groups.filename 
} 
getJavaScriptFilename( 'index.js' ) // <- 'index' 
getJavaScriptFilename( 'index.php' ) // <- null

还有阴性的先行断言,其表达为 (?!…) 而不是阳性先行断言的 (?=…)。在这种情况下,仅当先行断言不匹配时,断言才会成功。下面的代码使用了阴性的先行断言,我们可以观察结果如何不同:现在除 '.js' 之外的任何表达式都会导致断言成功。

function getNonJavaScriptFilename(input) { 
    const rfile = /^(?<filename>[az]+)(?!\.js)\.[az]+$/u 
    const match = rfile.exec(input) 
    if (match === null ) { 
        return null 
    } 
    return match.groups.filename 
} 
getNonJavaScriptFilename('index.js') // <- null 
getNonJavaScriptFilename('index.php') // <- 'index'

后行断言提案 (第 3 阶段) 引入了阳性和阴性的后行断言,分别用 (?<=…)(?<!…) 表示。这些断言可用于确保我们想要匹配的片段是不是紧跟在另一个给定片段之后。以下代码段使用阳性的后行断言来匹配美元金额的数字,但不匹配欧元。

function getDollarAmount(input) { 
    const rdollars = /^(?<=\$)(?<amount>\d+(?:\.\d+)?)$/u 
    const match = rdollars.exec(input) 
    if (match === null ) { 
        return null 
    } 
    return match.groups.amount 
} 
getDollarAmount('$12.34') // <- '12.34' 
getDollarAmount('€12.34') // <- null

另一方面,可以使用阴性的后行来匹配非美元符号的数字。

function getNonDollarAmount (input) { 
    const rnumbers = /^(?<!\$)(?<amount>\d+(?:\.\d+)?)$/u 
    const match = rnumbers.exec(input) 
    if (match === null ) { 
        return null 
    } 
    return match.groups.amount 
} 
getNonDollarAmount('$12.34') // <- null 
getNonDollarAmount('€12.34') // <- '12.34'

一个新的 /s dotAll标志

使用 . 时我们通常期望匹配每一个字符。然而,在JavaScript中,一个 . 表达式不匹配 astral 符号(可以通过添加 u 标志来修正),也不匹配行终止符。

const rcharacter = /^.$/ 
rcharacter.test('a') // <- true 
rcharacter.test('\t') // <- true 
rcharacter.test('\n') // <- false

这有时迫使开发人员编写其他类型的表达式来合成一个匹配任何字符的正则表达式。下一代码中的表达式匹配任何一个空格字符或非空白字符的字符,从而提供我们期望的 . 匹配行为

const rcharacter = /^[\s\S]$/ 
rcharacter.test('a') // <- true 
rcharacter.test('\t') // <- true 
rcharacter.test('\n') // <- true

dotAll提案 (第 3 阶段)添加了改变 . 行为的s标志,可以在 JavaScript 正则表达式中匹配任何单个字符。

const rcharacter = /^.$/s 
rcharacter.test('a') // <- true 
rcharacter.test('\t') // <- true 
rcharacter.test('\n') // <- true

String#matchAll

通常,当我们有一个具有全局或粘性标志的正则表达式时,我们想迭代捕获组集合中的每个匹配。目前,产生匹配列表可能有点麻烦:我们需要使用 String#matchRegExp#exec 在循环中收集捕获的组,直到正则表达式与最后一个 lastIndex 开始的输入不匹配为止。在下面的代码段中,parseAttributes 生成器函数只针对给定的正则表达式。

function *parseAttributes(input) { 
    const rattributes = /(\w+)="([^"]+)"\s/ig 
    while (true) { 
        const match = rattributes.exec(input) 
        if (match === null ) { 
            break 
        } 
        const [ , key, value] = match 
        yield [key, value] 
    } 
}
const html = '<input type="email" placeholder="hello@mjavascript.com" />' 
console.log(...parseAttributes(html)) 
// <- ['type', 'email'] ['placeholder', 'hello@mjavascript.com']

这种方法的一个问题是它是针对我们的正则表达式及其捕获组而量身定制的。我们可以通过创建一个 matchAll 生成器来解决这个问题,该生成器只关心循环匹配和收集捕获组的集合,如下面的代码片段所示。

function *matchAll(regex, input) {
    while (true) { 
        const match = regex.exec(input) 
        if (match === null ) { 
            break 
        } 
        const [ , ...captures] = match 
        yield captures 
    } 
} 
function *parseAttributes(input) { 
    const rattributes = /(\w+)="([^"]+)"\s/ig 
    yield *matchAll(rattributes, input) 
} 
const html = '<input type="email" placeholder="hello@mjavascript.com" />' 
console.log(...parseAttributes(html)) 
// <- ['type', 'email'] ['placeholder', 'hello@mjavascript.com']

一个更大的困惑是,在每次调用 RegExp#exec 时,这个 rattributes 会改变其 lastIndex 属性,这使得它可以记录最后一次匹配的位置。当没有匹配的时候,lastIndex 会重新设置为 0。当我们不一次性迭代一个输入的所有可能匹配时,会出现一个问题 —— 这会将 lastIndex 重置为 0 —— 然后我们在第二个输入上使用这个正则表达式,会获得意想不到的结果。

虽然看起来我们的 matchAll 实现不会成为这个问题的受害者,但是由于可以用生成器手动循环遍历所有匹配项,这意味着如果我们重复使用相同的正则表达式,我们就会遇到麻烦,如下代码所示。请注意,第二个匹配器本应该匹配怎样的 ['type', 'text'],而实际上却是从比 0 更远的索引开始匹配,甚至将 'placeholder' 键错匹配为 'laceholder'

const rattributes = /(\w+)="([^"]+)"\s/ig 
const email = '<input type="email" placeholder="hello@mjavascript.com" />' 
const emailMatcher = matchAll(rattributes, email) 
const address = '<input type="text" placeholder="Enter your business address" />' 
const addressMatcher = matchAll(rattributes, address) 
console.log(emailMatcher.next().value) // <- ['type', 'email'] 
console.log(addressMatcher.next().value) // <- ['laceholder', 'Enter your business address']

一个解决方案是改变 matchAll,使得当我们 yield 给 *parseAttributes 时,lastIndex 总是 0,同时在内部跟踪 lastIndex,以便我们可以在序列中从上次暂停的步骤开始执行。

以下代码显示,确实,这解决了我们提到的问题。 由于这个原因,经常会避免使用可重用的全局正则表达式:这样我们就不用担心每次使用后重新lastIndex

function *matchAll(regex, input) { 
    let lastIndex = 0 
    while (true) { 
        regex.lastIndex = lastIndex 
        const match = regex.exec(input) 
        if (match === null) { 
            break 
        } 
        lastIndex = regex.lastIndex 
        regex.lastIndex = 0 
        const [ , ...captures] = match 
        yield captures 
    } 
} 
const rattributes = /(\w+)="([^"]+)"\s/ig 
const email = '<input type="email" placeholder="hello@mjavascript.com" />' 
const emailMatcher = matchAll(rattributes, email) 
const address = '<input type="text" placeholder="Enter your business address" />' 
const addressMatcher = matchAll(rattributes, address) 
console.log(emailMatcher.next().value) // <- ['type', 'email'] 
console.log(addressMatcher.next().value) // <- ['type', 'text'] 
console.log(emailMatcher.next().value) // <- ['placeholder', 'hello@mjavascript.com'] 
console.log(addressMatcher.next().value) // <- ['placeholder', 'Enter your business address']

String#matchAll提案 (撰写本文时在第一阶段)在字符串原型上定义了一个新方法,它将与我们的 matchAll 类似的实现方式运行,除了返回的 iterable 是一个match对象的序列,而不仅仅是上面的例子中的 captures。请注意,String#matchAll 序列包含整个 match 对象,而不仅仅是编号的捕获。这意味着我们可以通过 match.groups 为序列中的每个 match 访问命名捕获。

const rattributes = /(?<key>\w+)="(?<value>[^"]+)"\s/igu 
const email = '<input type="email" placeholder="hello@mjavascript.com" />' 
for ( const { groups: { key, value } } of email.matchAll(rattributes)) { 
    console .log(${ key }: ${ value }) 
} 
// <- type: email 
// <- placeholder: hello@mjavascript.com
相关文章