九、正則表達式
譯者:飛龍
自豪地采用谷歌翻譯
一些人遇到問題時會認為,“我知道了,我會用正則表達式。”現在它們有兩個問題了。
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》
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/9-0.jpg
程序設計工具技術的發展與傳播方式是在混亂中不斷進化。在此過程中獲勝的往往不是優雅或杰出的一方,而是那些瞄準主流市場,并能夠填補市場需求的,或者碰巧與另一種成功的技術集成在一起的工具技術。
本章將會討論正則表達式(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
時,正則表達式引擎在你的字符串中尋找匹配,通過首先從字符串的開頭匹配表達式,然后從第二個字符匹配表達式,直到它找到匹配或達到字符串的末尾。 它會返回找到的第一個匹配,或者根本找不到任何匹配。
為了進行實際的匹配,引擎會像處理流程圖一樣處理正則表達式。 這是上例中用于家畜表達式的圖表:
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/9-1.svg
如果我們可以找到一條從圖表左側通往圖表右側的路徑,則可以說“表達式產生了匹配”。我們保存在字符串中的當前位置,每移動通過一個盒子,就驗證當前位置之后的部分字符串是否與該盒子匹配。
因此,如果我們嘗試從位置 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),或者沒有后綴字符的常規十進制數字。這是對應的圖表。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/9-2.svg
當匹配該表達式時,常常會發生一種情況:輸入的字符串進入上方(二進制)分支的匹配過程,但輸入中并不包含二進制數字。我們以匹配字符串"103"
為例,匹配過程只有遇到字符 3 時才知道進入了錯誤分支。該字符串匹配我們給出的表達式,但沒有匹配目前應當處于的分支。
因此匹配器執行“回溯”。進入一個分支時,匹配器會記住當前位置(在本例中,是在字符串起始,剛剛通過圖中第一個表示邊界的盒子),因此若當前分支無法匹配,可以回退并嘗試另一條分支。對于字符串"103"
,遇到字符 3 之后,它會開始嘗試匹配十六進制數字的分支,它會再次失敗,因為數字后面沒有h
。所以它嘗試匹配進制數字的分支,由于這條分支可以匹配,因此匹配器最后的會返回十進制數的匹配信息。
一旦字符串與模式完全匹配,匹配器就會停止。這意味著多個分支都可能匹配一個字符串,但匹配器最后只會使用第一條分支(按照出現在正則表達式中的出現順序排序)。
回溯也會發生在處理重復模式運算符(比如+
和*
)時。如果使用"abcxe"
匹配/^.*x/
,.*
部分,首先嘗試匹配整個字符串,接著引擎發現匹配模式還需要一個字符x
。由于字符串結尾沒有x
,因此*
運算符嘗試少匹配一個字符。但匹配器依然無法在abcx
之后找到x
字符,因此它會再次回溯,此時*
運算符只匹配abc
?,F在匹配器發現了所需的x
,接著報告從位置 0 到位置 4 匹配成功。
我們有可能編寫需要大量回溯的正則表達式。當模式能夠以許多種不同方式匹配輸入的一部分時,這種問題就會出現。例如,若我們在編寫匹配二進制數字的正則表達式時,一時糊涂,可能會寫出諸如/([01]+)+b/
之類的表達式。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/9-3.svg
若我們嘗試匹配一些只由 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 這種活動是編寫盡量短小的正則表達式,來匹配給定模式(而且只能匹配給定模式)。
針對以下幾項,編寫正則表達式,測試給定的子串是否在字符串中出現。正則表達式匹配的字符串,應該只包含以下描述的子串之一。除非明顯提到單詞邊界,否則千萬不要擔心邊界問題。當你的表達式有效時,請檢查一下能否讓正則表達式更短小。
car
和cat
pop
和prop
ferret
、ferry
和ferrari
以
ious
結尾的單詞句號、冒號、分號之前的空白字符
多于六個字母的單詞
不包含
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}'`);
}
}