正則之坑知多少

Colafornia發表於2019-03-04

cover

原文地址: 又雙叒叕學習了一遍正規表示式

前兩天在 Twitter 上看到了題圖,感覺又是個大坑,在此介紹正則本身和在 JavaScript 中使用正則的坑。如有錯誤,煩請指正。

首先說說 JavaScript 中正則的坑。

字面量 VS RegExp()

在 JavaScript 中建立正規表示式有兩種方式:

// 正則字面量
var pattern1 = /\d+/;

// 構造 RegExp 例項,以字串形式傳入正則
var pattern2 = new RegExp('\\d+');
複製程式碼

兩種方式建立出的正則沒有任何差別。從建立方式上看,正則字面量可讀性更優,因為正則中經常使用 \ 反斜槓在字串中是一個轉義字元,想以字串中表示反斜槓的話,需要使用 \\ 兩個反斜槓。

但是,需要注意,每個正規表示式都有一個獨立的物件表示,每次建立正規表示式,都會為其建立一個新的正規表示式物件,這和其它型別(字串、陣列)不同

我們可以通過讓正規表示式只編譯一次並將其儲存在一個變數中以供後續使用來實現優化。

因此,第一段程式碼將建立三個正規表示式物件,並進行了三次編譯,雖然表示式是相同的。而第二段程式碼則效能更高。

console.log(/abc/.test('a'));
console.log(/abc/.test('ab'));
console.log(/abc/.test('abc'));

var pattern = /abc/;
console.log(pattern.test('a'));
console.log(pattern.test('ab'));
console.log(pattern.test('abc'));
複製程式碼

這其中有效能隱患。先記住這一點,我們繼續往下看。

冷知識 lastIndex

這裡我們來解釋下題圖中的情況是怎麼回事。

cover

這其實是全域性匹配的坑,也就是正則後的 /g 符號。

var pattern = /abc/g;
console.log(pattern.global) // true
複製程式碼

/g 標識的正則作為全域性匹配,也就擁有了 global 屬性並導致了題圖中呈現的異常行為。

全域性正規表示式的另一個屬性 lastIndex 用於存放上一次匹配文字之後的第一個字元的位置。

RegExp.prototype.exec()RegExp.prototype.test() 方法都以 lastIndex 屬性中所儲存的位置作為下次正則匹配檢索的起點。連續呼叫這兩個方法就可以遍歷字串中的所有匹配文字。

lastIndex 屬性可讀寫,當 RegExp.prototype.exec()RegExp.prototype.test() 再也找不到可以匹配的文字時,會自動把 lastIndex 屬性重置為 0。因此使用這兩個方法來檢索文字,是可以無限執行下去的。我們也就明白了題圖中為何每次執行 RegExp.prototype.test() 返回的結果都不一樣。

不僅如此,看看下面這段程式碼,能看出來有什麼問題嗎?

var count = 0;
while (/a/g.test('ababc')) count++;
複製程式碼

不要輕易拷貝到控制檯中嘗試,會把瀏覽器卡死的。

由於每個迴圈中 /a/g.test('ababc') 都建立了新的正規表示式物件,每次匹配都是重新開始,這一操作會無限執行下去,形成死迴圈。

正確的寫法是:

var count = 0;
var regex = /a/g;
while (regex.test('ababc')) count++;
複製程式碼

這樣,每次迴圈中操作的都是同一個正規表示式物件,隨著每次匹配後 lastIndex 的增加,等到將整個字串匹配完成後,就跳出迴圈了。

給以上知識點畫個重點

  1. 將正規表示式儲存到變數中,只在邏輯中使用這個變數,不僅效能更高,還安全。
  2. 謹慎使用全域性匹配,RegExp.prototype.exec()RegExp.prototype.test()這兩個方法的執行結果可能每次都不同。
  3. 做到了以上兩點後,還要謹慎在迴圈中使用正則匹配。

回溯陷阱 Catastrophic Backtracking

回溯陷阱是正規表示式本身的一個坑了,會導致非常嚴重的效能問題,事故現場可以參看《一個正規表示式引發的血案,讓線上 CPU100% 異常!》

簡單介紹一下回溯陷阱的問題源頭,正則引擎分為 NFA(確定型有窮自動機)DFA(不確定型有窮自動機)DFA 是從匹配文字入手,同一個字元不會匹配兩次(可以理解為手裡捏著文字,挨個字元拿去匹配正則),時間複雜度是線性的,它的功能有限,不支援回溯。大多數程式語言選用的都是 NFA,相當於手裡拿著正規表示式,去匹配文字。

/(a(bdc|cbd|bcd)/ 中已經有三種匹配路徑,在 NFA 中,以文字 'abcd' 為例,將花費 7 步才能匹配成功:

regex101
(圖中還包括了字元邊界的匹配步驟,因此多了三步)

  1. 正則中的第一個字元 a 匹配到 'abcd' 中的第一個字母 'a',匹配成功。
  2. 此時遇到了匹配路徑的分叉口,bdc 或 cbd 或 bcd,先使用 bdc 來匹配。
  3. bdc 中的第一個字元 b 匹配到了 'abcd' 中的第二個字母 'b',匹配成功。
  4. bdc 中的第二個字元 d 與 'abcd' 中的第三個字母 'c' 不匹配,這條路徑匹配失敗,此時將發生回溯(backtrack),把 'b' 還回去。選擇第二條路徑 cbd 進行匹配。
  5. cbd 的第一個字元 'c' 就與 'b' 匹配失敗。開始第三條路徑 bcd 的匹配。
  6. bcd 的第一個字元 'b' 與文字 'b' 匹配成功。
  7. bcd 的第一個字元 'c' 與文字 'c' 匹配成功。
  8. bcd 的第一個字元 'd' 與文字 'd' 匹配成功。

至此匹配完成。

可想而知,如果正則中再多一些匹配路徑或者匹配本文再長一點,匹配步驟將多到難以控制。

比如用 /(a*)*bc/ 來匹配 'aaaaaaaaaaaabc' 都會導致效能問題,匹配文字中每增加一個 'a',都會導致執行時間翻倍。

禁止這種回溯陷阱的方法有兩種:

  1. 佔有優先量詞(Possessive Quantifiers)
  2. 原子分組(Atomic Grouping)

可惜 JavaScript 不支援這兩種語法,有興趣可以 Google 自行了解下。

在 JavaScript 中我們沒有方法可以直接禁止回溯陷阱,我們只能:

  1. 避免量詞巢狀 (a*)* => a*
  2. 減少匹配路徑

除此之外,我們也可以把正則匹配放到 Service Worker 中進行,從而避免影響頁面效能。

查資料的時候發現,回溯陷阱不僅會導致效能問題,也有安全問題,有興趣可以看看先知白帽大會上的《WAF是時候跟正規表示式說再見》分享。

參考資料

相關文章