天天看點

JavaScript 程式設計精解 中文第三版 九、正規表達式

九、正規表達式

原文: Regular Expressions 譯者: 飛龍 協定: CC BY-NC-SA 4.0 自豪地采用 谷歌翻譯 部分參考了 《JavaScript 程式設計精解(第 2 版)》

一些人遇到問題時會認為,“我知道了,我會用正規表達式。”現在它們有兩個問題了。

Jamie Zawinski

Yuan-Ma said, ‘When you cut against the grain of the wood, much strength is needed. When you program against the grain of the problem, much code is needed.’

Master Yuan-Ma,《The Book of Programming》

JavaScript 程式設計精解 中文第三版 九、正規表達式

程式設計工具技術的發展與傳播方式是在混亂中不斷進化。在此過程中獲勝的往往不是優雅或傑出的一方,而是那些瞄準主流市場,并能夠填補市場需求的,或者碰巧與另一種成功的技術內建在一起的工具技術。

本章将會讨論正規表達式(regular expression)這種工具。正規表達式是一種描述字元串資料模式的方法。它們形成了一種小而獨立的語言,也是 JavaScript 和許多其他語言和系統的一部分。

正規表達式雖然不易了解,但是功能非常強大。正規表達式的文法有點詭異,JavaScript 提供的程式設計接口也不太易用。但正規表達式的确是檢查、處理字元串的強力工具。如果讀者能夠正确了解正規表達式,将會成為更高效的程式員。

建立正規表達式

正規表達式是一種對象類型。我們可以使用兩種方法來構造正規表達式:一是使用

RegExp

構造器構造一個正規表達式對象;二是使用斜杠(

/

)字元将模式包圍起來,生成一個字面值。

let re1 = new RegExp("abc");
let re2 = /abc/;           

這兩個正規表達式對象都表示相同的模式:字元

a

後緊跟一個

b

,接着緊跟一個

c

使用

RegExp

構造器時,需要将模式書寫成普通的字元串,是以反斜杠的使用規則與往常相同。

第二種寫法将模式寫在斜杠之間,處理反斜杠的方式與第一種方法略有差别。首先,由于斜杠會結束整個模式,是以模式中包含斜杠時,需在斜杠前加上反斜杠。此外,如果反斜杠不是特殊字元代碼(比如

\n

)的一部分,則會保留反斜杠,不像字元串中會将其忽略,也不會改變模式的含義。一些字元,比如問号、加号在正規表達式中有特殊含義,如果你想要表示其字元本身,需要在字元前加上反斜杠。

let eighteenPlus = /eighteen\+/;           

比對測試

正規表達式對象有許多方法。其中最簡單的就是

test

方法。

test

方法接受使用者傳遞的字元串,并傳回一個布爾值,表示字元串中是否包含能與表達式模式比對的字元串。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false           

不包含特殊字元的正規表達式簡單地表示一個字元序列。如果使用

test

測試字元串時,字元串中某處出現

abc

(不一定在開頭),則傳回

true

字元集

我們也可調用

indexOf

來找出字元串中是否包含

abc

。正規表達式允許我們表達一些更複雜的模式。

假如我們想比對任意數字。在正規表達式中,我們可以将一組字元放在兩個方括号之間,該表達式可以比對方括号中的任意字元。

下面兩個表達式都可以比對包含數字的字元串。

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true           

我們可以在方括号中的兩個字元間插入連字元(

),來指定一個字元範圍,範圍内的字元順序由字元 Unicode 代碼決定。在 Unicode 字元順序中,0 到 9 是從左到右彼此相鄰的(代碼從48到57),是以

[0-9]

覆寫了這一範圍内的所有字元,也就是說可以比對任意數字。

許多常見字元組都有自己的内置簡寫。 數字就是其中之一:

\ d

[0-9]

表示相同的東西。

  • \d

    任意數字元号
  • \w

    字母和數字元号(單詞符号)
  • \s

    任意空白符号(空格,制表符,換行符等類似符号)
  • \D

    非數字元号
  • \W

    非字母和數字元号
  • \S

    非空白符号
  • .

    除了換行符以外的任意符号

是以你可以使用下面的表達式比對類似于

30-01-2003 15:20

