面向急切程序员的 JavaScript(ES2022 版)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

43 正则表达式 (RegExp)



  功能可用性

除非另有说明,否则每个正则表达式功能自 ES3 起均可用。

43.1 创建正则表达式

43.1.1 字面量 vs. 构造函数

创建正则表达式的两种主要方式是

两种正则表达式都具有相同的两部分

43.1.2 克隆和非破坏性修改正则表达式

构造函数 RegExp() 有两种变体

第二种变体对于克隆正则表达式很有用,可以选择同时修改它们。标志是不可变的,这是更改它们的唯一方法 – 例如

function copyAndAddFlags(regExp, flagsToAdd='') {
  // The constructor doesn’t allow duplicate flags;
  // make sure there aren’t any:
  const newFlags = Array.from(
    new Set(regExp.flags + flagsToAdd)
  ).join('');
  return new RegExp(regExp, newFlags);
}
assert.equal(/abc/i.flags, 'i');
assert.equal(copyAndAddFlags(/abc/i, 'g').flags, 'gi');

43.2 语法

43.2.1 语法字符

在正则表达式的顶层,以下语法字符是特殊的。它们通过在前面加上反斜杠 (\) 来转义。

\ ^ $ . * + ? ( ) [ ] { } |

在正则表达式字面量中,我们必须转义斜杠

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

new RegExp() 的参数中,我们不必转义斜杠

> new RegExp('/').test('/')
true

43.2.2 基本原子

原子是正则表达式的基本构建块。

43.2.3 Unicode 属性转义 [ES2018]

43.2.3.1 Unicode 字符属性

在 Unicode 标准中,每个字符都有属性 – 描述它的元数据。属性在定义字符的性质方面起着重要作用。引用Unicode 标准,第 3.3 节,D3

字符的语义由其标识、规范属性和行为决定。

以下是属性的一些示例

43.2.3.2 Unicode 属性转义

Unicode 属性转义如下所示

  1. \p{prop=value}:匹配属性 prop 的值为 value 的所有字符。
  2. \P{prop=value}:匹配属性 prop 的值不是 value 的所有字符。
  3. \p{bin_prop}:匹配二进制属性 bin_prop 为 True 的所有字符。
  4. \P{bin_prop}:匹配二进制属性 bin_prop 为 False 的所有字符。

注释

示例

进一步阅读

43.2.4 字符类

字符类字符范围括在方括号中。字符范围指定一组字符

字符范围规则

43.2.5 组

43.2.6 量词

默认情况下,以下所有量词都是贪婪的(它们匹配尽可能多的字符)

要使它们不情愿(以便它们匹配尽可能少的字符),请在它们后面加上问号 (?)

> /".*"/.exec('"abc"def"')[0]  // greedy
'"abc"def"'
> /".*?"/.exec('"abc"def"')[0] // reluctant
'"abc"'

43.2.7 断言

43.2.7.1 先行断言

正先行断言: (?=«模式») 如果 模式 匹配后面的内容,则匹配。

示例:后跟 X 的小写字母序列。

> 'abcX def'.match(/[a-z]+(?=X)/g)
[ 'abc' ]

请注意,X 本身不是匹配子字符串的一部分。

负先行断言: (?!«模式») 如果 模式 不匹配后面的内容,则匹配。

示例:后跟 X 的小写字母序列。

> 'abcX def'.match(/[a-z]+(?!X)/g)
[ 'ab', 'def' ]
43.2.7.2 后顾断言 [ES2018]

正向断言: (?<=«pattern») 如果 pattern 与前面的内容匹配,则匹配。

示例:以 X 开头的连续小写字母序列。

> 'Xabc def'.match(/(?<=X)[a-z]+/g)
[ 'abc' ]

负向断言: (?<!«pattern») 如果 pattern 与前面的内容不匹配,则匹配。

示例:不以 X 开头的连续小写字母序列。

> 'Xabc def'.match(/(?<!X)[a-z]+/g)
[ 'bc', 'def' ]

示例:将“.js”替换为“.html”,但“Node.js”中的“.js”除外。

> 'Node.js: index.js and main.js'.replace(/(?<!Node)\.js/g, '.html')
'Node.js: index.html and main.html'

43.2.8 析取 (|)

注意:此运算符的优先级较低。如有必要,请使用组

43.3 标志

表 21:这些是 JavaScript 支持的正则表达式标志。
字面标志 属性名称 ES 描述
d hasIndices ES2022 开启匹配索引
g global ES3 多次匹配
i ignoreCase ES3 不区分大小写匹配
m multiline ES3 ^$ 匹配每行
s dotAll ES2018 点匹配行终止符
u unicode ES6 Unicode 模式(推荐)
y sticky ES6 匹配之间没有字符

以下正则表达式标志在 JavaScript 中可用(表 21 提供了一个简要概述)

