正則之坑知多少

cover

原文地址: 又雙叒叕學習了一遍正則表達式javascript

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

首先說說 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'));
複製代碼

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

冷知識 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是時候跟正則表達式說再見》分享。

參考資料

相關文章
相關標籤/搜索