這樣的日期數字格式:

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("30-01-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false           

這個表達式看起來是不是非常糟糕?該表達式中一半都是反斜杠,影響讀者的了解,使得讀者難以揣摩表達式實際想要表達的模式。稍後我們會看到一個稍加改進的版本。

我們也可以将這些反斜杠代碼用在方括号中。例如,

[\d.]

比對任意數字或一個句号。但是方括号中的句号會失去其特殊含義。其他特殊字元也是如此,比如

+

你可以在左方括号後添加脫字元(

^

)來排除某個字元集,即表示不比對這組字元中的任何字元。

let notBinary = /[^01]/;
console.log(notBinary.test("1100100010100110"));
// → false
console.log(notBinary.test("1100100010200110"));
// → true           

部分模式重複

現在我們已經知道如何比對一個數字。如果我們想比對一個整數(一個或多個數字的序列),該如何處理呢?

在正規表達式某個元素後面添加一個加号(

+

),表示該元素至少重複一次。是以

/\d+/

可以比對一個或多個數字字元。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true           

星号(

*

)擁有類似含義,但是可以比對模式不存在的情況。在正規表達式的元素後添加星号并不會導緻正規表達式停止比對該元素後面的字元。隻有正規表達式無法找到可以比對的文本時才會考慮比對該元素從未出現的情況。

元素後面跟一個問号表示這部分模式“可選”,即模式可能出現 0 次或 1 次。下面的例子可以比對

neighbour

u

出現1次),也可以比對

neighbor

u

沒有出現)。

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true           

我們可以使用花括号準确指明某個模式的出現次數。例如,在某個元素後加上

{4}

,則該模式需要出現且隻能出現 4 次。也可以使用花括号指定一個範圍:比如

{2,4}

表示該元素至少出現 2 次,至多出現 4 次。

這裡給出另一個版本的正規表達式,可以比對日期、月份、小時,每個數字都可以是一位或兩位數字。這種形式更易于解釋。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("30-1-2003 8:45"));
// → true           

花括号中也可以省略逗号任意一側的數字,表示不限制這一側的數量。是以

{,5}

表示 0 到 5 次,而

{5,}

表示至少五次。

子表達式分組

為了一次性對多個元素使用

*

或者

+

,那麼你必須使用圓括号,建立一個分組。對于後面的操作符來說,圓括号裡的表達式算作單個元素。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true           

第一個和第二個

+

字元分别作用于

boo

hoo

o

字元,而第三個

+

字元則作用于整個元組(

hoo+

),可以比對

hoo+

這種正規表達式出現一次及一次以上的情況。

示例中表達式末尾的i表示正規表達式不區分大小寫,雖然模式中使用小寫字母,但可以比對輸入字元串中的大寫字母

B

比對和分組

test

方法是比對正規表達式最簡單的方法。該方法隻負責判斷字元串是否與某個模式比對。正規表達式還有一個

exec

(執行,execute)方法,如果無法比對模式則傳回

null

,否則傳回一個表示比對字元串資訊的對象。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8           

exec

方法傳回的對象包含index屬性,表示字元串成功比對的起始位置。除此之外,該對象看起來像(而且實際上就是)一個字元串數組,其首元素是與模式比對的字元串——在上面的例子中就是我們查找的數字序列。

字元串也有一個類似的match方法。

console.log("one two 100".match(/\d+/));
// → ["100"]           

若正規表達式包含使用圓括号包圍的子表達式分組,與這些分組比對的文本也會出現在數組中。第一個元素是與整個模式比對的字元串,其後是與第一個分組比對的部分字元串(表達式中第一次出現左圓括号的那部分),然後是第二個分組。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]           

若分組最後沒有比對任何字元串(例如在元組後加上一個問号),結果數組中與該分組對應的元素将是

undefined

。類似的,若分組比對了多個元素,則數組中隻包含最後一個比對項。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]           

分組是提取部分字元串的實用特性。如果我們不隻是想驗證字元串中是否包含日期,還想将字元串中的日期字元串提取出來,并将其轉換成等價的日期對象,那麼我們可以使用圓括号包圍那些比對數字的模式字元串,并直接将日期從

exec

的結果中提取出來。

不過,我們暫且先讨論另一個話題——在 JavaScript 中存儲日期和時間的内建方法。

日期類

JavaScript 提供了用于表示日期的标準類,我們甚至可以用其表示時間點。該類型名為

Date

。如果使用

new

建立一個

Date

對象,你會得到目前的日期和時間。

console.log(new Date());
// → Mon Nov 13 2017 16:19:11 GMT+0100 (CET)           