43.3.1 如何对正则表达式标志进行排序?

请考虑以下正则表达式:/“([^”]+)”/udg

我们应该按什么顺序列出其标志?有两种选择

  1. 字母顺序:/dgu
  2. 按重要性顺序(可以说,/u 是最基本的,等等):/ugd

鉴于 (2) 不明显,(1) 是更好的选择。JavaScript 也将其用于 RegExp 属性 .flags

> /a/ismudgy.flags
'dgimsuy'

43.3.2 标志:通过 /u 使用 Unicode 模式

标志 /u 为正则表达式开启了一种特殊的 Unicode 模式。该模式启用了几个功能

以下小节更详细地解释了最后一项。它们使用以下 Unicode 字符来解释何时原子单位是 Unicode 字符,何时是 JavaScript 字符

const codePoint = '🙂';
const codeUnits = '\uD83D\uDE42'; // UTF-16

assert.equal(codePoint, codeUnits); // same string!

我只是在 🙂\uD83D\uDE42 之间切换,以说明 JavaScript 如何看待事物。两者是等效的,可以在字符串和正则表达式中互换使用。

43.3.2.1 结果:我们可以将 Unicode 字符放入字符类中

使用 /u🙂 的两个代码单元被视为单个字符

> /^[🙂]$/u.test('🙂')
true

如果没有 /u,则 🙂 被视为两个字符

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

请注意,^$ 要求输入字符串只有一个字符。这就是第一个结果为 false 的原因。

43.3.2.2 结果:点运算符 (.) 匹配 Unicode 字符,而不是 JavaScript 字符

使用 /u,点运算符匹配 Unicode 字符

> '🙂'.match(/./gu).length
1

.match() 加上 /g 返回一个包含正则表达式所有匹配项的数组。

如果没有 /u,则点运算符匹配 JavaScript 字符

> '\uD83D\uDE80'.match(/./g).length
2
43.3.2.3 结果:量词应用于 Unicode 字符,而不是 JavaScript 字符

使用 /u,量词应用于整个前面的 Unicode 字符

> /^🙂{3}$/u.test('🙂🙂🙂')
true

如果没有 /u,则量词仅应用于前面的 JavaScript 字符

> /^\uD83D\uDE80{3}$/.test('\uD83D\uDE80\uDE80\uDE80')
true

43.4 正则表达式对象的属性

值得注意的是

43.4.1 标志作为属性

每个正则表达式标志都作为一个具有更长、更具描述性名称的属性存在

> /a/i.ignoreCase
true
> /a/.ignoreCase
false

以下是标志属性的完整列表

43.4.2 其他属性

每个正则表达式还具有以下属性

43.5 匹配对象

几个与正则表达式相关的方法返回所谓的匹配对象,以提供有关正则表达式匹配输入字符串的位置的详细信息。这些方法是

这是一个例子

assert.deepEqual(
  /(a+)b/d.exec('ab aaab'),
  {
    0: 'ab',
    1: 'a',
    index: 0,
    input: 'ab aaab',
    groups: undefined,
    indices: {
      0: [0, 2],
      1: [0, 1],
      groups: undefined
    },
  }
);

.exec() 的结果是第一个匹配的匹配对象,具有以下属性

43.5.1 匹配对象中的匹配索引 [ES2022]

匹配索引是匹配对象的一个功能:如果我们通过正则表达式标志 /d(属性 .hasIndices)将其打开,则它们会记录捕获组的开始和结束索引。

43.5.1.1 编号组的匹配索引

以下是我们如何访问编号组的捕获

const matchObj = /(a+)(b+)/d.exec('aaaabb');
assert.equal(
  matchObj[1], 'aaaa'
);
assert.equal(
  matchObj[2], 'bb'
);

由于正则表达式标志 /dmatchObj 还具有一个属性 .indices,该属性记录每个编号组在输入字符串中的捕获位置

assert.deepEqual(
  matchObj.indices[1], [0, 4]
);
assert.deepEqual(
  matchObj.indices[2], [4, 6]
);
43.5.1.2 命名组的匹配索引

命名组的捕获访问方式如下

const matchObj = /(?<as>a+)(?<bs>b+)/d.exec('aaaabb');
assert.equal(
  matchObj.groups.as, 'aaaa');
assert.equal(
  matchObj.groups.bs, 'bb');

它们的索引存储在 matchObj.indices.groups

assert.deepEqual(
  matchObj.indices.groups.as, [0, 4]);
assert.deepEqual(
  matchObj.indices.groups.bs, [4, 6]);
43.5.1.3 更现实的例子

匹配索引的一个重要用例是解析器,它们指向语法错误的确切位置。以下代码解决了相关问题:它指向引号内容的开始和结束位置(请参阅末尾的演示)。

