第 19 章:正規表示式
目錄
購買本書
(廣告,請勿封鎖)

第 19 章:正規表示式

本章概述 JavaScript API 中的正規表示式。假設你大致了解正規表示式的運作方式。如果你不了解,網路上有很多不錯的教學資源。以下兩個範例:

正規表示式語法

這裡使用的術語緊密反映 ECMAScript 規範中的語法。我偶爾會偏離規範,以協助讀者理解。

原子:一般

一般原子的語法如下:

特殊字元

所有下列字元都有特殊意義:

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

你可以加上反斜線來跳脫這些字元。例如

> /^(ab)$/.test('(ab)')
false
> /^\(ab\)$/.test('(ab)')
true

其他特殊字元包括

  • 在字元類別 [...] 內部

    -
  • 在以問號開頭的群組 (?...) 內部

    : = ! < >

    尖括號只供 XRegExp 函式庫(請參閱第 30 章)用來命名群組。

樣式字元
除了上述特殊字元之外,所有字元都與其本身相符。
.(點號)

與任何 JavaScript 字元(UTF-16 編碼單位)相符,但排除換行符號(換行、回車等)。若要與任何字元相符,請使用 [\s\S]。例如

> /./.test('\n')
false
> /[\s\S]/.test('\n')
true
字元跳脫(與單一字元相符)
  • 特定控制字元包括 \f(換頁)、\n(換行)、\r(回車)、\t(水平定位標籤)和 \v(垂直定位標籤)。
  • \0 與 NUL 字元相符(\u0000)。
  • 任何控制字元:\cA\cZ
  • Unicode 字元跳脫:\u0000\xFFFF(Unicode 編碼單位;請參閱 第 24 章)。
  • 十六進位字元跳脫:\x00\xFF
字元類別跳脫(比對一組字元中的其中一個)
  • 數字:\d 比對任何數字(與 [0-9] 相同);\D 比對任何非數字(與 [^0-9] 相同)。
  • 字母數字字元:\w 比對任何拉丁字母數字字元加上底線(與 [A-Za-z0-9_] 相同);\W 比對所有未由 \w 比對的字元。
  • 空白:\s 比對空白字元(空白、標籤、換行、回車、換頁、所有 Unicode 空白等);\S 比對所有非空白字元。

原子:字元類別

字元類別的語法如下:

  • [«charSpecs»] 比對任何與至少一個 charSpecs 相符的單一字元。
  • [^«charSpecs»] 比對任何與任何 charSpecs 都不相符的單一字元。

下列建構都是字元規格

  • 來源字元比對它們自己。大多數字元都是來源字元(甚至許多在其他地方是特殊字元的字元)。只有三個字元不是

        \ ] -

    與往常一樣,您透過反斜線進行跳脫。如果您想要比對破折號而不跳脫它,它必須是開括號後的第 1 個字元或範圍的右側,如以下所述。

  • 類別跳脫:先前列出的任何字元跳脫和字元類別跳脫都允許使用。還有一個額外的跳脫

    • 退格 (\b):在字元類別外,\b 比對字詞邊界。在字元類別內,它比對控制字元 退格
  • 範圍包含一個來源字元或一個類別跳脫,後接一個破折號 (-),再後接一個來源字元或一個類別跳脫。

為了示範使用字元類別,這個範例會剖析以 ISO 8601 標準格式化的日期

function parseIsoDate(str) {
    var match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(str);

    // Other ways of writing the regular expression:
    // /^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$/
    // /^(\d\d\d\d)-(\d\d)-(\d\d)$/

    if (!match) {
        throw new Error('Not an ISO date: '+str);
    }
    console.log('Year: '  + match[1]);
    console.log('Month: ' + match[2]);
    console.log('Day: '   + match[3]);
}

以下是互動

> parseIsoDate('2001-12-24')
Year: 2001
Month: 12
Day: 24

原子:群組

群組語法如下

  • («模式») 是擷取群組。任何符合 模式 的內容都可以透過反向參照或比對作業的結果來存取。
  • (?:«模式») 是非擷取群組。 模式 仍會與輸入內容比對,但不會儲存為擷取。因此,群組沒有可以參照的數字(例如透過反向參照)。