你也可以建立表示特定時間的對象。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)           

JavaScript 中約定是:使用從 0 開始的數字表示月份(是以使用 11 表示 12 月),而使用從1開始的數字表示日期。這非常容易令人混淆。要注意這個細節。

構造器的後四個參數(小時、分鐘、秒、毫秒)是可選的,如果使用者沒有指定這些參數,則參數的值預設為 0。

時間戳存儲為 UTC 時區中 1970 年以來的毫秒數。 這遵循一個由“Unix 時間”設定的約定,該約定是在那個時候發明的。 你可以對 1970 年以前的時間使用負數。 日期對象上的

getTime

方法傳回這個數字。 你可以想象它會很大。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)           

如果你為

Date

構造器指定了一個參數,構造器會将該參數看成毫秒數。你可以建立一個新的

Date

對象,并調用

getTime

方法,或調用

Date.now()

函數來擷取目前時間對應的毫秒數。

Date

對象提供了一些方法來提取時間中的某些數值,比如

getFullYear

getMonth

getDate

getHours

getMinutes

getSeconds

。除了

getFullYear

之外該對象還有一個

getYear

方法,會傳回使用兩位數字表示的年份(比如 93 或 14),但很少用到。

通過在希望捕獲的那部分模式字元串兩邊加上圓括号,我們可以從字元串中建立對應的

Date

對象。

