深入理解 JavaScript
请支持本书:购买捐赠
(广告,请不要屏蔽。)

16 正则表达式:通过示例了解环视断言



在本章中,我们将使用示例来探讨正则表达式中的环视断言。环视断言是非捕获的,并且必须匹配(或不匹配)输入字符串中当前位置之前(或之后)的内容。

16.1 速查表:环视断言

表 4:可用环视断言概述。
模式 名称
(?=«pattern») 正向先行断言 ES3
(?!«pattern») 负向先行断言 ES3
(?<=«pattern») 正向后行断言 ES2018
(?<!«pattern») 负向后行断言 ES2018

共有四种环视断言(表 4

16.2 本章警告

16.3 示例:指定匹配项之前或之后的字符(正向环视)

在以下交互中,我们提取带引号的单词

> 'how "are" "you" doing'.match(/(?<=")[a-z]+(?=")/g)
[ 'are', 'you' ]

这里有两个环视断言可以帮助我们

环视断言在 /g 模式下使用 .match() 时特别方便,它返回完整的匹配项(捕获组 0)。环视断言匹配的任何内容都不会被捕获。如果没有环视断言,则引号将显示在结果中

> 'how "are" "you" doing'.match(/"([a-z]+)"/g)
[ '"are"', '"you"' ]

16.4 示例:指定匹配项之前或之后不能出现的字符(负向环视)

我们如何才能实现与上一节相反的操作,并从字符串中提取所有不带引号的单词?

我们的第一个尝试是简单地将正向环视断言转换为负向环视断言。唉,失败了

> 'how "are" "you" doing'.match(/(?<!")[a-z]+(?!")/g)
[ 'how', 'r', 'o', 'doing' ]

问题在于,我们提取了未用引号括起来的字符序列。这意味着在字符串 '"are"' 中,中间的“r”被认为是不带引号的,因为它前面是“a”,后面是“e”。

我们可以通过声明前缀和后缀既不能是引号也不能是字母来解决此问题

> 'how "are" "you" doing'.match(/(?<!["a-z])[a-z]+(?!["a-z])/g)
[ 'how', 'doing' ]

另一种解决方案是通过 \b 要求字符序列 [a-z]+ 在单词边界处开始和结束

> 'how "are" "you" doing'.match(/(?<!")\b[a-z]+\b(?!")/g)
[ 'how', 'doing' ]

负向后行断言和负向先行断言的一个优点是,它们也可以分别在字符串的开头或结尾处工作,如示例所示。

16.4.1 没有简单的替代方案可以替代负向环视断言

负向环视断言是一个强大的工具,通常无法通过其他正则表达式方法来模拟。

如果我们不想使用它们,我们通常必须采取完全不同的方法。例如,在这种情况下,我们可以将字符串拆分为(带引号和不带引号的)单词,然后对其进行过滤

const str = 'how "are" "you" doing';

const allWords = str.match(/"?[a-z]+"?/g);
const unquotedWords = allWords.filter(
  w => !w.startsWith('"') || !w.endsWith('"'));
assert.deepEqual(unquotedWords, ['how', 'doing']);

这种方法的优点

16.5 插曲:将环视断言指向内部

到目前为止,我们看到的所有示例都有一个共同点,即环视断言规定了匹配项之前或之后必须出现的内容,但没有将这些字符包含在匹配项中。

本章其余部分中显示的正则表达式有所不同:它们的环视断言指向内部并限制匹配项内部的内容。

16.6 示例:匹配不以 'abc' 开头的字符串

假设我们要匹配所有不以 'abc' 开头的字符串。我们的第一个尝试可能是正则表达式 /^(?!abc)/

这对于 .test() 来说效果很好

> /^(?!abc)/.test('xyz')
true

但是,.exec() 会给我们一个空字符串

> /^(?!abc)/.exec('xyz')
{ 0: '', index: 0, input: 'xyz', groups: undefined }

问题在于,诸如环视断言之类的断言不会扩展匹配的文本。也就是说,它们不会捕获输入字符,它们只会对输入中的当前位置提出要求。

因此,解决方案是添加一个确实捕获输入字符的模式

> /^(?!abc).*$/.exec('xyz')
{ 0: 'xyz', index: 0, input: 'xyz', groups: undefined }

如预期的那样,这个新的正则表达式拒绝以 'abc' 为前缀的字符串

> /^(?!abc).*$/.exec('abc')
null
> /^(?!abc).*$/.exec('abcd')
null

并且它接受没有完整前缀的字符串

> /^(?!abc).*$/.exec('ab')
{ 0: 'ab', index: 0, input: 'ab', groups: undefined }

16.7 示例:匹配不包含 '.mjs' 的子字符串

在以下示例中,我们要查找

import ··· from '«module-specifier»';

其中 module-specifier 不以 '.mjs' 结尾。

const code = `
import {transform} from './util';
import {Person} from './person.mjs';
import {zip} from 'lodash';
`.trim();
assert.deepEqual(
  code.match(/^import .*? from '[^']+(?<!\.mjs)';$/umg),
  [
    "import {transform} from './util';",
    "import {zip} from 'lodash';",
  ]);

在这里,后行断言 (?<!\.mjs) 充当一个*守卫*,防止正则表达式匹配在此位置包含 '.mjs' 的字符串。

16.8 示例:跳过带有注释的行

场景:我们要解析包含设置的行,同时跳过注释。例如

const RE_SETTING = /^(?!#)([^:]*):(.*)$/

const lines = [
  'indent: 2', // setting
  '# Trim trailing whitespace:', // comment
  'whitespace: trim', // setting
];
for (const line of lines) {
  const match = RE_SETTING.exec(line);
  if (match) {
    const key = JSON.stringify(match[1]);
    const value = JSON.stringify(match[2]);
    console.log(`KEY: ${key} VALUE: ${value}`);
  }
}

// Output:
// 'KEY: "indent" VALUE: " 2"'
// 'KEY: "whitespace" VALUE: " trim"'

我们是如何得出正则表达式 RE_SETTING 的?

我们从以下用于设置的正则表达式开始

/^([^:]*):(.*)$/

直观地说,它是以下部分的序列

此正则表达式确实拒绝了*一些*注释

> /^([^:]*):(.*)$/.test('# Comment')
false

但它接受其他注释(其中包含冒号)

> /^([^:]*):(.*)$/.test('# Comment:')
true

我们可以通过在前面加上 (?!#) 作为守卫来解决此问题。直观地说,这意味着:“输入字符串中的当前位置后面不能是字符 #。”

新的正则表达式按预期工作

> /^(?!#)([^:]*):(.*)$/.test('# Comment:')
false

16.9 示例:智能引号

假设我们要将成对的直双引号转换为弯引号

这是我们的第一次尝试

> `The words "must" and "should".`.replace(/"(.*)"/g, '“$1”')
'The words “must" and "should”.'

只有第一个引号和最后一个引号是弯引号。这里的问题是 * 量词贪婪地匹配(尽可能多)。

如果我们在 * 后面放一个问号,它就会*不情愿地*匹配

> `The words "must" and "should".`.replace(/"(.*?)"/g, '“$1”')
'The words “must” and “should”.'

16.9.1 支持通过反斜杠转义

如果我们想允许通过反斜杠转义引号怎么办?我们可以通过在引号前面使用守卫 (?<!\\) 来做到这一点

> const regExp = /(?<!\\)"(.*?)(?<!\\)"/g;
> String.raw`\"straight\" and "curly"`.replace(regExp, '“$1”')
'\\"straight\\" and “curly”'

作为后处理步骤,我们仍然需要这样做

.replace(/\\"/g, `"`)

但是,当存在反斜杠转义的反斜杠时,此正则表达式可能会失败

> String.raw`Backslash: "\\"`.replace(/(?<!\\)"(.*?)(?<!\\)"/g, '“$1”')
'Backslash: "\\\\"'

第二个反斜杠阻止了引号变成弯引号。

如果我们让守卫更复杂一些(?: 使组不捕获),我们可以解决这个问题

(?<=[^\\](?:\\\\)*)

新的守卫允许在引号前面出现成对的反斜杠

> const regExp = /(?<=[^\\](?:\\\\)*)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> String.raw`Backslash: "\\"`.replace(regExp, '“$1”')
'Backslash: “\\\\”'

还有一个问题。如果第一个引号出现在字符串的开头,则此守卫会阻止匹配它

> const regExp = /(?<=[^\\](?:\\\\)*)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'"abc"'

我们可以通过将第一个守卫更改为以下内容来解决此问题:(?<=[^\\](?:\\\\)*|^)

> const regExp = /(?<=[^\\](?:\\\\)*|^)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'“abc”'

16.10 致谢

16.11 延伸阅读