const reQuoted = /“([^”]+)”/dgu;
function pointToQuotedText(str) {
  const startIndices = new Set();
  const endIndices = new Set();
  for (const match of str.matchAll(reQuoted)) {
    const [start, end] = match.indices[1];
    startIndices.add(start);
    endIndices.add(end);
  }
  let result = '';
  for (let index=0; index < str.length; index++) {
    if (startIndices.has(index)) {
      result += '[';
    } else if (endIndices.has(index+1)) {
      result += ']';
    } else {
      result += ' ';
    }
  }
  return result;
}

assert.equal(
  pointToQuotedText(
    'They said “hello” and “goodbye”.'),
    '           [   ]       [     ]  '
);

43.6 使用正则表达式的方法

43.6.1 默认情况下,正则表达式匹配字符串中的任何位置

默认情况下,正则表达式匹配字符串中的任何位置

> /a/.test('__a__')
true

我们可以通过使用断言(例如 ^)或使用标志 /y 来更改它

> /^a/.test('__a__')
false
> /^a/.test('a__')
true

43.6.2 regExp.test(str):是否有匹配项? [ES3]

如果 regExp 匹配 str,则正则表达式方法 .test() 返回 true

> /bc/.test('ABCD')
false
> /bc/i.test('ABCD')
true
> /\.mjs$/.test('main.mjs')
true

使用 .test() 时,我们通常应避免使用 /g 标志。如果我们使用它,我们通常不会在每次调用该方法时都得到相同的结果

> const r = /a/g;
> r.test('aab')
true
> r.test('aab')
true
> r.test('aab')
false

结果是由于 /a/ 在字符串中有两个匹配项。在找到所有匹配项后,.test() 返回 false

43.6.3 str.search(regExp):匹配项位于哪个索引? [ES3]

字符串方法 .search() 返回 strregExp 匹配的第一个索引

> '_abc_'.search(/abc/)
1
> 'main.mjs'.search(/\.mjs$/)
4

43.6.4 regExp.exec(str):捕获组 [ES3]

43.6.4.1 获取第一个匹配的匹配对象

如果没有标志 /g,则 .exec() 返回 strregExp 的第一个匹配的 匹配对象

assert.deepEqual(
  /(a+)b/.exec('ab aab'),
  {
    0: 'ab',
    1: 'a',
    index: 0,
    input: 'ab aab',
    groups: undefined,
  }
);
43.6.4.2 命名捕获组 [ES2018]

前面的示例包含一个编号组。以下示例演示了命名组

assert.deepEqual(
  /(?<as>a+)b/.exec('ab aab'),
  {
    0: 'ab',
    1: 'a',
    index: 0,
    input: 'ab aab',
    groups: { as: 'a' },
  }
);

.exec() 的结果中,我们可以看到命名组也是一个编号组 - 它的捕获存在两次

43.6.4.3 循环遍历所有匹配项

  检索所有匹配项的更好选择:str.matchAll(regExp) [ES2020]

从 ECMAScript 2020 开始,JavaScript 还有另一种检索所有匹配项的方法:str.matchAll(regExp)。此方法更易于使用,并且注意事项更少。

如果我们想检索正则表达式的所有匹配项(而不仅仅是第一个),则需要打开标志 /g。然后我们可以多次调用 .exec(),每次获取一个匹配项。在最后一个匹配项之后,.exec() 返回 null

> const regExp = /(a+)b/g;
> regExp.exec('ab aab')
{ 0: 'ab', 1: 'a', index: 0, input: 'ab aab', groups: undefined }
> regExp.exec('ab aab')
{ 0: 'aab', 1: 'aa', index: 3, input: 'ab aab', groups: undefined }
> regExp.exec('ab aab')
null

因此,我们可以按如下方式循环遍历所有匹配项

const regExp = /(a+)b/g;
const str = 'ab aab';

let match;
// Check for null via truthiness
// Alternative: while ((match = regExp.exec(str)) !== null)
while (match = regExp.exec(str)) {
  console.log(match[1]);
}
// Output:
// 'a'
// 'aa'

  /g 共享正则表达式时要小心!

使用 /g 共享正则表达式有一些陷阱,这些陷阱将在稍后解释。

  练习:通过 .exec() 提取引用的文本

exercises/regexps/extract_quoted_test.mjs

43.6.5 str.match(regExp):获取所有组 0 捕获 [ES3]

如果没有 /g.match() 的工作方式与 .exec() 相同 - 它返回一个匹配对象。

使用 /g.match() 返回与 regExp 匹配的 str 的所有子字符串

> 'ab aab'.match(/(a+)b/g)
[ 'ab', 'aab' ]

如果没有匹配项,.match() 返回 null

> 'xyz'.match(/(a+)b/g)
null

我们可以使用空值合并运算符 (??) 来防止 null

const numberOfMatches = (str.match(regExp) ?? []).length;

