深入探討 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 延伸閱讀