正規表示式是如何讓你的網頁卡住的

hjava發表於2020-11-30

概述

正規表示式在我們日程的工作專案中,應該是一個經常用到的技能。在做一些字元的匹配和處理的過程中,發揮了很大的作用。我們這篇文章主要是通過一個我在工作中遇到的效能問題,來探究下正規表示式是如何影響我們的程式碼效能的。在我們遇到了正規表示式有效能平靜的時候,我們應該如何的來對它進行優化?

如果對正規表示式還沒有什麼概念,或者說不了解的同學,可以先參考我之前寫過的部落格:

問題現狀

在我們日常的工作中,如果不需要去調整正規表示式的話,大部分人其實是會選擇性忽略它的。這就導致了大部分人對正規表示式其實並不是太瞭解。在正規表示式出現問題以後也不知道如何去解決。

因為我在美團是負責做大象Web/PC的相關開發,所以在日常的工作中免不了要經常和正規表示式打交道,比如識別文字訊息中的URL進行高亮,或者說識別會議室、解析特定格式展示不同的UI等。在這種情況下,我免不了會跟大量的正規表示式打交道。從長時間與正則打交道的經歷中,也有了部分的經驗總結。

下面我們通過一個工作中具體的例子,來看下正規表示式是如何讓你的網頁卡住的?

在最近的效能問題優化排查中,我們發現在遇到文字內容較多(約15000字)的文字訊息文書處理時,render函式會有一個比較大的效能損耗,每次渲染需要差不多100ms。因為訊息每次渲染都是20條一起,因此正規表示式一旦有效能問題,就會因為多次渲染的放大效應,被使用者很明顯的感知到。如果每條訊息處理都需要100ms,那麼20條訊息處理就會直接卡頓2s,這其實對於使用者來說是不可以接受的。

具體我們可以看下火焰圖(火焰圖就是Chrome的devtools中,分析profile時候的圖表,大家可以理解為一個呼叫時間圖譜,如果不瞭解,推薦看看阮一峰老師的如何讀懂火焰圖? - 阮一峰的網路日誌):
image.png

通過上述的火焰圖,我們可以看到這個render渲染函式每次執行都差不多100ms。對於JavaScript來說,100ms其實時間已經很長了。那麼這一百毫秒中具體幹了哪些事情呢?

我們簡單的梳理一下當前的程式碼,發現最有可能的原因就是正則耗時的影響。在訊息處理中,有兩個需要進行匹配的正則,一個是匹配會議室進行高亮的,一個是匹配引用訊息進行格式轉換的。這兩個正則分別如下:

const QUOTED_MSG_REG = /([^「]*?)「((?:[a-zA-Z0-9\u4E00-\u9FBF_\.\s]{0,40})\:(?:.|\n)*)」\n(—){10}\n((?:\S|\s)*)$/m;

const MEETING_ROOM_REG = /北京廳|天津廳|石家莊廳|濟南廳|哈爾濱廳|...(此處省略200+個會議室)|臺灣廳/mg;

這個兩個正規表示式用來匹配的文字如下:

// 引用格式
「張三:老司機」
——————————
帶帶我

// 會議室
張三呀,我們去 常德廳 開個會吧,叫上其他人

一開始看,大家可能覺得這兩個正則都很正常,我們在正常的工作中也會寫出這樣的正規表示式,沒有發現什麼問題。

如果告訴你這兩個正規表示式執行有效能問題,那麼大家可能還會覺得,會議室匹配的文字正則這麼長,需要匹配的會議室這麼多,肯定是這個正則有效能問題,導致了執行時間過長。

那麼具體情況到底是不是和我們直觀感受一樣呢?我們來對具體問題進行一個分析。

問題分析

為了分析我們上面說到的這兩個正規表示式效能到底怎麼樣,我從網上找了一些文字,來模擬訊息的內容。通過使用正規表示式進行匹配,在Node端執行計算耗時,得到的一個字數與時間的關係圖如下,表格的橫座標是字數,縱座標是時間(ms):
image.png

這個和大家的猜測是不是一樣?在我之前最早的猜測中,我也以為是正則長度越長,那麼效能就越差。但是,這個和我的猜測正好相反,反倒是看上去比較短的。引用正在表示式效能問題最大。

從我們分析的資料來看,在10000字之前,其實差別沒有那麼大。 但是在超過10,000個字的時候,其實耗時差異就比較明顯了。

大家可以看到引用的這個正規表示式,他的耗時其實是發生了指數型的上升。 在超過50,000字,以後其實這個正則你可以認為基本上就不能夠再使用了,而且這還是在效能比較好的MacBook情況下。 如果是在一些更老的電腦,或者說Windows的低端本上,那麼這個耗時其實還會更大。你想想你,你能夠接受你的開發的專案,卡住2秒不動嗎?

