23. 正则表达式的新特性
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

23. 正则表达式的新特性

本章解释 ECMAScript 6 中正则表达式的新特性。如果您熟悉 ES5 正则表达式特性和 Unicode,将有所帮助。如有必要,请参阅“Speaking JavaScript”的以下两章



23.1 概述

以下正则表达式特性是 ECMAScript 6 中新增的

23.2 新标志 /y(粘性匹配)

新标志 /y 在将正则表达式 re 与字符串匹配时会改变两件事

此匹配行为的主要用例是词法分析,您希望每个匹配项紧跟在其前一个匹配项之后。稍后将给出一个通过粘性正则表达式和 exec() 进行词法分析的示例。

让我们看看各种正则表达式操作如何对 /y 标志做出反应。下表概述了这些内容。我将在后面提供更多详细信息。

正则表达式的方法(re 是调用方法的正则表达式)

  标志 开始匹配 锚定到 匹配时的结果 不匹配 re.lastIndex
exec() 0 匹配对象 null 不变
  /g re.lastIndex 匹配对象 null 匹配后的索引
  /y re.lastIndex re.lastIndex 匹配对象 null 匹配后的索引
  /gy re.lastIndex re.lastIndex 匹配对象 null 匹配后的索引
test() (任意) (类似于 exec()) (类似于 exec()) true false (类似于 exec())

字符串的方法(str 是调用方法的字符串,r 是正则表达式参数)

  标志 开始匹配 锚定到 匹配时的结果 不匹配 r.lastIndex
search() –, /g 0 匹配项的索引 -1 不变
  /y, /gy 0 0 匹配项的索引 -1 不变
match() 0 匹配对象 null 不变
  /y r.lastIndex r.lastIndex 匹配对象 null 之后的索引
            匹配
  /g 上一个之后 包含匹配项的数组 null 0
    匹配(循环)        
  /gy 上一个之后 上一个之后 包含匹配项的数组 null 0
    匹配(循环) 匹配      
split() –, /g 上一个之后 包含字符串的数组 [str] 不变
    匹配(循环)   匹配项之间    
  /y, /gy 上一个之后 上一个之后 包含空字符串的数组 [str] 不变
    匹配(循环) 匹配 匹配项之间    
replace() 0 替换第一个匹配项 无替换 不变
  /y 0 0 替换第一个匹配项 无替换 不变
  /g 上一个之后 替换所有匹配项 无替换 不变
    匹配(循环)        
  /gy 上一个之后 上一个之后 替换所有匹配项 无替换 不变
    匹配(循环) 匹配      

23.2.1 RegExp.prototype.exec(str)

如果未设置 /g,则匹配始终从开头开始,但会跳过直到找到匹配项。 REGEX.lastIndex 不变。

const REGEX = /a/;

REGEX.lastIndex = 7; // ignored
const match = REGEX.exec('xaxa');
console.log(match.index); // 1
console.log(REGEX.lastIndex); // 7 (unchanged)

如果设置了 /g,则匹配从 REGEX.lastIndex 开始,并跳过直到找到匹配项。 REGEX.lastIndex 设置为匹配项之后的位置。这意味着,如果您循环直到 exec() 返回 null,您将收到所有匹配项。

const REGEX = /a/g;

REGEX.lastIndex = 2;
const match = REGEX.exec('xaxa');
console.log(match.index); // 3
console.log(REGEX.lastIndex); // 4 (updated)

// No match at index 4 or later
console.log(REGEX.exec('xaxa')); // null

如果仅设置了 /y,则匹配从 REGEX.lastIndex 开始,并锚定到该位置(不会跳过直到找到匹配项)。 REGEX.lastIndex 的更新方式与设置 /g 时的更新方式类似。

const REGEX = /a/y;

// No match at index 2
REGEX.lastIndex = 2;
console.log(REGEX.exec('xaxa')); // null

// Match at index 3
REGEX.lastIndex = 3;
const match = REGEX.exec('xaxa');
console.log(match.index); // 3
console.log(REGEX.lastIndex); // 4

同时设置 /y/g 与仅设置 /y 相同。

23.2.2 RegExp.prototype.test(str)