\1\2 等稱為 反向參照;它們參照先前比對的群組。反斜線後的數字可以是任何大於或等於 1 的整數,但第一個數字不能是 0。

在此範例中,反向參照保證連字符前後的 a 數量相同

> /^(a+)-\1$/.test('a-a')
true
> /^(a+)-\1$/.test('aaa-aaa')
true
> /^(a+)-\1$/.test('aa-a')
false

此範例使用反向參照來比對 HTML 標籤(顯然地,您通常應該使用適當的剖析器來處理 HTML)

> var tagName = /<([^>]+)>[^<]*<\/\1>/;
> tagName.exec('<b>bold</b>')[1]
'b'
> tagName.exec('<strong>text</strong>')[1]
'strong'
> tagName.exec('<strong>text</stron>')
null

量詞

任何原子(包括字元類別和群組)後方都可以接續量詞:

  • ? 表示比對 0 次或 1 次。
  • * 表示比對 0 次或多次。
  • + 表示比對 1 次或多次。
  • {n} 表示比對 n 次。
  • {n,} 表示比對 n 次或多次。
  • {n,m} 表示比對至少 n 次,最多 m 次。

預設情況下,量詞為 貪婪亦即,它們會盡可能多地比對。您可以透過在任何前述量詞(包括大括弧中的範圍)後方加上問號 (?) 來取得不情願比對(盡可能少地比對)。例如:

> '<a> <strong>'.match(/^<(.*)>/)[1]  // greedy
'a> <strong'
> '<a> <strong>'.match(/^<(.*?)>/)[1]  // reluctant
'a'