function getDate(string) {
  let [_, day, month, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)           

_

(下劃線)綁定被忽略,并且隻用于跳過由

exec

傳回的數組中的,完整比對元素。

單詞和字元串邊界

不幸的是,

getDate

會從字元串

"100-1-30000"

中提取出一個無意義的日期——

00-1-3000

。正規表達式可以從字元串中的任何位置開始比對,在我們的例子中,它從第二個字元開始比對,到倒數第二個字元為止。

如果我們想要強制比對整個字元串,可以使用

^

标記和

$

标記。脫字元表示輸入字元串起始位置,美元符号表示字元串結束位置。是以

/^\d+$/

可以比對整個由一個或多個數字組成的字元串,

/^!/

比對任何以感歎号開頭的字元串,而

/x^/

不比對任何字元串(字元串起始位置之前不可能有字元

x

)。

另一方面,如果我們想要確定日期字元串起始結束位置在單詞邊界上,可以使用

\b

标記。所謂單詞邊界,指的是起始和結束位置都是單詞字元(也就是

\w

代表的字元集合),而起始位置的前一個字元以及結束位置的後一個字元不是單詞字元。

console.log(/cat/.test("concatenate"));
// → true
console.log(/\bcat\b/.test("concatenate"));
// → false           

這裡需要注意,邊界标記并不比對實際的字元,隻在強制正規表達式滿足模式中的條件時才進行比對。

選項模式

假如我們不僅想知道文本中是否包含數字,還想知道數字之後是否跟着一個單詞(

pig

cow

chicken

)或其複數形式。

那麼我們可以編寫三個正規表達式并輪流測試,但還有一種更好的方式。管道符号(

|

)表示從其左側的模式和右側的模式任意選擇一個進行比對。是以代碼如下所示。

let animalCount = /\b\d+ (pig|cow|chicken)s?\b/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pigchickens"));
// → false           

小括号可用于限制管道符号選擇的模式範圍,而且你可以連續使用多個管道符号,表示從多于兩個模式中選擇一個備選項進行比對。

比對原理

從概念上講,當你使用

exec

test

時,正規表達式引擎在你的字元串中尋找比對,通過首先從字元串的開頭比對表達式,然後從第二個字元比對表達式,直到它找到比對或達到字元串的末尾。 它會傳回找到的第一個比對,或者根本找不到任何比對。

為了進行實際的比對,引擎會像處理流程圖一樣處理正規表達式。 這是上例中用于家畜表達式的圖表:

JavaScript 程式設計精解 中文第三版 九、正規表達式

如果我們可以找到一條從圖表左側通往圖表右側的路徑,則可以說“表達式産生了比對”。我們儲存在字元串中的目前位置,每移動通過一個盒子,就驗證目前位置之後的部分字元串是否與該盒子比對。

是以,如果我們嘗試從位置 4 比對

"the 3 pigs"

,大緻會以如下的過程通過流程圖:

  • 在位置 4,有一個單詞邊界,是以我們通過第一個盒子。
  • 依然在位置 4,我們找到一個數字,是以我們通過第二個盒子。
  • 在位置 5,有一條路徑循環回到第二個盒子(數字)之前,而另一條路徑則移動到下一個盒子(單個空格字元)。由于這裡是一個空格,而非數字,是以我們必須選擇第二條路徑。
  • 我們目前在位置 6(

    pig

    的起始位置),而表中有三路分支。這裡看不到

    "cow"

    "chicken"

    ,但我們看到了

    "pig"

    ,是以選擇

    "pig"

    這條分支。
  • 在位置 9(三路分支之後),有一條路徑跳過了

    s

    這個盒子,直接到達最後的單詞邊界,另一條路徑則比對

    s

    。這裡有一個

    s

    字元,而非單詞邊界,是以我們通過

    s

    這個盒子。
  • 我們在位置 10(字元串結尾),隻能比對單詞邊界。而字元串結尾可以看成一個單詞邊界,是以我們通過最後一個盒子,成功比對字元串。

回溯

正規表達式

/\b([01]+b|\d+|[\da-f]h)\b/

可以比對三種字元串:以

b

結尾的二進制數字,以

h

結尾的十六進制數字(即以 16 為進制,字母

a

f

表示數字 10 到 15),或者沒有字尾字元的正常十進制數字。這是對應的圖表。

JavaScript 程式設計精解 中文第三版 九、正規表達式

當比對該表達式時,常常會發生一種情況:輸入的字元串進入上方(二進制)分支的比對過程,但輸入中并不包含二進制數字。我們以比對字元串

"103"

為例,比對過程隻有遇到字元 3 時才知道進入了錯誤分支。該字元串比對我們給出的表達式,但沒有比對目前應當處于的分支。

是以比對器執行“回溯”。進入一個分支時,比對器會記住目前位置(在本例中,是在字元串起始,剛剛通過圖中第一個表示邊界的盒子),是以若目前分支無法比對,可以回退并嘗試另一條分支。對于字元串

"103"

,遇到字元 3 之後,它會開始嘗試比對十六進制數字的分支,它會再次失敗,因為數字後面沒有

h

。是以它嘗試比對進制數字的分支,由于這條分支可以比對,是以比對器最後的會傳回十進制數的比對資訊。

一旦字元串與模式完全比對,比對器就會停止。這意味着多個分支都可能比對一個字元串,但比對器最後隻會使用第一條分支(按照出現在正規表達式中的出現順序排序)。

回溯也會發生在處理重複模式運算符(比如

+

*

)時。如果使用

"abcxe"

比對

/^.*x/

.*

部分,首先嘗試比對整個字元串,接着引擎發現比對模式還需要一個字元

x

。由于字元串結尾沒有

x

,是以

*

運算符嘗試少比對一個字元。但比對器依然無法在

abcx

之後找到

x

字元,是以它會再次回溯,此時

*

運算符隻比對

abc

。現在比對器發現了所需的

x

,接着報告從位置 0 到位置 4 比對成功。

我們有可能編寫需要大量回溯的正規表達式。當模式能夠以許多種不同方式比對輸入的一部分時,這種問題就會出現。例如,若我們在編寫比對二進制數字的正規表達式時,一時糊塗,可能會寫出諸如

/([01]+)+b/

之類的表達式。

JavaScript 程式設計精解 中文第三版 九、正規表達式

若我們嘗試比對一些隻由 0 與 1 組成的長序列,比對器首先會不斷執行内部循環,直到它發現沒有數字為止。接下來比對器注意到,這裡不存在

b

,是以向前回溯一個位置,開始執行外部循環,接着再次放棄,再次嘗試執行一次内部循環。該過程會嘗試這兩個循環的所有可能路徑。這意味着每多出一個字元,其工作量就會加倍。甚至隻需較少的一堆字元,就可使比對實際上永不停息地執行下去。

replace

方法

字元串有一個

replace

方法,該方法可用于将字元串中的一部分替換為另一個字元串。

console.log("papa".replace("p", "m"));
// → mapa           

該方法第一個參數也可以是正規表達式,這種情況下會替換正規表達式首先比對的部分字元串。若在正規表達式後追加

g

選項(全局,Global),該方法會替換字元串中所有比對項,而不是隻替換第一個。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar           

如果 JavaScript 為

replace

添加一個額外參數,或提供另一個不同的方法(

replaceAll

),來區分替換一次比對還是全部比對,将會是較為明智的方案。遺憾的是,因為某些原因 JavaScript 依靠正規表達式的屬性來區分替換行為。

如果我們在替換字元串中使用元組,就可以展現出

replace

方法的真實威力。例如,假設我們有一個規模很大的字元串,包含了人的名字,每個名字占據一行,名字格式為“姓,名”。若我們想要交換姓名,并移除中間的逗号(轉變成“名,姓”這種格式),我們可以使用下面的代碼:

console.log(
  "Liskov, Barbara\nMcCarthy, John\nWadler, Philip"
    .replace(/(\w+), (\w+)/g, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Philip Wadler           

替換字元串中的

$1

$2

引用了模式中使用圓括号包裹的元組。

$1

會替換為第一個元組比對的字元串,

$2

會替換為第二個,依次類推,直到

$9

為止。也可以使用

$&

來引用整個比對。

第二個參數不僅可以使用字元串,還可以使用一個函數。每次比對時,都會調用函數并以比對元組(也可以是比對整體)作為參數,該函數傳回值為需要插入的新字元串。

這裡給出一個小示例:

let s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g,
            str => str.toUpperCase()));
// → the CIA and FBI           

這裡給出另一個值得讨論的示例:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // only one left, remove the 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs           

該程式接受一個字元串,找出所有滿足模式“一個數字緊跟着一個單詞(數字和字母)”的字元串,傳回時将捕獲字元串中的數字減一。

元組

(\d+)

最後會變成函數中的

amount

參數,而·(\w+)

元組将會綁定

unit

。該函數将

amount

轉換成數字(由于該參數是

\d+`的比對結果,是以此過程總是執行成功),并根據剩下 0 還是 1,決定如何做出調整。

貪婪模式

replace

編寫一個函數移除 JavaScript 代碼中的所有注釋也是可能的。這裡我們嘗試一下:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1           

或運算符之前的部分比對兩個斜杠字元,後面跟着任意數量的非換行字元。多行注釋部分較為複雜,我們使用

[^]

(任何非空字元集合)來比對任意字元。我們這裡無法使用句号,因為塊注釋可以跨行,句号無法比對換行符。

但最後一行的輸出顯然有錯。

為何?

在回溯一節中已經提到過,表達式中的

[^]*

部分會首先比對所有它能比對的部分。如果其行為引起模式的下一部分比對失敗,比對器才會回溯一個字元,并再次嘗試。在本例中,比對器首先比對整個剩餘字元串,然後向前移動。比對器回溯四個字元後,會找到*/,并完成比對。這并非我們想要的結果。我們的意圖是比對單個注釋,而非到達代碼末尾并找到最後一個塊注釋的結束部分。

因為這種行為,是以我們說模式重複運算符(

+

*

?

{}

)是“貪婪”的,指的是這些運算符會盡量多地比對它們可以比對的字元,然後回溯。若讀者在這些符号後加上一個問号(

+?

*?

??

{}?

),它們會變成非貪婪的,此時這些符号會盡量少地比對字元,隻有當剩下的模式無法比對時才會多進行比對。

而這便是我們想要的情況。通過讓星号盡量少地比對字元,我們可以比對第一個

*/

,進而比對一個塊注釋,而不會比對過多内容。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1           

對于使用了正規表達式的程式而言,其中出現的大量缺陷都可歸咎于一個問題:在非貪婪模式效果更好時,無意間錯用了貪婪運算符。若使用了模式重複運算符,請首先考慮一下是否可以使用非貪婪符号替代貪婪運算符。

動态建立

RegExp

對象

有些情況下,你無法在編寫代碼時準确知道需要比對的模式。假設你想尋找文本片段中的使用者名,并使用下劃線字元将其包裹起來使其更顯眼。由于你隻有在程式運作時才知道姓名,是以你無法使用基于斜杠的記法。

但你可以建構一個字元串,并使用

RegExp

構造器根據該字元串構造正規表達式對象。

這裡給出一個示例。

let name = "harry";
let text = "Harry is a suspicious character.";
let regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.           

由于我們建立正規表達式時使用的是普通字元串,而非使用斜杠包圍的正規表達式,是以如果想建立

\b

邊界,我們不得不使用兩個反斜杠。

RegExp

構造器的第二個參數包含了正規表達式選項。在本例中,

"gi"

表示全局和不區分大小寫。

但由于我們的使用者是怪異的青少年,如果使用者将名字設定為

"dea+hl[]rd"

,将會發生什麼?這将會導緻正規表達式變得沒有意義,無法比對使用者名。

為了能夠處理這種情況,我們可以在任何有特殊含義的字元前添加反斜杠。

let name = "dea+hl[]rd";
let text = "This dea+hl[]rd guy is super annoying.";
let escaped = name.replace(/[^\w\s]/g, "\\$&");
let regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_><_"));
// → This _dea+hl[]rd_ guy is super annoying.           

search

字元串的

indexOf

方法不支援以正規表達式為參數。

但還有一個

search

方法,調用該方法時需要傳遞一個正規表達式。類似于

indexOf

,該方法會傳回首先比對的表達式的索引,若沒有找到則傳回 –1。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1           

遺憾的是,沒有任何方式可以指定比對的起始偏移(就像

indexOf

的第二個參數),而指定起始偏移這個功能是很實用的。

lastIndex

屬性

exec

方法同樣沒提供友善的方法來指定字元串中的起始比對位置。但我們可以使用一種比較麻煩的方法來實作該功能。

正規表達式對象包含了一些屬性。其中一個屬性是

source

,該屬性包含用于建立正規表達式的字元串。另一個屬性是

lastIndex

,可以在極少數情況下控制下一次比對的起始位置。

所謂的極少數情況,指的是當正規表達式啟用了全局(

g

)或者粘性(

y

),并且使用

exec

比對模式的時候。此外,另一個解決方案應該是向

exec

傳遞的額外參數,但 JavaScript 的正規表達式接口能設計得如此合理才是怪事。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5           

如果成功比對模式,

exec

調用會自動更新

lastIndex

屬性,來指向比對字元串後的位置。如果無法比對,會将

lastIndex

清零(就像新建構的正規表達式對象

lastIndex

屬性為零一樣)。

全局和粘性選項之間的差別在于,啟用粘性時,僅當比對直接從

lastIndex

開始時,搜尋才會成功,而全局搜尋中,它會搜尋比對可能起始的所有位置。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null           

對多個

exec

調用使用共享的正規表達式值時,這些

lastIndex

屬性的自動更新可能會導緻問題。 你的正規表達式可能意外地在之前的調用留下的索引處開始。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null           

全局選項還有一個值得深思的效果,它會改變

match

比對字元串的工作方式。如果調用

match

時使用了全局表達式,不像

exec

傳回的數組,

match

會找出所有比對模式的字元串,并傳回一個包含所有比對字元串的數組。

console.log("Banana".match(/an/g));
// → ["an", "an"]           

是以使用全局正規表達式時需要倍加小心。隻有以下幾種情況中,你确實需要全局表達式即調用

replace

方法時,或是需要顯示使用

lastIndex

時。這也基本是全局表達式唯一的應用場景了。

循環比對

一個常見的事情是,找出字元串中所有模式的出現位置,這種情況下,我們可以在循環中使用

lastIndex

exec

通路比對的對象。

let input = "A string with 3 numbers in it... 42 and 88.";
let number = /\b(\d+)\b/g;
let match;
while (match = number.exec(input)) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40           

這裡我們利用了指派表達式的一個特性,該表達式的值就是被賦予的值。是以通過使用

match=re.exec(input)

作為

while

語句的條件,我們可以在每次疊代開始時執行比對,将結果儲存在變量中,當無法找到更多比對的字元串時停止循環。

解析

INI

檔案

為了總結一下本章介紹的内容,我們來看一下如何調用正規表達式來解決問題。假設我們編寫一個程式從網際網路上擷取我們敵人的資訊(這裡我們實際上不會編寫該程式,僅僅編寫讀取配置檔案的那部分代碼,對不起)。配置檔案如下所示。

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; comments are preceded by a semicolon...
; each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn           

該配置檔案格式的文法規則如下所示(它是廣泛使用的格式,我們通常稱之為

INI

檔案):

  • 忽略空行和以分号起始的行。
  • []

    包圍的行表示一個新的節(section)。
  • 如果行中是一個辨別符(包含字母和數字),後面跟着一個=字元,則表示向目前節添加選項。
  • 其他的格式都是無效的。

我們的任務是将這樣的字元串轉換為一個對象,該對象的屬性包含沒有節的設定的字元串,和節的子對象的字元串,節的子對象也包含節的設定。

由于我們需要逐行處理這種格式的檔案,是以預處理時最好将檔案分割成一行行文本。我們使用第 6 章中的

string.split("\n")

來分割檔案内容。但是一些作業系統并非使用換行符來分隔行,而是使用回車符加換行符(

"\r\n"

)。考慮到這點,我們也可以使用正規表達式作為

split

方法的參數,我們使用類似于

/\r?\n/

的正規表達式,這樣可以同時支援

"\n"

"\r\n"

兩種分隔符。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let currentSection = {name: null, fields: []};
  let categories = [currentSection];

  string.split(/\r?\n/).forEach(line => {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
      section = result[match[1]] = {};
    } else if (!/^\s*(;.*)?$/.test(line)) {
      throw new Error("Line '" + line + "' is not valid.");
    }
  });

  return result;
}

console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}           

代碼周遊檔案的行并建構一個對象。 頂部的屬性直接存儲在該對象中,而在節中找到的屬性存儲在單獨的節對象中。

section

綁定指向目前節的對象。

有兩種重要的行 - 節标題或屬性行。 當一行是正常屬性時,它将存儲在目前節中。 當它是一個節标題時,建立一個新的節對象,并設定

section

來指向它。

這裡需要注意,我們反複使用

^

$

確定表達式比對整行,而非一行中的一部分。如果不使用這兩個符号,大多數情況下程式也可以正常工作,但在處理特定輸入時,程式就會出現不合理的行為,我們一般很難發現這個缺陷的問題所在。

if (match = string.match(...))

類似于使用指派作為

while

的條件的技巧。你通常不确定你對

match

的調用是否成功,是以你隻能在測試它的

if

語句中通路結果對象。 為了不打破

else if

形式的令人愉快的鍊條,我們将比對結果賦給一個綁定,并立即使用該指派作為

if

語句的測試。

國際化字元

由于 JavaScript 最初的實作非常簡單,而且這種簡單的處理方式後來也成了标準,是以 JavaScript 正規表達式處理非英語字元時非常無力。例如,就 JavaScript 的正規表達式而言,“單詞字元”隻是 26 個拉丁字母(大寫和小寫)和數字,而且由于某些原因還包括下劃線字元。像

α

β

這種明顯的單詞字元,則無法比對

\w

(會比對大寫的

\W

,因為它們屬于非單詞字元)。

由于奇怪的曆史性意外,

\s

(空白字元)則沒有這種問題,會比對所有 Unicode 标準中規定的空白字元,包括不間斷空格和蒙古文元音分隔符。

另一個問題是,預設情況下,正規表達式使用代碼單元,而不是實際的字元,正如第 5 章中所讨論的那樣。 這意味着由兩個代碼單元組成的字元表現很奇怪。

console.log(/\ud83c\udf4e{3}/.test("\ud83c\udf4e\ud83c\udf4e\ud83c\udf4e"));
// → false
console.log(/<.>/.test("<\ud83c\udf39>"));
// → false
console.log(/<.>/u.test("<\ud83c\udf39>"));
// → true           

問題是第一行中的

"\ud83c\udf4e"

(emoji 蘋果)被視為兩個代碼單元,而

{3}

部分僅适用于第二個。 與之類似,點比對單個代碼單元,而不是組成玫瑰 emoji 符号的兩個代碼單元。

你必須在正規表達式中添加一個

u

選項(表示 Unicode),才能正确處理這些字元。 不幸的是,錯誤的行為仍然是預設行為,因為改變它可能會導緻依賴于它的現有代碼出現問題。

盡管這是剛剛标準化的,在撰寫本文時尚未得到廣泛支援,但可以在正規表達式中使用

\p

(必須啟用 Unicode 選項)以比對 Unicode 标準配置設定了給定屬性的所有字元。

console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false
console.log(/\p{Alphabetic}/u.test("α"));
// → true
console.log(/\p{Alphabetic}/u.test("!"));
// → false           

Unicode 定義了許多有用的屬性,盡管找到你需要的屬性可能并不總是沒有意義。 你可以使用

\p{Property=Value}

符号來比對任何具有該屬性的給定值的字元。 如果屬性名稱保持不變,如

\p{Name}

中那樣,名稱被假定為二進制屬性,如

Alphabetic

,或者類别,如

Number

本章小結

正規表達式是表示字元串模式的對象,使用自己的語言來表達這些模式:

  • /abc/

    :字元序列
  • /[abc]/

    :字元集中的任何字元
  • /[^abc]/

    :不在字元集中的任何字元
  • /[0-9]/

    :字元範圍内的任何字元
  • /x+/

    :出現一次或多次
  • /x+?/

    :出現一次或多次,非貪婪模式
  • /x*/

    :出現零次或多次
  • /x??/

    :出現零次或多次,非貪婪模式
  • /x{2,4}/

    :出現兩次到四次
  • /(abc)/

    :元組
  • /a|b|c/

    :比對任意一個模式
  • /\d/

    :數字字元
  • /\w/

    :字母和數字字元(單詞字元)
  • /\s/

    :任意空白字元
  • /./

    :任意字元(除換行符外)
  • /\b/

    :單詞邊界
  • /^/

    :輸入起始位置
  • /$/

    :輸入結束位置

正規表達式有一個

test

方法來測試給定的字元串是否比對它。 它還有一個

exec

方法,當找到比對項時,傳回一個包含所有比對組的數組。 這樣的數組有一個

index

屬性,用于表明比對開始的位置。

match

方法來對正确表達式比對它們,以及

search

方法來搜尋字元串,隻傳回比對的起始位置。 他們的

replace

方法可以用替換字元串或函數替換模式比對。

正規表達式擁有選項,這些選項寫在閉合斜線後面。

i

選項使比對不區分大小寫。

g

選項使表達式成為全聚德,除此之外,它使

replace

方法替換所有執行個體,而不是第一個。

y

選項使它變為粘性,這意味着它在搜尋比對時不會向前搜尋并跳過部分字元串。

u

選項開啟 Unicode 模式,該模式解決了處理占用兩個代碼單元的字元時的一些問題。

正規表達式是難以駕馭的強力工具。它可以簡化一些任務,但用到一些複雜問題上時也會難以控制管理。想要學會使用正規表達式的重要一點是:不要将其用到無法幹淨地表達為正規表達式的問題。

習題

在做本章習題時,讀者不可避免地會對一些正規表達式的莫名其妙的行為感到困惑,因而備受挫折。讀者可以使用類似于

http://debuggex.com/

這樣的線上學習工具,将你想編寫的正規表達式可視化,并試驗其對不同輸入字元串的響應。

RegexpGolf

Code Golf 是一種遊戲,嘗試盡量用最少的字元來描述特定程式。類似的,Regexp Golf 這種活動是編寫盡量短小的正規表達式,來比對給定模式(而且隻能比對給定模式)。

針對以下幾項,編寫正規表達式,測試給定的子串是否在字元串中出現。正規表達式比對的字元串,應該隻包含以下描述的子串之一。除非明顯提到單詞邊界,否則千萬不要擔心邊界問題。當你的表達式有效時,請檢查一下能否讓正規表達式更短小。

  1. car

    cat

  2. pop

    prop

  3. ferret

    ferry

    ferrari

  4. ious

    結尾的單詞
  5. 句号、冒号、分号之前的空白字元
  6. 多于六個字母的單詞
  7. 不包含

    e

    (或者

    E

    )的單詞

需要幫助時,請參考本章總結中的表格。使用少量測試字元串來測試每個解決方案。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["hottentottententen"],
       ["no", "hotten totten tenten"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "learning ape", "BEET"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}           

QuotingStyle

想象一下,你編寫了一個故事,自始至終都使用單引号來标記對話。現在你想要将對話的引号替換成雙引号,但不能替換在縮略形式中使用的單引号。

思考一下可以區分這兩種引号用法的模式,并手動調用

replace

方法進行正确替換。

let text = "'I'm the cook,' he said, 'it's my job.'";
// Change this call.
console.log(text.replace(/A/g, "B"));
// → "I'm the cook," he said, "it's my job."           

NumbersAgain

編寫一個表達式,隻比對 JavaScript 風格的數字。支援數字前可選的正号與負号、十進制小數點、指數計數法(

5e-3

1E10

,指數前也需要支援可選的符号)。也請注意小數點前或小數點後的數字也是不必要的,但數字不能隻有小數點。例如

.5

5.

都是合法的 JavaScript 數字,但單個點則不是。

// Fill in this regular expression.
let number = /^...$/;

// Tests:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
                 "1.3e2", "1E-4", "1e+12"]) {
  if (!number.test(str)) {
    console.log(`Failed to match '${str}'`);
  }
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
                 ".5.", "1f5", "."]) {
  if (number.test(str)) {
    console.log(`Incorrectly accepted '${str}'`);
  }
}           

繼續閱讀