test() 的工作方式与 exec() 相同,但它在匹配成功或失败时返回 truefalse(而不是匹配对象或 null)。

const REGEX = /a/y;

REGEX.lastIndex = 2;
console.log(REGEX.test('xaxa')); // false

REGEX.lastIndex = 3;
console.log(REGEX.test('xaxa')); // true
console.log(REGEX.lastIndex); // 4

23.2.3 String.prototype.search(regex)

search() 忽略标志 /glastIndex(它也不会改变)。它从字符串的开头开始,查找第一个匹配项并返回其索引(如果没有匹配项,则返回 -1)。

const REGEX = /a/;

REGEX.lastIndex = 2; // ignored
console.log('xaxa'.search(REGEX)); // 1

如果设置了标志 /y,则仍然会忽略 lastIndex,但正则表达式现在锚定到索引 0。

const REGEX = /a/y;

REGEX.lastIndex = 1; // ignored
console.log('xaxa'.search(REGEX)); // -1 (no match)

23.2.4 String.prototype.match(regex)

match() 有两种模式

如果未设置标志 /g,则 match()exec() 一样捕获组

{
    const REGEX = /a/;

    REGEX.lastIndex = 7; // ignored
    console.log('xaxa'.match(REGEX).index); // 1
    console.log(REGEX.lastIndex); // 7 (unchanged)
}
{
    const REGEX = /a/y;

    REGEX.lastIndex = 2;
    console.log('xaxa'.match(REGEX)); // null

    REGEX.lastIndex = 3;
    console.log('xaxa'.match(REGEX).index); // 3
    console.log(REGEX.lastIndex); // 4
}

如果仅设置了标志 /g,则 match() 在数组中返回所有匹配的子字符串(或 null)。匹配始终从位置 0 开始。

const REGEX = /a|b/g;
REGEX.lastIndex = 7;
console.log('xaxb'.match(REGEX)); // ['a', 'b']
console.log(REGEX.lastIndex); // 0

如果还设置了标志 /y,则仍然会重复执行匹配,同时将正则表达式锚定到上一个匹配项之后的索引(或 0)。

const REGEX = /a|b/gy;

REGEX.lastIndex = 0; // ignored
console.log('xab'.match(REGEX)); // null
REGEX.lastIndex = 1; // ignored
console.log('xab'.match(REGEX)); // null

console.log('ab'.match(REGEX)); // ['a', 'b']
console.log('axb'.match(REGEX)); // ['a']

23.2.5 String.prototype.split(separator, limit)

split() 的完整详细信息在 Speaking JavaScript 中有解释

对于 ES6,有趣的是看看如果使用标志 /y 会发生什么变化。

使用 /y 时,字符串必须以分隔符开头