因此,.*? 是用於比對所有內容直到下一個原子出現的實用模式。例如,以下內容是剛才顯示的 HTML 標籤正規表示式的更簡潔版本(使用 .*? 取代 [^<]*

/<(.+?)>.*?<\/\1>/

斷言

斷言,顯示在 以下清單中,是針對輸入內容中目前位置的檢查:

^

僅在輸入開頭處比對。

$

僅在輸入結尾處比對。

\b

僅在字詞邊界處比對。不要與 [\b] 混淆,後者會比對退格鍵。

\B

僅在非字詞邊界處比對。

(?=«pattern»)

正向先行斷言:僅在 pattern 與後續內容相符時比對。 pattern 僅用於先行斷言,否則會被忽略。

(?!«pattern»)

負向先行斷言:僅在 pattern 與後續內容不相符時比對。 pattern 僅用於先行斷言,否則會被忽略。

此範例透過 \b 比對字詞邊界。

> /\bell\b/.test('hello')
false
> /\bell\b/.test('ello')
false
> /\bell\b/.test('ell')
true

此範例透過 \B 比對字詞內部。

> /\Bell\B/.test('ell')
false
> /\Bell\B/.test('hell')
false
> /\Bell\B/.test('hello')
true

注意

不支援後行斷言。 手動實作後行斷言 說明如何手動實作。

析取

析取運算子 (|) 分隔兩個選項;析取要比對成功,任一選項都必須比對成功。這些選項是原子(選擇性地包含量詞)。

此運算子的結合力非常弱,因此您必須小心,不要讓選項延伸得太遠。例如,下列正規表示式會比對所有以 aa 開頭或以 bb 結尾的字串

> /^aa|bb$/.test('aaxx')
true
> /^aa|bb$/.test('xxbb')
true

換句話說,析取的結合力甚至比 ^$ 還弱,而兩個選項為 ^aabb$。如果您要比對兩個字串 'aa''bb',您需要使用括號

/^(aa|bb)$/

類似地,如果您要比對字串 'aab''abb'

/^a(a|b)b$/

建立正規表示式

您可以透過文字或建構函式建立 正規表示式,並透過旗標設定其運作方式。

文字與建構函式

有兩種方法可以建立正規表示式:您可以使用文字或建構函式 RegExp

文字

/xyz/i

在載入時編譯

建構函式(第二個引數是選用的)

new RegExp('xyz', 'i')

在執行時編譯

文字和建構函式在編譯時間上有所不同

因此,您通常應該使用文字,但如果您想要動態組合正規表示式,則需要建構函式。

旗標

旗標是正規表示式文字的後綴和正規表示式建構函式的參數;它們會修改正規表示式的配對行為。下列旗標存在:

簡稱 全稱 說明

g

global

給定的正規表示式會配對多次。會影響多種方法,特別是 replace()

i

ignoreCase

在嘗試配對給定的正規表示式時會忽略大小寫。

m

multiline

在多行模式中,開始運算子 ^ 和結束運算子 $ 會配對每一行,而不是完整的輸入字串。

簡稱用於文字前綴和建構函式參數(請參閱下一節中的範例)。全稱用於正規表示式的屬性,用以指示在建立正規表示式時設定了哪些旗標。

正規表示式的實例屬性

正規表示式具有下列實例屬性:

  • 旗標:布林值,指示設定了哪些旗標

    • global:是否設定旗標 /g
    • ignoreCase:是否設定旗標 /i
    • multiline:是否設定旗標 /m
  • 多次配對的資料(設定旗標 /g

    • lastIndex 是下次繼續搜尋的索引。

以下是存取旗標實例屬性的範例

> var regex = /abc/i;
> regex.ignoreCase
true
> regex.multiline
false

建立正規表示式的範例

在此範例中,我們先使用文字建立相同的正規表示式,然後使用建構函式建立相同的正規表示式,並使用 test() 方法來判斷它是否與字串配對:

> /abc/.test('ABC')
false
> new RegExp('abc').test('ABC')
false

在此範例中,我們建立一個忽略大小寫的正規表示式(旗標 /i

> /abc/i.test('ABC')
true
> new RegExp('abc', 'i').test('ABC')
true

RegExp.prototype.test:是否有配對?

方法 test() 檢查正規表示式 regex 是否與字串 str 相符:

regex.test(str)

test() 的運作方式取決於是否設定旗標 /g

如果未設定旗標 /g,則此方法會檢查 str 中某處是否有相符項。例如

> var str = '_x_x';

> /x/.test(str)
true
> /a/.test(str)
false

如果設定旗標 /g,則此方法會針對 strregex 的相符項,傳回 true 的次數。屬性 regex.lastIndex 包含最後一個相符項之後的索引

> var regex = /x/g;
> regex.lastIndex
0

> regex.test(str)
true
> regex.lastIndex
2

> regex.test(str)
true
> regex.lastIndex
4

> regex.test(str)
false

String.prototype.search:相符項位於哪個索引?

方法 search() str 中尋找與 regex 相符的項:

str.search(regex)

如果有相符項,則會傳回找到相符項的索引。否則,結果為 -1。執行搜尋時會忽略 regex 的屬性 globallastIndex(且 lastIndex 也不會變更)。

例如

> 'abba'.search(/b/)
1
> 'abba'.search(/x/)
-1

如果 search() 的引數不是正規表示式,則會轉換為正規表示式

> 'aaab'.search('^a+b+$')
0

RegExp.prototype.exec:擷取群組

下列方法呼叫會在比對 regexstr 時擷取群組:

var matchData = regex.exec(str);

如果沒有相符項,matchData 會是 null。否則,matchData 會是 相符結果,也就是一個陣列,並具有兩個額外的屬性

陣列元素
  • 元素 0 是完整正規表示式的相符項(也就是群組 0)。
  • 元素 n > 1 是群組 n 的擷取結果。
屬性
  • input 是完整的輸入字串。
  • index 是找到相符項的索引。

第一個相符項(未設定旗標 /g)

如果未設定旗標 /g,則只會傳回第一個相符項

> var regex = /a(b+)/;
> regex.exec('_abbb_ab_')
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
0

所有相符項(設定旗標 /g)

如果設定旗標 /g,重複呼叫 exec() 會傳回所有比對結果。傳回值 null 表示沒有更多比對結果。屬性 lastIndex 表示下次比對將從何處繼續

> var regex = /a(b+)/g;
> var str = '_abbb_ab_';

> regex.exec(str)
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
6

> regex.exec(str)
[ 'ab',
  'b',
  index: 7,
  input: '_abbb_ab_' ]
> regex.lastIndex
10

> regex.exec(str)
null

我們在此迴圈處理比對結果

var regex = /a(b+)/g;
var str = '_abbb_ab_';
var match;
while (match = regex.exec(str)) {
    console.log(match[1]);
}

並且取得下列輸出

bbb
b

String.prototype.match:擷取群組或傳回所有符合的子字串

下列方法呼叫 比對 regexstr

var matchData = str.match(regex);

如果 regex 的旗標 /g 未設定,此方法會像 RegExp.prototype.exec() 一樣運作

> 'abba'.match(/a/)
[ 'a', index: 0, input: 'abba' ]

如果設定旗標,則此方法會傳回一個陣列,其中包含 str 中所有符合的子字串(也就是每個比對結果的群組 0),或在沒有比對結果時傳回 null

> 'abba'.match(/a/g)
[ 'a', 'a' ]
> 'abba'.match(/x/g)
null

String.prototype.replace:搜尋並取代

方法 replace() 會搜尋 字串 str 中與 search 符合的項目,並用 replacement 取代它們:

str.replace(search, replacement)

有數種方式可以指定這兩個參數

search

字串或正規表示法

  • 字串:在輸入字串中以字面值尋找。請注意,只會取代字串的第一個出現位置。如果您要取代多個出現位置,您必須使用帶有 /g 旗標的正規表示法。這點出乎意料,而且是個重大陷阱。
  • 正規表示法:與輸入字串比對。警告:請使用 global 旗標,否則只會嘗試比對正規表示法一次。
replacement

字串或函式

  • 字串:說明如何取代已找到的項目。
  • 函式:計算取代項目,並透過參數提供比對資訊。

取代項目為字串

如果 replacement 為字串,其內容會逐字用來取代比對結果。唯一的例外是特殊字元美元符號 ($),它會啟動所謂的 取代指令

  • 群組:$n 會插入比對結果中的群組 n。n 必須至少為 1($0 沒有特殊意義)。
  • 符合的子字串

    • $`(反引號)會插入比對結果之前的文字。
    • $& 會插入完整的比對結果。
    • $'(單引號)會插入比對結果之後的文字。
  • $$ 插入單一 $

此範例參照配對的子字串及其字首和字尾

> 'axb cxd'.replace(/x/g, "[$`,$&,$']")
'a[a,x,b cxd]b c[axb c,x,d]d'

此範例參照群組

> '"foo" and "bar"'.replace(/"(.*?)"/g, '#$1#')
'#foo# and #bar#'

替換是一個函式

如果 replacement 是函式,它會計算要取代配對的字串。此函式具有下列簽章:

function (completeMatch, group_1, ..., group_n, offset, inputStr)

completeMatch 與先前的 $& 相同,offset 指出找到配對的位置,而 inputStr 是用來配對的內容。因此,您可以使用特殊變數 arguments 存取群組(透過 arguments[1] 存取群組 1,依此類推)。例如

> function replaceFunc(match) { return 2 * match }
> '3 apples and 5 oranges'.replace(/[0-9]+/g, replaceFunc)
'6 apples and 10 oranges'

/g 旗標的問題

如果設定 正規表示式的 /g 旗標,則在呼叫其方法時,如果必須呼叫多次才能傳回所有結果,就會產生問題。有兩個方法會發生這種情況:

  • RegExp.prototype.test()
  • RegExp.prototype.exec()

然後 JavaScript 會濫用正規表示式作為反覆運算器,作為結果順序中的指標。這會造成問題

問題 1:/g 正規表示式無法內嵌

例如

// Don’t do that:
var count = 0;
while (/a/g.test('babaa')) count++;

前一個迴圈是無限的,因為會為每個迴圈反覆運算建立新的正規表示式,這會重新開始結果的反覆運算。因此,必須重新撰寫程式碼

var count = 0;
var regex = /a/g;
while (regex.test('babaa')) count++;

以下是另一個範例

// Don’t do that:
function extractQuoted(str) {
    var match;
    var result = [];
    while ((match = /"(.*?)"/g.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

呼叫前一個函式會再次導致無限迴圈。正確的版本為(稍後會說明為何將 lastIndex 設定為 0)

var QUOTE_REGEX = /"(.*?)"/g;
function extractQuoted(str) {
    QUOTE_REGEX.lastIndex = 0;
    var match;
    var result = [];
    while ((match = QUOTE_REGEX.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

使用函式

> extractQuoted('"hello", "world"')
[ 'hello', 'world' ]

提示

無論如何,最佳作法都是不要內嵌(這樣您可以提供正規表示式描述性名稱)。但您必須知道無法執行此操作,即使是在快速駭客中也不行。

問題 2:/g 正規表示式作為參數
想要呼叫 test()exec() 多次的程式碼,必須小心參數中傳遞的正規表示式。其旗標 /g 必須啟用,且為了安全,其 lastIndex 應設定為零(下一個範例會提供說明)。
問題 3:共用的 /g 正規表示式(例如常數)
只要您參照的正規表示式不是新建立的,您就應在將其用作反覆運算器之前,將其 lastIndex 屬性設定為零(下一個範例會提供說明)。由於反覆運算依賴 lastIndex,因此此類正規表示式無法同時用於多個反覆運算。

下列範例說明了問題 2。這是函數的簡陋實作,用於計算字串 str 中與正規表示式 regex 相符的次數

// Naive implementation
function countOccurrences(regex, str) {
    var count = 0;
    while (regex.test(str)) count++;
    return count;
}

以下是使用此函數的範例

> countOccurrences(/x/g, '_x_x')
2

第一個問題是,如果正規表示式的 /g 旗標未設定,此函數將進入無限迴圈。例如

countOccurrences(/x/, '_x_x') // never terminates

第二個問題是,如果 regex.lastIndex 不為 0,函數無法正確運作,因為該屬性指出搜尋的起始位置。例如

> var regex = /x/g;
> regex.lastIndex = 2;
> countOccurrences(regex, '_x_x')
1

下列實作修正了這兩個問題

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    var origLastIndex = regex.lastIndex;  // store
    regex.lastIndex = 0;

    var count = 0;
    while (regex.test(str)) count++;

    regex.lastIndex = origLastIndex;  // restore
    return count;
}

較為簡單的替代方法是使用 match()

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    return (str.match(regex) || []).length;
}

有一個可能的陷阱:如果設定 /g 旗標且沒有相符項,str.match() 會傳回 null。我們在先前的程式碼中使用 [] 來避免這個陷阱,如果 match() 的結果不為真值。

提示和訣竅

本節提供一些在 JavaScript 中使用正規表示式的提示和訣竅。

引用文字

有時,當您手動組裝正規表示式時,您想要逐字使用給定的字串。這表示沒有任何特殊字元(例如 *[)應解釋為特殊字元,所有字元都需要跳脫。JavaScript 沒有內建此類引用的方法,但您可以編寫自己的函數 quoteText,其運作方式如下:

> console.log(quoteText('*All* (most?) aspects.'))
\*All\* \(most\?\) aspects\.

如果需要進行搜尋和取代多個出現,此功能特別好用。然後,要搜尋的值必須是設定有 global 旗標的正規表示式。使用 quoteText(),您可以使用任意字串。該函數如下所示

function quoteText(text) {
    return text.replace(/[\\^$.*+?()[\]{}|=!<>:-]/g, '\\$&');
}

所有特殊字元都會被跳脫,因為您可能想要在括號或方括號內引用多個字元。

陷阱:沒有斷言(例如 ^、$),正規表示式會在任何地方找到

如果您不使用 斷言,例如 ^$,大多數正規表示式方法會在任何地方找到樣式。例如:

> /aa/.test('xaay')
true
> /^aa$/.test('xaay')
false

比對所有或沒有任何內容

這是一個罕見的用途案例,但有時您需要一個正規表示式來比對所有或沒有任何內容。例如,一個函數可能有一個使用正規表示式進行篩選的參數。如果該參數遺失,請給它一個預設值,一個比對所有內容的正規表示式。

比對所有內容

空正規表示式比對所有內容。我們可以根據該正規表示式建立 RegExp 的一個執行個體,如下所示

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

然而,空正規表示式文字會是 //,它會被 JavaScript 解釋為註解。因此,以下是最接近您可以透過文字取得的:/(?:)/(空的非擷取群組)。該群組比對所有內容,同時不擷取任何內容,這會讓該群組影響 exec() 返回的結果。即使 JavaScript 本身在顯示空正規表示式時也會使用前述的表示法

> new RegExp('')
/(?:)/

比對沒有任何內容

空正規表示式有一個反向的正規表示式,比對沒有任何內容

> var never = /.^/;
> never.test('abc')
false
> never.test('')
false

手動實作後向觀察

後向觀察是一個斷言。類似於前向觀察,一個樣式用於檢查輸入中目前位置的某些內容,但否則會被忽略。與前向觀察相反,樣式的比對必須結束在目前位置(而不是從它開始)。

以下函數使用參數 name 的值取代字串 'NAME' 的每個出現,但僅當出現沒有在引號之前。我們透過「手動」檢查目前比對之前的字元來處理引號

function insertName(str, name) {
    return str.replace(
        /NAME/g,
        function (completeMatch, offset) {
            if (offset === 0 ||
                (offset > 0 && str[offset-1] !== '"')) {
                return name;
            } else {
                return completeMatch;
            }
        }
    );
}
> insertName('NAME "NAME"', 'Jane')
'Jane "NAME"'
> insertName('"NAME" NAME', 'Jane')
'"NAME" Jane'

另一種方式是在正規表示式中包含可能跳脫的字元。然後,您必須暫時在您正在搜尋的字串中加入一個前置詞;否則,您會錯過該字串開頭的比對

function insertName(str, name) {
    var tmpPrefix = ' ';
    str = tmpPrefix + str;
    str = str.replace(
        /([^"])NAME/g,
        function (completeMatch, prefix) {
            return prefix + name;
        }
    );
    return str.slice(tmpPrefix.length); // remove tmpPrefix
}

正規表示式秘笈

原子 (請參閱 原子:一般)

  • . (點) 符合每個事物,除了行終止符 (例如換行符)。使用 [\s\S] 才能真正符合所有事物。
  • 字元類別跳脫字元

    • \d 符合數字 ([0-9]);\D 符合非數字 ([^0-9])。
    • \w 符合拉丁字母數字字元加上底線 ([A-Za-z0-9_]);\W 符合所有其他字元。
    • \s 符合所有空白字元 (空白、跳格、換行符等);\S 符合所有非空白字元。
  • 字元類別 (字元組):[...][^...]

    • 原始字元:[abc] (所有字元,除了 \ ] - 本身符合)
    • 字元類別跳脫字元 (請參閱前一個):[\d\w]
    • 範圍:[A-Za-z0-9]
  • 群組

    • 擷取群組:(...);反向參照:\1
    • 非擷取群組:(?:...)

量詞 (請參閱 量詞)

  • 貪婪

    • ? * +
    • {n} {n,} {n,m}
  • 不貪婪:在任何貪婪量詞之後加上 ?

斷言 (請參閱 斷言)

  • 輸入開頭、輸入結尾:^ $
  • 在字詞邊界、不在字詞邊界:\b \B
  • 正向先行斷言:(?=...) (模式必須在後面,但否則會被忽略)
  • 負向先行斷言:(?!...) (模式不能在後面,但否則會被忽略)

析取:|

建立正規表示式 (請參閱 建立正規表示式)

  • 文字:/xyz/i (在載入時編譯)
  • 建構函式:new RegExp('xzy', 'i') (在執行時編譯)

旗標 (請參閱 旗標)

方法

有關使用旗標 /g 的提示,請參閱 旗標 /g 的問題

致謝

Mathias Bynens (@mathias) 和 Juan Ignacio Dopazo (@juandopazo) 建議使用 match()test() 來計算出現次數,而 Šime Vidas (@simevidas) 警告我如果沒有符合,要小心使用 match()。Andrea Giammarchi (@webreflection) 的 演講 中提到全球旗標會造成無限迴圈的缺點。Claude Pache 告訴我在 quoteText() 中多跳脫一些字元。

下一頁:20. 日期