43.6.6 str.matchAll(regExp):获取所有匹配对象的迭代器 [ES2020]

以下是调用 .matchAll() 的方式

const matchIterable = str.matchAll(regExp);

给定一个字符串和一个正则表达式,.matchAll() 返回所有匹配项的匹配对象的迭代器。

在以下示例中,我们使用Array.from() 将迭代器转换为数组,以便更好地比较它们。

> Array.from('-a-a-a'.matchAll(/-(a)/ug))
[
  { 0:'-a', 1:'a', index: 0, input: '-a-a-a', groups: undefined },
  { 0:'-a', 1:'a', index: 2, input: '-a-a-a', groups: undefined },
  { 0:'-a', 1:'a', index: 4, input: '-a-a-a', groups: undefined },
]

必须设置标志 /g

> Array.from('-a-a-a'.matchAll(/-(a)/u))
TypeError: String.prototype.matchAll called with a non-global
RegExp argument

.matchAll() 不受 regExp.lastIndex 的影响,也不会更改它。

43.6.6.1 实现 .matchAll()

.matchAll() 可以通过 .exec() 实现,如下所示

function* matchAll(str, regExp) {
  if (!regExp.global) {
    throw new TypeError('Flag /g must be set!');
  }
  const localCopy = new RegExp(regExp, regExp.flags);
  let match;
  while (match = localCopy.exec(str)) {
    yield match;
  }
}

创建本地副本可确保两件事

使用 matchAll()

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

for (const match of matchAll(str, regex)) {
  console.log(match[1]);
}
// Output:
// 'fee'
// 'fi'
// 'fo'
// 'fum'

43.6.7 regExp.exec() 与 str.match() 与 str.matchAll()

下表总结了三种方法之间的区别

没有 /g 使用 /g
regExp.exec(str) 第一个匹配对象 下一个匹配对象或 null
str.match(regExp) 第一个匹配对象 组 0 捕获的数组
str.matchAll(regExp) TypeError 匹配对象的迭代器

43.6.8 使用 str.replace()str.replaceAll() 替换

两种替换方法都有两个参数

searchValue 可以是

replacementValue 可以是

这两种方法的区别如下

下表总结了它是如何工作的

搜索: 字符串 没有 /g 的 RegExp 带有 /g 的 RegExp
.replace 第一次出现 第一次出现 (所有出现)
.replaceAll 所有出现 TypeError 所有出现

.replace() 的最后一列括在括号中,因为此方法在 .replaceAll() 之前很久就已存在,因此支持现在应该由后者方法处理的功能。如果我们可以更改它,.replace() 将在此处抛出 TypeError

我们首先探讨当 replacementValue 是一个简单字符串(不带字符 $)时,.replace().replaceAll() 如何单独工作。然后,我们检查两者如何受更复杂的替换值的影响。

43.6.8.1 str.replace(searchValue, replacementValue) [ES3]

.replace() 的操作方式受其第一个参数 searchValue 的影响

如果我们要替换字符串的每次出现,我们有两个选择

43.6.8.2 str.replaceAll(searchValue, replacementValue) [ES2021]

.replaceAll() 的操作方式受其第一个参数 searchValue 的影响

43.6.8.3 .replace().replaceAll() 的参数 replacementValue

到目前为止,我们只将参数 replacementValue 与简单字符串一起使用,但它可以做更多的事情。如果它的值是

43.6.8.4 replacementValue 是一个字符串

如果替换值是一个字符串,则美元符号具有特殊含义 - 它插入与正则表达式匹配的文本

文本 结果
$$ 单个 $
$& 完全匹配
$` 匹配之前的文本
$' 匹配之后的文本
$n 编号组 n 的捕获(n > 0)
$<name> 命名组 name 的捕获 [ES2018]

示例:插入匹配子字符串之前、之中和之后的文本。

> 'a1 a2'.replaceAll(/a/g, "($`|$&|$')")
'(|a|1 a2)1 (a1 |a|2)2'

示例:插入编号组的捕获。

> const regExp = /^([A-Za-z]+): (.*)$/ug;
> 'first: Jane'.replaceAll(regExp, 'KEY: $1, VALUE: $2')
'KEY: first, VALUE: Jane'

示例:插入命名组的捕获。

> const regExp = /^(?<key>[A-Za-z]+): (?<value>.*)$/ug;
> 'first: Jane'.replaceAll(regExp, 'KEY: $<key>, VALUE: $<value>')
'KEY: first, VALUE: Jane'

  练习:通过 .replace() 和命名组更改引号

exercises/regexps/change_quotes_test.mjs

43.6.8.5 replacementValue 是一个函数

如果替换值是一个函数,我们可以计算每个替换。在以下示例中,我们将找到的每个非负整数乘以 2。

assert.equal(
  '3 cats and 4 dogs'.replaceAll(/[0-9]+/g, (all) => 2 * Number(all)),
  '6 cats and 8 dogs'
);