反倒是我們覺得比較複雜的這個會議室正規表示式,它在匹配的內容字數增加的情況下,效能其實沒有明顯的增加,一直都穩定在100毫秒以下。

看到這裡,有人可能會覺得是不是match方法,它比較吃效能呢?也有人可能會想,我們是不是在match之前增加一個相同正規表示式的test判斷?如果符合的話,我們再執行match,這樣是不是就能夠提高我們的效能呢?

那麼我們把match方法換成test方法來看一下,這樣能不能夠提升我們正則匹配的效能呢?下圖是我們使用會議室正規表示式來進行匹配的一個耗時圖。我們從圖中可以看到相關的執行耗時情況:
image.png

從圖中可以看到,test方法並不會比match方法節省更多的時間,相反來看他的耗時其實比match還略微有增加。不過可能就是幾個毫秒。我嘗試了一下效能問題更明顯的引用正規表示式,得到了結論也是一樣的。所以我們想到的先使用test方法來進行判斷,如果test方法命中的話再進行match。這個不但沒有優化,反倒是可能會損耗雙倍的效能。

既然相同的正規表示式使用任意一個方法執行的時候都會有比較明顯的效能問題,那麼我們就只能從正規表示式本身的優化入手了。我們來看一下,為什麼我們覺得比較複雜的正規表示式,耗時沒有什麼變化。反而我們認為比較簡單的正規表示式時間的增長卻這麼明顯呢?

原理分析

其實,正規表示式效能最大的影響來自於正規表示式的回溯。如果一個正規表示式回溯的越多,那麼它的效能損耗就越明顯。我們可以去看一下上面兩個正規表示式的情況。

其實上面兩個正規表示式都有回溯的問題。如果大家不瞭解,回溯,可以去看下我之前的那一篇 正規表示式高階進階。在這裡我們簡單介紹一下回溯回溯的原因:正規表示式在匹配的過程中需要往回走重新進行匹配,這就會導致回溯。一般產生回溯的有這麼幾種情況,一種是分支,一種是量詞。

我們可以看看上面兩個正規表示式,會議是這個正則比較簡單,他其實是很多分支的集合體;引用的這個正則就不同了,他的回溯主要是來源於量詞。尤其是[^「]*這種的存在,導致了大量的回溯情況。

所以說一個正規表示式效能好不好跟他的長短沒有必然的聯絡。而是跟他具體的寫法有關。如果這個正規表示式很多地方都有回溯的情況,那麼他的效能必然就好不了。反過來說,如果一個正規表示式雖然很長很複雜,但是它能夠儘可能的避免回溯。需要匹配的文字也儘可能的清晰,那麼這種情況下它的效能其實是很不錯的。

解決方案

遇到這個問題,我們一般會有以下兩個解決方案。

優化正規表示式本身

第一個解決方案就是儘可能的去優化這個正規表示式本身,去儘可能消除裡面一些回溯的情況。這個也是我們一般最常用的一個解決方案。具體有以下2個操作:

  1. 在明確匹配規則的情況下,使用\d{1, 30}來替換.*,儘可能的去明確我們需要匹配的型別與長度。
  2. 在需要進行不明確數量匹配的時候,儘可能的使用非貪婪匹配,而不是使用貪婪匹配。

同時,還有個規則:在不需要捕獲組的情況下,括號儘可能的使用非捕獲組(與回溯無)。

總體上來說就是:如果一個正規表示式越精確,捕獲的元素越少,那麼它的效能就會越好。反之,如果有大量的模糊匹配跟回溯的情況,那麼它的效能大概率就不怎麼好。

在一般的場景中,我們使用了這個方法,基本上我們的效能問題就能夠迎刃而解了。

但是,那麼如果我們繼續要匹配比較複雜的正則,同時這個正則又沒有辦法避免回溯的情況,我們應該怎麼去優化這個效能的?

優化正規表示式匹配順序

也就是說在這種情況下,這個正規表示式其實是沒有辦法再進行優化了,但是我們又需要在日常的專案中使用,不能直接廢棄。這就需要我們使用另外的優化方案了。

在正則沒有辦法修改的情況下,我們可以做正則匹配的分級,儘可能使用一些效能比較高的正規表示式,先進行一些過濾匹配。在命中我們需要匹配的條件以後,再使用比較複雜的正規表示式進行匹配。從而避免複雜的正規表示式頻繁的被呼叫。

