說起來你可能不信,一個正則就能讓頁面卡死

某個陽光明媚的下午,我正悠閒的品着剛買的滇紅,測試小姐姐突然急匆匆的找到我:

“快看一下羣裏,文章編輯器出問題了!”

我手中的滇紅瞬間不香了,抓了抓所剩無幾的頭髮,開始了漫長的 Debug 環節

經過排查,發現問題的根源居然是一段正則表達式...

 

一、問題重現

// 在瀏覽器控制檯中運行下面的代碼
// 放心,不會卡死的

const reg = /^<del>(.|\s)*<\/del>$/;
const str = '<del>hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! </del><ins>hello wrold!</ins>';

start = Date.now();
const res = reg.test(str);
end = Date.now();

console.log('耗時:' + (end - start));

上面就是出問題的正則,但是字符串更加複雜(不然一執行代碼,瀏覽器就崩潰了)

這段正則本身的目的是爲了匹配 <del> 標籤的內容。由於 . 不包含換行符,所以用 (.|\s)* 來指代內容

而由於 . 和 \s 有重合的部分,再加上或運算符 | 和貪婪匹配 * ,讓正則表達式的運算量指數級增加,最終呈現出頁面崩潰的結果

 

 

二、正則引擎

爲了解釋這個問題,就得了解正則表達式的工作原理

正則有兩種工作方式:用文本去匹配正則(DFA)、正則去匹配文本(NFA)

舉個例子:

/ja(cket|vate|vascr)/.test('javascript')

如果是 DFA,會用字符串去匹配,過程是這樣的:

在匹配到第三個字符 v 的時候,會有三個備選分支,但 cket 分支不滿足規則,被排除。

所以在匹配第四個字符 a 的時候,只有兩個備選分支,直到第五個字符 s,排除掉 vate 分支,最後只剩下一個備選分支 vascr,最終完成匹配。

 

而對於 NFA,是用正則來匹配文本:

 

在匹配到 ja 之後,會先匹配 cket 分支,發現 c 不匹配,返回上一個節點。

返回節點之後會進入下一個分支,即 vate 分支,直到匹配到 t 纔會不匹配,然後返回上一節點。

由於上一節點 a 並沒有別的分支,所以繼續返回,直到返回最開始的 ja 節點,進入最後一個 vascr 分支,最後完成匹配。

 

從這兩個分析可以看出, DFA 在用文本來匹配正則的時候,會逐漸排除不滿足條件的備選項。

而 NFA 會匹配每個分支,如果分支不匹配,則回到上一個節點,進入當前節點的另一個分支繼續匹配。

也就是說 NFA 就像是在走迷宮,遇到岔路的時候,先選擇第一條路走到頭。如果走不通,則返回岔路口,進入下一條路繼續探索。這個返回岔路口的過程叫做回溯

所以 DFA 引擎的效率比 NFA 更高,但很可惜的是,JavaScript 的正則的引擎是 NFA 類型。

 

 

三、回溯

上面已經提到,NFA 在匹配某一個分支失敗時,會返回節點,嘗試另一條分支,這種行爲被稱作回溯。

如果只是上面舉的簡單例子,回溯並不會造成嚴重的性能問題,可如果是有多個備選狀態,再加上貪婪匹配,這個過程就很恐怖了。

比如這樣一個正則:

/(.*)+\d/.test('abcd')

這裏的 .* 可以匹配任意字符(\n除外)任意次數,再加上貪婪特性,第一次匹配時 .* 會直接喫掉 abcd ,然後匹配 \d 失敗,進行第一次回溯:

然後 .* 將 d 吐出來,本身只匹配 abc。但由於 + 的原因, .* 會進行第二次匹配,然後 \d 匹配失敗,再次回溯:

第三次匹配的時候, + 重新記爲 1, .* 依然爲 abc,剩下一個 d 交給 \d 匹配。由於 \d 需要匹配數字,所以匹配失敗,繼續回溯:

以此類推,正則會在經過很多次回溯之後,纔會得出匹配失敗的結論

 

 

四、優化方案

由於回溯機制的存在,我們在寫正則的時候一定要牢記:

儘可能的減少備選分支的數量。

比如上例的正則: /(.*)+\d/ ,這裏的 + 和 * 存在重複匹配

如果我們最終的期望是匹配 test1、hello123 這種以數字結尾,總長度不小於 2 的字符串

將正則表達式改爲  /.+\d/ 就能滿足我們的需求

而在文章一開始提到的線上暴雷的正則: /^<del>(.|\s)*<\/del>$/ 

其實也是因爲 . 和 \s 有重合的匹配規則,改爲 (.|\n) 即可

 

另外,如果我們能預期目標字符串的構成,將備選分支更少的規則寫在前面,這樣正則就能更早的返回結果

所以調整備選分支的順序也是一個優化方案

最後,在可以選擇的情況,使用 DFA 才能從根本上解決問題。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章