替换函数获取以下参数。请注意它们与匹配对象的相似之处。这些参数都是位置参数,但我已经包含了如何命名它们

如果我们只对 groups 感兴趣,我们可以使用以下技术

const result = 'first=jane, last=doe'.replace(
  /(?<key>[a-z]+)=(?<value>[a-z]+)/g,
  (...args) => { // (A)
    const groups = args.at(-1); // (B)
    const {key, value} = groups;
    return key.toUpperCase() + '=' + value.toUpperCase();
  });
assert.equal(result, 'FIRST=JANE, LAST=DOE');

由于 A 行中的剩余参数args 包含一个包含所有参数的数组。我们通过 B 行中的数组方法 .at() 访问最后一个参数。

43.6.9 使用正则表达式的其他方法

String.prototype.split()关于字符串的章节中进行了描述。String.prototype.split() 的第一个参数是字符串或正则表达式。如果是后者,则组的捕获将出现在结果中

> 'a:b : c'.split(':')
[ 'a', 'b ', ' c' ]
> 'a:b : c'.split(/ *: */)
[ 'a', 'b', 'c' ]
> 'a:b : c'.split(/( *):( *)/)
[ 'a', '', '', 'b', ' ', ' ', 'c' ]

43.7 标志 /g/y 以及属性 .lastIndex(高级)

在本节中,我们将研究 RegExp 标志 /g/y 的工作原理,以及它们如何依赖于 RegExp 属性 .lastIndex。我们还将发现 .lastIndex 的一个有趣的用例,您可能会感到惊讶。

43.7.1 标志 /g/y

每个方法对 /g/y 的反应都不同;这给了我们一个粗略的总体思路

如果正则表达式既没有标志 /g 也没有标志 /y,则匹配发生一次并从头开始。

使用 /g/y,匹配将相对于输入字符串内的“当前位置”执行。该位置存储在正则表达式属性 .lastIndex 中。

与正则表达式相关的方法有三组

  1. 字符串方法 .search(regExp).split(regExp) 完全忽略 /g/y(因此也忽略 .lastIndex)。

  2. 如果设置了 /g/y,则 RegExp 方法 .exec(str).test(str) 会以两种方式发生变化。

    首先,我们通过重复调用一个方法来获得多个匹配项。每次调用都会返回另一个结果(匹配对象或 true)或“结果结束”值(nullfalse)。

    其次,正则表达式属性 .lastIndex 用于遍历输入字符串。一方面,.lastIndex 确定匹配的开始位置

    • /g 表示匹配必须从 .lastIndex 或之后开始。

    • /y 表示匹配必须从 .lastIndex 开始。也就是说,正则表达式的开头固定在 .lastIndex 处。

      请注意,^$ 继续像往常一样工作:它们将匹配项固定在输入字符串的开头或结尾,除非设置了 .multiline。然后,它们固定在行的开头或结尾。

    另一方面,.lastIndex 设置为前一个匹配项的最后一个索引加一。

  3. 所有其他方法都会受到以下影响

    • /g 会导致多个匹配项。
    • /y 会导致必须从 .lastIndex 开始的单个匹配项。
    • /yg 会导致多个没有间隙的匹配项。

这是第一个概述。接下来的部分将更详细地介绍。

43.7.2 方法究竟如何受 /g/y 的影响?

43.7.2.1 regExp.exec(str) [ES3]

如果没有 /g/y.exec() 会忽略 .lastIndex 并始终返回第一个匹配项的匹配对象

> const re = /#/; re.lastIndex = 1;
> [re.exec('##-#'), re.lastIndex]
[{ 0: '#', index: 0, input: '##-#' }, 1]
> [re.exec('##-#'), re.lastIndex]
[{ 0: '#', index: 0, input: '##-#' }, 1]

使用 /g,匹配必须从 .lastIndex 或之后开始。.lastIndex 会更新。如果没有匹配项,则返回 null

> const re = /#/g; re.lastIndex = 1;
> [re.exec('##-#'), re.lastIndex]
[{ 0: '#', index: 1, input: '##-#' }, 2]
> [re.exec('##-#'), re.lastIndex]
[{ 0: '#', index: 3, input: '##-#' }, 4]
> [re.exec('##-#'), re.lastIndex]
[null, 0]

使用 /y,匹配必须从 .lastIndex 处开始。.lastIndex 会更新。如果没有匹配项,则返回 null

> const re = /#/y; re.lastIndex = 1;
> [re.exec('##-#'), re.lastIndex]
[{ 0: '#', index: 1, input: '##-#' }, 2]
> [re.exec('##-#'), re.lastIndex]
[null, 0]

使用 /yg.exec() 的行为与使用 /y 时相同。

43.7.2.2 regExp.test(str) [ES3]