我舉一個簡單的例子,還是以上面的引用正規表示式來分析。如果這個正規表示式我沒有辦法再進行進一步優化了情況下,我們可以先把他的一些特定的規則摘取出來,進行一個前置校驗。我們可以簡單的來看一下下面一個程式碼示例:

let str = 'xxxxxx'; //長文字

const LINE_REG = /\n(—){10}\n/m;
const QUOTED_MSG_REG = /([^「]*?)「((?:[a-zA-Z0-9\u4E00-\u9FBF_\.\s]{0,40})\:(?:.|\n)*)」\n(—){10}\n((?:\S|\s)*)$/m;

if(LINE_GER.test(str)) {
    let result = str.match(QUOTED_MSG_REG);
    // do something
}

不要在主執行緒中執行

如果一個正規表示式沒有辦法通過上述兩種方案進行優化(這個概率其實已經很低了,感覺和彩票中獎差不多了),那麼我們還有一個最終的解決方案,就是使用Web Workder,來進行耗時的操作計算。

這樣的話,我們至少在主執行緒執行過程中,不會有卡住影響使用者操作的問題。

不過,在這個方案中,需要考慮到大量資料通過postMessage傳遞到Web Worker中的效能損耗問題。

這個方案本質上比較簡單,我在具體專案中也沒有使用到,因此不展開講了,有興趣瞭解的同學可以自行上網查閱相關資料,或者評論私信留言討論。

從上面的程式碼中我們可以看到,我們可以選取一個沒有回溯的明確特徵條件來先進行一次快速的匹配。一般情況來說沒有回溯的正則匹配效率都是特別高,即使是在大量文字處理的情況下也不會對效能有什麼太大的影響。在進行了第一次正規表示式匹配後,如果這個文字還是符合當前的條件,那麼說明有較大概率它其實是需要我們命中的,那麼我們再執行正則匹配即可。

這樣的話,我們就能夠避免大部分的無意義的效能消耗。

服務端資料處理

如果一個資料量太過龐大(超過1M的文字)時,我推薦對資料進行分頁,不要一次性處理所有資料(這個時候正則已經不是瓶頸了,JS執行引擎才是瓶頸)。

但是,有些神奇的專案就是會有這種訴求,遇到這種情況時,我們必須(不是可以,是必須)藉助服務端來進行資料處理,前端只做簡單的展示邏輯(即使是展示這麼大量的資料,渲染也會有比較明顯的卡頓和耗時)。

如果沒有後端的支援,那麼自己用Node搭建一個簡單的中轉處理服務都行。這個時候需要關注的,就是自己的Node服務如何能夠彈性擴容了。

效果驗證

在我的專案遇到的效能問題中,只使用了前兩個方案對引用的正規表示式進行了優化。我們可以來看一下優化後的渲染耗時情況:
image.png

在通過對正規表示式進行優化後,我們的每次文字渲染時間從100ms直接降到了不到2ms。 這可是50倍的效能提升。對於15000字的文字來說,這個速度可以算是沒有任何的效能影響了。

我們還試了試極限情況下1000000字的情況,渲染也能夠控制在20ms以內,這和之前相比,進步還是很明顯的。

總結

正規表示式在我們的日常程式碼使用中其是很常見的。但是稍有不慎我們就會遇到效能問題。大部分在寫程式碼的過程中,不會去考慮這個正規表示式效能怎麼樣,都會下意識覺得反正處理的文字長度不大,寫的再差也沒有什麼影響。但是,在專案逐漸發展過程中,有可能由於產品策略調整或者資料的積累,某一個不起眼的正規表示式,就會對整個專案的效能產生決定性影響。

因此我們在具體開發的過程中一定要有效能的意識,我們寫的任意一個正規表示式都有可能會導致整個系統的效能問題。因此我們寫的每一個正規表示式都應該儘可能的準確,儘可能的減少執行次數。

再遇到正則的效能問題時,正規表示式的優化手段主要有3個:

  1. 我們需要儘可能的去讓我們的正規表示式準確化,越準確的正規表示式匹配時,他的回溯情況就越少,所以它的效能就越高。
  2. 在正規表示式已經沒有辦法再進行優化的情況下,我們可以先選取一些沒有回覆情況的特徵值進行先置條件判斷,這樣的話,我們能夠儘量多的去避免一些無意義的好事匹配,優化我們的效能。
  3. 藉助其他執行緒或者服務來進行正則處理,避免使用者卡頓。

希望能夠通過上述的具體實戰優化,能夠讓大家瞭解正規表示式在專案中對效能的影響,也歡迎大家在遇到正規表示式相關的問題時,隨時討論交流,大家一起解決問題,一起進步。

相關文章