某個陽光明媚的下午,我正悠閑的品着剛買的滇紅,測試小姐姐突然急匆匆的找到我:
“快看一下群里,文章編輯器出問題了!”
我手中的滇紅瞬間不香了,抓了抓所剩無幾的頭發,開始了漫長的 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 才能從根本上解決問題。