此方法的行为与 .exec() 相同,但它返回 true 而不是匹配对象,返回 false 而不是 null

例如,如果没有 /g/y,结果始终为 true

> const re = /#/; re.lastIndex = 1;
> [re.test('##-#'), re.lastIndex]
[true, 1]
> [re.test('##-#'), re.lastIndex]
[true, 1]

使用 /g,有两个匹配项

> const re = /#/g; re.lastIndex = 1;
> [re.test('##-#'), re.lastIndex]
[true, 2]
> [re.test('##-#'), re.lastIndex]
[true, 4]
> [re.test('##-#'), re.lastIndex]
[false, 0]

使用 /y,只有一个匹配项

> const re = /#/y; re.lastIndex = 1;
> [re.test('##-#'), re.lastIndex]
[true, 2]
> [re.test('##-#'), re.lastIndex]
[false, 0]

使用 /yg.test() 的行为与使用 /y 时相同。

43.7.2.3 str.match(regExp) [ES3]

如果没有 /g.match() 的工作方式与 .exec() 相同。没有 /y

> const re = /#/; re.lastIndex = 1;
> ['##-#'.match(re), re.lastIndex]
[{ 0: '#', index: 0, input: '##-#' }, 1]
> ['##-#'.match(re), re.lastIndex]
[{ 0: '#', index: 0, input: '##-#' }, 1]

或使用 /y

> const re = /#/y; re.lastIndex = 1;
> ['##-#'.match(re), re.lastIndex]
[{ 0: '#', index: 1, input: '##-#' }, 2]
> ['##-#'.match(re), re.lastIndex]
[null, 0]

使用 /g,我们在数组中获取所有匹配项(组 0)。.lastIndex 被忽略并重置为零。

> const re = /#/g; re.lastIndex = 1;
> '##-#'.match(re)
['#', '#', '#']
> re.lastIndex
0

/yg 的工作方式与 /g 相同,但匹配项之间没有间隙

> const re = /#/yg; re.lastIndex = 1;
> '##-#'.match(re)
['#', '#']
> re.lastIndex
0
43.7.2.4 str.matchAll(regExp) [ES2020]

如果未设置 /g.matchAll() 会抛出异常

> const re = /#/y; re.lastIndex = 1;
> '##-#'.matchAll(re)
TypeError: String.prototype.matchAll called with
a non-global RegExp argument

如果设置了 /g,则匹配从 .lastIndex 开始,并且该属性不会更改

> const re = /#/g; re.lastIndex = 1;
> Array.from('##-#'.matchAll(re))
[
  { 0: '#', index: 1, input: '##-#' },
  { 0: '#', index: 3, input: '##-#' },
]
> re.lastIndex
1

如果设置了 /yg,则行为与 /g 相同,但匹配项之间没有间隙

> const re = /#/yg; re.lastIndex = 1;
> Array.from('##-#'.matchAll(re))
[
  { 0: '#', index: 1, input: '##-#' },
]
> re.lastIndex
1
43.7.2.5 str.replace(regExp, str) [ES3]

如果没有 /g/y,则只替换第一次出现

> const re = /#/; re.lastIndex = 1;
> '##-#'.replace(re, 'x')
'x#-#'
> re.lastIndex
1

使用 /g,所有出现都将被替换。.lastIndex 被忽略但重置为零。

> const re = /#/g; re.lastIndex = 1;
> '##-#'.replace(re, 'x')
'xx-x'
> re.lastIndex
0

使用 /y,只替换 .lastIndex 处的(第一次)出现。.lastIndex 会更新。

> const re = /#/y; re.lastIndex = 1;
> '##-#'.replace(re, 'x')
'#x-#'
> re.lastIndex
2

/yg 的工作方式与 /g 相同,但不允许匹配项之间存在间隙

> const re = /#/yg; re.lastIndex = 1;
> '##-#'.replace(re, 'x')
'xx-#'
> re.lastIndex
0
43.7.2.6 str.replaceAll(regExp, str) [ES2021]

.replaceAll() 的工作方式与 .replace() 相同,但如果未设置 /g,则会抛出异常

> const re = /#/y; re.lastIndex = 1;
> '##-#'.replaceAll(re, 'x')
TypeError: String.prototype.replaceAll called
with a non-global RegExp argument

43.7.3 /g 和 /y 的四个陷阱以及如何处理它们

我们将首先了解 /g 和 /y 的四个陷阱,然后了解处理这些陷阱的方法。

43.7.3.1 陷阱 1:我们不能内联带有 /g 或 /y 的正则表达式

带有 /g 的正则表达式不能内联。例如,在以下 while 循环中,每次检查条件时都会重新创建一个正则表达式。因此,它的 .lastIndex 始终为零,并且循环永远不会终止。

let matchObj;
// Infinite loop
while (matchObj = /a+/g.exec('bbbaabaaa')) {
  console.log(matchObj[0]);
}