> 'x##'.split(/#/y) // no match
[ 'x##' ]
> '##x'.split(/#/y) // 2 matches
[ '', '', 'x' ]

只有当后续分隔符紧跟在第一个分隔符之后时,才会识别它们

> '#x#'.split(/#/y) // 1 match
[ '', 'x#' ]
> '##'.split(/#/y) // 2 matches
[ '', '', '' ]

这意味着第一个分隔符之前的字符串和分隔符之间的字符串始终为空。

像往常一样,您可以使用组将分隔符的部分放入结果数组中

> '##'.split(/(#)/y)
[ '', '#', '', '#', '' ]

23.2.6 String.prototype.replace(search, replacement)

如果没有标志 /g,则 replace() 仅替换第一个匹配项

const REGEX = /a/;

// One match
console.log('xaxa'.replace(REGEX, '-')); // 'x-xa'

如果仅设置了 /y,则最多也只能获得一个匹配项,但该匹配项始终锚定到字符串的开头。 lastIndex 被忽略且不变。

const REGEX = /a/y;

// Anchored to beginning of string, no match
REGEX.lastIndex = 1; // ignored
console.log('xaxa'.replace(REGEX, '-')); // 'xaxa'
console.log(REGEX.lastIndex); // 1 (unchanged)

// One match
console.log('axa'.replace(REGEX, '-')); // '-xa'

设置 /g 后,replace() 会替换所有匹配项

const REGEX = /a/g;

// Multiple matches
console.log('xaxa'.replace(REGEX, '-')); // 'x-x-'

设置 /gy 后,replace() 会替换所有匹配项,但每个匹配项都锚定到上一个匹配项的末尾

const REGEX = /a/gy;

// Multiple matches
console.log('aaxa'.replace(REGEX, '-')); // '--xa'

参数 replacement 也可以是一个函数,有关详细信息,请参阅“Speaking JavaScript”

23.2.7 示例:使用粘性匹配进行词法分析

粘性匹配的主要用例是*词法分析*,即将文本转换为一系列标记。词法分析的一个重要特征是标记是文本的片段,并且它们之间不能有间隙。因此,粘性匹配在这里非常完美。

function tokenize(TOKEN_REGEX, str) {
    const result = [];
    let match;
    while (match = TOKEN_REGEX.exec(str)) {
        result.push(match[1]);
    }
    return result;
}

const TOKEN_GY = /\s*(\+|[0-9]+)\s*/gy;
const TOKEN_G  = /\s*(\+|[0-9]+)\s*/g;

在合法的标记序列中,粘性匹配和非粘性匹配产生相同的输出

> tokenize(TOKEN_GY, '3 + 4')
[ '3', '+', '4' ]
> tokenize(TOKEN_G, '3 + 4')
[ '3', '+', '4' ]

但是,如果字符串中存在非标记文本,则粘性匹配会停止词法分析,而非粘性匹配会跳过非标记文本

> tokenize(TOKEN_GY, '3x + 4')
[ '3' ]
> tokenize(TOKEN_G, '3x + 4')
[ '3', '+', '4' ]

词法分析期间粘性匹配的行为有助于错误处理。

23.2.8 示例:手动实现粘性匹配

如果要手动实现粘性匹配,可以按如下方式进行:函数 execSticky() 的工作方式类似于粘性模式下的 RegExp.prototype.exec()

 function execSticky(regex, str) {
     // Anchor the regex to the beginning of the string
     let matchSource = regex.source;
     if (!matchSource.startsWith('^')) {
         matchSource = '^' + matchSource;
     }
     // Ensure that instance property `lastIndex` is updated
     let matchFlags = regex.flags; // ES6 feature!
     if (!regex.global) {
         matchFlags = matchFlags + 'g';
     }
     const matchRegex = new RegExp(matchSource, matchFlags);

     // Ensure we start matching `str` at `regex.lastIndex`
     const matchOffset = regex.lastIndex;
     const matchStr = str.slice(matchOffset);
     let match = matchRegex.exec(matchStr);

     // Translate indices from `matchStr` to `str`
     regex.lastIndex = matchRegex.lastIndex + matchOffset;
     match.index = match.index + matchOffset;
     return match;
 }

23.3 新标志 /u(Unicode)

标志 /u 为正则表达式启用特殊的 Unicode 模式。该模式具有两个特性

  1. 您可以使用 Unicode 代码点转义序列(例如 \u{1F42A})通过代码点指定字符。普通的 Unicode 转义序列(例如 \u03B1)只有四个十六进制数字的范围(等于基本多语言平面)。
  2. 正则表达式模式和字符串中的“字符”是代码点(而不是 UTF-16 代码单元)。代码单元将转换为代码点。

Unicode 章节中的一个部分包含有关转义序列的更多信息。接下来我将解释特性 2 的结果。我使用两个 UTF-16 代码单元(例如 \uD83D\uDE80)而不是 Unicode 代码点转义符(例如 \u{1F680})。这清楚地表明代理对在 Unicode 模式下是分组的,并且在 Unicode 模式和非 Unicode 模式下都可以工作。

> '\u{1F680}' === '\uD83D\uDE80' // code point vs. surrogate pairs
true

23.3.1 结果:正则表达式中的孤立代理项仅匹配孤立代理项

在非 Unicode 模式下,即使在(编码代理对的)代码点内部也能找到正则表达式中的孤立代理项

> /\uD83D/.test('\uD83D\uDC2A')
true

在 Unicode 模式下,代理对成为原子单元,并且在它们“内部”找不到孤立代理项

> /\uD83D/u.test('\uD83D\uDC2A')
false

仍然可以找到实际的孤立代理项

> /\uD83D/u.test('\uD83D \uD83D\uDC2A')
true
> /\uD83D/u.test('\uD83D\uDC2A \uD83D')
true

23.3.2 结果:您可以在字符类中放置代码点

在 Unicode 模式下,您可以将代码点放入字符类中,并且它们将不再被解释为两个字符。

> /^[\uD83D\uDC2A]$/u.test('\uD83D\uDC2A')
true
> /^[\uD83D\uDC2A]$/.test('\uD83D\uDC2A')
false

> /^[\uD83D\uDC2A]$/u.test('\uD83D')
false
> /^[\uD83D\uDC2A]$/.test('\uD83D')
true

23.3.3 结果:点运算符 (.) 匹配代码点,而不是代码单元

在 Unicode 模式下,点运算符匹配代码点(一个或两个代码单元)。在非 Unicode 模式下,它匹配单个代码单元。例如

> '\uD83D\uDE80'.match(/./gu).length
1
> '\uD83D\uDE80'.match(/./g).length
2

23.3.4 结果:量词应用于代码点,而不是代码单元

在 Unicode 模式下,量词应用于代码点(一个或两个代码单元)。在非 Unicode 模式下,它们应用于单个代码单元。例如

> /\uD83D\uDE80{2}/u.test('\uD83D\uDE80\uD83D\uDE80')
true

> /\uD83D\uDE80{2}/.test('\uD83D\uDE80\uD83D\uDE80')
false
> /\uD83D\uDE80{2}/.test('\uD83D\uDE80\uDE80')
true

23.4 新的数据属性 flags

在 ECMAScript 6 中,正则表达式具有以下数据属性

顺便提一下,lastIndex 现在是唯一的实例属性,所有其他数据属性都通过内部实例属性和 getter 实现,例如 get RegExp.prototype.global

属性 source(在 ES5 中已存在)包含作为字符串的正则表达式模式。

> /abc/ig.source
'abc'

属性 flags 是新增的,它包含作为字符串的标志,每个标志对应一个字符。

> /abc/ig.flags
'gi'

您不能更改现有正则表达式的标志(ignoreCase 等一直是不可变的),但 flags 允许您创建标志已更改的副本。

function copyWithIgnoreCase(re) {
    return new RegExp(re.source,
        re.flags.includes('i') ? re.flags : re.flags+'i');
}

下一节将解释另一种创建正则表达式修改副本的方法。

23.5 RegExp() 可用作复制构造函数

在 ES6 中,构造函数 RegExp() 有两种变体(第二种是新增的)。

以下交互演示了后一种变体。

> new RegExp(/abc/ig).flags
'gi'
> new RegExp(/abc/ig, 'i').flags // change flags
'i'

因此,RegExp 构造函数为我们提供了另一种更改标志的方法。

function copyWithIgnoreCase(re) {
    return new RegExp(re,
        re.flags.includes('i') ? re.flags : re.flags+'i');
}

23.5.1 示例:exec() 的可迭代版本

以下函数 execAll()exec() 的可迭代版本,它修复了使用 exec() 检索正则表达式的所有匹配项时出现的几个问题。

function* execAll(regex, str) {
    // Make sure flag /g is set and regex.index isn’t changed
    const localCopy = copyAndEnsureFlag(regex, 'g');
    let match;
    while (match = localCopy.exec(str)) {
        yield match;
    }
}
function copyAndEnsureFlag(re, flag) {
    return new RegExp(re,
        re.flags.includes(flag) ? re.flags : re.flags+flag);
}

使用 execAll()

const str = '"fee" "fi" "fo" "fum"';
const regex = /"([^"]*)"/;

// Access capture of group #1 via destructuring
for (const [, group1] of execAll(regex, str)) {
    console.log(group1);
}
// Output:
// fee
// fi
// fo
// fum

23.6 委托给正则表达式方法的字符串方法

以下字符串方法现在将其部分工作委托给正则表达式方法。

有关更多信息,请参阅字符串章节中的“将正则表达式工作委托给其参数的字符串方法”部分。

下一页:24. 异步编程(后台)