使用 /y 时,问题是一样的。

43.7.3.2 陷阱 2:删除 /g 或 /y 可能会破坏代码

如果代码需要一个带有 /g 的正则表达式,并且有一个循环遍历 .exec() 或 .test() 的结果,那么没有 /g 的正则表达式可能会导致无限循环

function collectMatches(regExp, str) {
  const matches = [];
  let matchObj;
  // Infinite loop
  while (matchObj = regExp.exec(str)) {
    matches.push(matchObj[0]);
  }
  return matches;
}
collectMatches(/a+/, 'bbbaabaaa'); // Missing: flag /g

为什么会出现无限循环?因为 .exec() 总是返回第一个结果,一个匹配对象,而不是 null。

使用 /y 时,问题是一样的。

43.7.3.3 陷阱 3:添加 /g 或 /y 可能会破坏代码

对于 .test(),还有另一个需要注意的地方:它受 .lastIndex 的影响。因此,如果我们想精确地检查一次正则表达式是否匹配字符串,那么该正则表达式一定不能有 /g。否则,我们每次调用 .test() 通常都会得到不同的结果

> const regExp = /^X/g;
> [regExp.test('Xa'), regExp.lastIndex]
[ true, 1 ]
> [regExp.test('Xa'), regExp.lastIndex]
[ false, 0 ]
> [regExp.test('Xa'), regExp.lastIndex]
[ true, 1 ]

第一次调用会产生匹配并更新 .lastIndex。第二次调用找不到匹配项,并将 .lastIndex 重置为零。

如果我们专门为 .test() 创建一个正则表达式,那么我们可能不会添加 /g。但是,如果我们使用相同的正则表达式进行替换和测试,那么遇到 /g 的可能性就会增加。

同样,/y 也存在这个问题

> const regExp = /^X/y;
> regExp.test('Xa')
true
> regExp.test('Xa')
false
> regExp.test('Xa')
true
43.7.3.4 陷阱 4:如果 .lastIndex 不为零,代码可能会产生意外结果

考虑到所有受 .lastIndex 影响的正则表达式操作,我们必须小心许多算法,在开始时 .lastIndex 为零。否则,我们可能会得到意想不到的结果

function countMatches(regExp, str) {
  let count = 0;
  while (regExp.test(str)) {
    count++;
  }
  return count;
}

const myRegExp = /a/g;
myRegExp.lastIndex = 4;
assert.equal(
  countMatches(myRegExp, 'babaa'), 1); // should be 3

通常,.lastIndex 在新创建的正则表达式中为零,我们不会像在示例中那样显式更改它。但是,如果我们多次使用正则表达式,.lastIndex 最终仍然可能不为零。

43.7.3.5 如何避免 /g 和 /y 的陷阱

作为处理 /g 和 .lastIndex 的一个例子,我们重新审视前面例子中的 countMatches()。我们如何防止错误的正则表达式破坏我们的代码?让我们看看三种方法。

43.7.3.5.1 抛出异常

首先,如果未设置 /g 或 .lastIndex 不为零,我们可以抛出一个异常

function countMatches(regExp, str) {
  if (!regExp.global) {
    throw new Error('Flag /g of regExp must be set');
  }
  if (regExp.lastIndex !== 0) {
    throw new Error('regExp.lastIndex must be zero');
  }
  
  let count = 0;
  while (regExp.test(str)) {
    count++;
  }
  return count;
}
43.7.3.5.2 克隆正则表达式

其次,我们可以克隆参数。这样做还有一个好处,就是 regExp 不会被改变。

function countMatches(regExp, str) {
  const cloneFlags = regExp.flags + (regExp.global ? '' : 'g');
  const clone = new RegExp(regExp, cloneFlags);

  let count = 0;
  while (clone.test(str)) {
    count++;
  }
  return count;
}
43.7.3.5.3 使用不受 .lastIndex 或标志影响的操作

一些正则表达式操作不受 .lastIndex 或标志的影响。例如,如果存在 /g,.match() 会忽略 .lastIndex

function countMatches(regExp, str) {
  if (!regExp.global) {
    throw new Error('Flag /g of regExp must be set');
  }
  return (str.match(regExp) ?? []).length;
}

const myRegExp = /a/g;
myRegExp.lastIndex = 4;
assert.equal(countMatches(myRegExp, 'babaa'), 3); // OK!

在这里,countMatches() 可以工作,即使我们没有检查或修复 .lastIndex。

43.7.4 .lastIndex 的用例:从给定索引开始匹配

除了存储状态之外,.lastIndex 还可以用于从给定索引开始匹配。本节介绍如何实现。

43.7.4.1 示例:检查正则表达式是否在给定索引处匹配

鉴于 .test() 受 /y 和 .lastIndex 的影响,我们可以使用它来检查正则表达式 regExp 是否在给定的索引处匹配字符串 str

function matchesStringAt(regExp, str, index) {
  if (!regExp.sticky) {
    throw new Error('Flag /y of regExp must be set');
  }
  regExp.lastIndex = index;
  return regExp.test(str);
}
assert.equal(
  matchesStringAt(/x+/y, 'aaxxx', 0), false);
assert.equal(
  matchesStringAt(/x+/y, 'aaxxx', 2), true);

由于 /y 的原因,regExp 被锚定到 .lastIndex。

请注意,我们不能使用断言 ^,因为它会将 regExp 锚定到输入字符串的开头。

43.7.4.2 示例:从给定索引开始查找匹配项的位置

.search() 让我们找到正则表达式匹配的位置

> '#--#'.search(/#/)
0

遗憾的是,我们无法更改 .search() 开始查找匹配项的位置。作为一种解决方法,我们可以使用 .exec() 进行搜索

function searchAt(regExp, str, index) {
  if (!regExp.global && !regExp.sticky) {
    throw new Error('Either flag /g or flag /y of regExp must be set');
  }
  regExp.lastIndex = index;
  const match = regExp.exec(str);
  if (match) {
    return match.index;
  } else {
    return -1;
  }
}

assert.equal(
  searchAt(/#/g, '#--#', 0), 0);
assert.equal(
  searchAt(/#/g, '#--#', 1), 3);
43.7.4.3 示例:替换给定索引处的匹配项

当不带 /g 并带 /y 使用时,.replace() 会进行一次替换 - 如果在 .lastIndex 处有匹配项

function replaceOnceAt(str, regExp, replacement, index) {
  if (!(regExp.sticky && !regExp.global)) {
    throw new Error('Flag /y must be set, flag /g must not be set');
  }
  regExp.lastIndex = index;
  return str.replace(regExp, replacement);
}
assert.equal(
  replaceOnceAt('aa aaaa a', /a+/y, 'X', 0), 'X aaaa a');
assert.equal(
  replaceOnceAt('aa aaaa a', /a+/y, 'X', 3), 'aa X a');
assert.equal(
  replaceOnceAt('aa aaaa a', /a+/y, 'X', 8), 'aa aaaa X');

43.7.5 .lastIndex 的缺点

正则表达式属性 .lastIndex 有两个明显的缺点

从好的方面来说,.lastIndex 也为我们提供了额外的有用功能:我们可以指定匹配应该从哪里开始(对于某些操作)。

43.7.6 总结:.global (/g) 和 .sticky (/y)

以下两种方法完全不受 /g 和 /y 的影响

下表解释了其余与正则表达式相关的方法如何受这两个标志的影响

/ /g /y /yg
r.exec(s) {i:0} {i:1} {i:1} {i:1}
.lI 不变 .lI 更新 .lI 更新 .lI 更新
r.test(s) true true true true
.lI 不变 .lI 更新 .lI 更新 .lI 更新
s.match(r) {i:0} ["#","#","#"] {i:1} ["#","#"]
.lI 不变 .lI 重置 .lI 更新 .lI 重置
s.matchAll(r) TypeError [{i:1}, {i:3}] TypeError [{i:1}]
.lI 不变 .lI 不变
s.replace(r, 'x') "x#-#" "xx-x" "#x-#" "xx-#"
.lI 不变 .lI 重置 .lI 更新 .lI 重置
s.replaceAll(r, 'x') TypeError "xx-x" TypeError "xx-#"
.lI 重置 .lI 重置

变量

const r = /#/; r.lastIndex = 1;
const s = '##-#';

缩写

  生成上表的 Node.js 脚本

上表是通过 一个 Node.js 脚本 生成的。

43.8 使用正则表达式的技巧

43.8.1 转义正则表达式的任意文本

以下函数转义任意文本,以便如果我们将它放在正则表达式中,它将被逐字匹配

function escapeForRegExp(str) {
  return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); // (A)
}
assert.equal(escapeForRegExp('[yes?]'), String.raw`\[yes\?\]`);
assert.equal(escapeForRegExp('_g_'), String.raw`_g_`);

在 A 行中,我们转义了所有语法字符。我们必须有选择性,因为正则表达式标志 /u 禁止许多转义 - 例如:\a \: \-

escapeForRegExp() 有两个用例

.replace() 只允许我们替换一次纯文本。使用 escapeForRegExp(),我们可以解决这个限制

const plainText = ':-)';
const regExp = new RegExp(escapeForRegExp(plainText), 'ug');
assert.equal(
  ':-) :-) :-)'.replace(regExp, '🙂'), '🙂 🙂 🙂');

43.8.2 匹配所有内容或不匹配任何内容

有时,我们可能需要一个匹配所有内容或不匹配任何内容的正则表达式 - 例如,作为默认值。