正則表達(dá)式是如何讓你的網(wǎng)頁卡住的
作者:hjava
原文鏈接:https://segmentfault.com/a/1190000038320217
概述
正則表達(dá)式在我們?nèi)粘痰墓ぷ黜?xiàng)目中,應(yīng)該是一個(gè)經(jīng)常用到的技能。在做一些字符的匹配和處理的過程中,發(fā)揮了很大的作用。我們這篇文章主要是通過一個(gè)我在工作中遇到的性能問題,來探究下正則表達(dá)式是如何影響我們的代碼性能的。在我們遇到了正則表達(dá)式有性能平靜的時(shí)候,我們應(yīng)該如何的來對它進(jìn)行優(yōu)化?
問題現(xiàn)狀
在我們?nèi)粘5墓ぷ髦校绻恍枰フ{(diào)整正則表達(dá)式的話,大部分人其實(shí)是會選擇性忽略它的。這就導(dǎo)致了大部分人對正則表達(dá)式其實(shí)并不是太了解。在正則表達(dá)式出現(xiàn)問題以后也不知道如何去解決。
因?yàn)槲以诿缊F(tuán)是負(fù)責(zé)做大象Web/PC的相關(guān)開發(fā),所以在日常的工作中免不了要經(jīng)常和正則表達(dá)式打交道,比如識別文本消息中的URL進(jìn)行高亮,或者說識別會議室、解析特定格式展示不同的UI等。在這種情況下,我免不了會跟大量的正則表達(dá)式打交道。從長時(shí)間與正則打交道的經(jīng)歷中,也有了部分的經(jīng)驗(yàn)總結(jié)。
下面我們通過一個(gè)工作中具體的例子,來看下正則表達(dá)式是如何讓你的網(wǎng)頁卡住的?
在最近的性能問題優(yōu)化排查中,我們發(fā)現(xiàn)在遇到文字內(nèi)容較多(約15000字)的文本消息文字處理時(shí),render函數(shù)會有一個(gè)比較大的性能損耗,每次渲染需要差不多100ms。因?yàn)橄⒚看武秩径际?0條一起,因此正則表達(dá)式一旦有性能問題,就會因?yàn)槎啻武秩镜姆糯笮?yīng),被用戶很明顯的感知到。如果每條消息處理都需要100ms,那么20條消息處理就會直接卡頓2s,這其實(shí)對于用戶來說是不可以接受的。
具體我們可以看下火焰圖(火焰圖就是Chrome的devtools中,分析profile時(shí)候的圖表,大家可以理解為一個(gè)調(diào)用時(shí)間圖譜,如果不了解,推薦看看阮一峰老師的如何讀懂火焰圖?- 阮一峰的網(wǎng)絡(luò)日志):

通過上述的火焰圖,我們可以看到這個(gè)render渲染函數(shù)每次執(zhí)行都差不多100ms。對于JavaScript來說,100ms其實(shí)時(shí)間已經(jīng)很長了。那么這一百毫秒中具體干了哪些事情呢?
我們簡單的梳理一下當(dāng)前的代碼,發(fā)現(xiàn)最有可能的原因就是正則耗時(shí)的影響。在消息處理中,有兩個(gè)需要進(jìn)行匹配的正則,一個(gè)是匹配會議室進(jìn)行高亮的,一個(gè)是匹配引用消息進(jìn)行格式轉(zhuǎn)換的。這兩個(gè)正則分別如下:
const?QUOTED_MSG_REG?=?/([^「]*?)「((?:[a-zA-Z0-9\u4E00-\u9FBF_\.\s]{0,40})\:(?:.|\n)*)」\n(—){10}\n((?:\S|\s)*)$/m;
const?MEETING_ROOM_REG?=?/北京廳|天津廳|石家莊廳|濟(jì)南廳|哈爾濱廳|...(此處省略200+個(gè)會議室)|臺灣廳/mg;
這個(gè)兩個(gè)正則表達(dá)式用來匹配的文本如下:
//?引用格式
「張三:老司機(jī)」
——————————
帶帶我
//?會議室
張三呀,我們?nèi)?常德廳?開個(gè)會吧,叫上其他人
一開始看,大家可能覺得這兩個(gè)正則都很正常,我們在正常的工作中也會寫出這樣的正則表達(dá)式,沒有發(fā)現(xiàn)什么問題。
如果告訴你這兩個(gè)正則表達(dá)式執(zhí)行有性能問題,那么大家可能還會覺得,會議室匹配的文本正則這么長,需要匹配的會議室這么多,肯定是這個(gè)正則有性能問題,導(dǎo)致了執(zhí)行時(shí)間過長。
那么具體情況到底是不是和我們直觀感受一樣呢?我們來對具體問題進(jìn)行一個(gè)分析。
問題分析
為了分析我們上面說到的這兩個(gè)正則表達(dá)式性能到底怎么樣,我從網(wǎng)上找了一些文字,來模擬消息的內(nèi)容。通過使用正則表達(dá)式進(jìn)行匹配,在Node端執(zhí)行計(jì)算耗時(shí),得到的一個(gè)字?jǐn)?shù)與時(shí)間的關(guān)系圖如下,表格的橫坐標(biāo)是字?jǐn)?shù),縱坐標(biāo)是時(shí)間(ms):

這個(gè)和大家的猜測是不是一樣?在我之前最早的猜測中,我也以為是正則長度越長,那么性能就越差。但是,這個(gè)和我的猜測正好相反,反倒是看上去比較短的。引用正在表達(dá)式性能問題最大。
從我們分析的數(shù)據(jù)來看,在10000字之前,其實(shí)差別沒有那么大。但是在超過10,000個(gè)字的時(shí)候,其實(shí)耗時(shí)差異就比較明顯了。
大家可以看到引用的這個(gè)正則表達(dá)式,他的耗時(shí)其實(shí)是發(fā)生了指數(shù)型的上升。在超過50,000字,以后其實(shí)這個(gè)正則你可以認(rèn)為基本上就不能夠再使用了,而且這還是在性能比較好的MacBook情況下。如果是在一些更老的電腦,或者說Windows的低端本上,那么這個(gè)耗時(shí)其實(shí)還會更大。你想想你,你能夠接受你的開發(fā)的項(xiàng)目,卡住2秒不動(dòng)嗎?
反倒是我們覺得比較復(fù)雜的這個(gè)會議室正則表達(dá)式,它在匹配的內(nèi)容字?jǐn)?shù)增加的情況下,性能其實(shí)沒有明顯的增加,一直都穩(wěn)定在100毫秒以下。
看到這里,有人可能會覺得是不是match方法,它比較吃性能呢?也有人可能會想,我們是不是在match之前增加一個(gè)相同正則表達(dá)式的test判斷?如果符合的話,我們再執(zhí)行match,這樣是不是就能夠提高我們的性能呢?
那么我們把match方法換成test方法來看一下,這樣能不能夠提升我們正則匹配的性能呢?下圖是我們使用會議室正則表達(dá)式來進(jìn)行匹配的一個(gè)耗時(shí)圖。我們從圖中可以看到相關(guān)的執(zhí)行耗時(shí)情況:

從圖中可以看到,test方法并不會比match方法節(jié)省更多的時(shí)間,相反來看他的耗時(shí)其實(shí)比match還略微有增加。不過可能就是幾個(gè)毫秒。我嘗試了一下性能問題更明顯的引用正則表達(dá)式,得到了結(jié)論也是一樣的。所以我們想到的先使用test方法來進(jìn)行判斷,如果test方法命中的話再進(jìn)行match。這個(gè)不但沒有優(yōu)化,反倒是可能會損耗雙倍的性能。
既然相同的正則表達(dá)式使用任意一個(gè)方法執(zhí)行的時(shí)候都會有比較明顯的性能問題,那么我們就只能從正則表達(dá)式本身的優(yōu)化入手了。我們來看一下,為什么我們覺得比較復(fù)雜的正則表達(dá)式,耗時(shí)沒有什么變化。反而我們認(rèn)為比較簡單的正則表達(dá)式時(shí)間的增長卻這么明顯呢?
原理分析
其實(shí),正則表達(dá)式性能最大的影響來自于正則表達(dá)式的回溯。如果一個(gè)正則表達(dá)式回溯的越多,那么它的性能損耗就越明顯。我們可以去看一下上面兩個(gè)正則表達(dá)式的情況。
其實(shí)上面兩個(gè)正則表達(dá)式都有回溯的問題。如果大家不了解,回溯,可以去看下我之前的那一篇 正則表達(dá)式高級進(jìn)階。在這里我們簡單介紹一下回溯回溯的原因:正則表達(dá)式在匹配的過程中需要往回走重新進(jìn)行匹配,這就會導(dǎo)致回溯。一般產(chǎn)生回溯的有這么幾種情況,一種是分支,一種是量詞。
我們可以看看上面兩個(gè)正則表達(dá)式,會議是這個(gè)正則比較簡單,他其實(shí)是很多分支的集合體;引用的這個(gè)正則就不同了,他的回溯主要是來源于量詞。尤其是[^「]*這種的存在,導(dǎo)致了大量的回溯情況。
所以說一個(gè)正則表達(dá)式性能好不好跟他的長短沒有必然的聯(lián)系。而是跟他具體的寫法有關(guān)。如果這個(gè)正則表達(dá)式很多地方都有回溯的情況,那么他的性能必然就好不了。反過來說,如果一個(gè)正則表達(dá)式雖然很長很復(fù)雜,但是它能夠盡可能的避免回溯。需要匹配的文本也盡可能的清晰,那么這種情況下它的性能其實(shí)是很不錯(cuò)的。
解決方案
遇到這個(gè)問題,我們一般會有以下兩個(gè)解決方案。
優(yōu)化正則表達(dá)式本身
第一個(gè)解決方案就是盡可能的去優(yōu)化這個(gè)正則表達(dá)式本身,去盡可能消除里面一些回溯的情況。這個(gè)也是我們一般最常用的一個(gè)解決方案。具體有以下2個(gè)操作:
在明確匹配規(guī)則的情況下,使用 \d{1, 30}來替換.*,盡可能的去明確我們需要匹配的類型與長度。在需要進(jìn)行不明確數(shù)量匹配的時(shí)候,盡可能的使用非貪婪匹配,而不是使用貪婪匹配。同時(shí),還有個(gè)規(guī)則:在不需要捕獲組的情況下,括號盡可能的使用非捕獲組(與回溯無)。
總體上來說就是:如果一個(gè)正則表達(dá)式越精確,捕獲的元素越少,那么它的性能就會越好。反之,如果有大量的模糊匹配跟回溯的情況,那么它的性能大概率就不怎么好。
在一般的場景中,我們使用了這個(gè)方法,基本上我們的性能問題就能夠迎刃而解了。
但是,那么如果我們繼續(xù)要匹配比較復(fù)雜的正則,同時(shí)這個(gè)正則又沒有辦法避免回溯的情況,我們應(yīng)該怎么去優(yōu)化這個(gè)性能的?
優(yōu)化正則表達(dá)式匹配順序
也就是說在這種情況下,這個(gè)正則表達(dá)式其實(shí)是沒有辦法再進(jìn)行優(yōu)化了,但是我們又需要在日常的項(xiàng)目中使用,不能直接廢棄。這就需要我們使用另外的優(yōu)化方案了。
在正則沒有辦法修改的情況下,我們可以做正則匹配的分級,盡可能使用一些性能比較高的正則表達(dá)式,先進(jìn)行一些過濾匹配。在命中我們需要匹配的條件以后,再使用比較復(fù)雜的正則表達(dá)式進(jìn)行匹配。從而避免復(fù)雜的正則表達(dá)式頻繁的被調(diào)用。
我舉一個(gè)簡單的例子,還是以上面的引用正則表達(dá)式來分析。如果這個(gè)正則表達(dá)式我沒有辦法再進(jìn)行進(jìn)一步優(yōu)化了情況下,我們可以先把他的一些特定的規(guī)則摘取出來,進(jìn)行一個(gè)前置校驗(yàn)。我們可以簡單的來看一下下面一個(gè)代碼示例:
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
}
不要在主線程中執(zhí)行
如果一個(gè)正則表達(dá)式?jīng)]有辦法通過上述兩種方案進(jìn)行優(yōu)化(這個(gè)概率其實(shí)已經(jīng)很低了,感覺和彩票中獎(jiǎng)差不多了),那么我們還有一個(gè)最終的解決方案,就是使用Web Workder,來進(jìn)行耗時(shí)的操作計(jì)算。
這樣的話,我們至少在主線程執(zhí)行過程中,不會有卡住影響用戶操作的問題。
不過,在這個(gè)方案中,需要考慮到大量數(shù)據(jù)通過postMessage傳遞到Web Worker中的性能損耗問題。
這個(gè)方案本質(zhì)上比較簡單,我在具體項(xiàng)目中也沒有使用到,因此不展開講了,有興趣了解的同學(xué)可以自行上網(wǎng)查閱相關(guān)資料,或者評論私信留言討論。
從上面的代碼中我們可以看到,我們可以選取一個(gè)沒有回溯的明確特征條件來先進(jìn)行一次快速的匹配。一般情況來說沒有回溯的正則匹配效率都是特別高,即使是在大量文本處理的情況下也不會對性能有什么太大的影響。在進(jìn)行了第一次正則表達(dá)式匹配后,如果這個(gè)文本還是符合當(dāng)前的條件,那么說明有較大概率它其實(shí)是需要我們命中的,那么我們再執(zhí)行正則匹配即可。
這樣的話,我們就能夠避免大部分的無意義的性能消耗。
服務(wù)端數(shù)據(jù)處理
如果一個(gè)數(shù)據(jù)量太過龐大(超過1M的文本)時(shí),我推薦對數(shù)據(jù)進(jìn)行分頁,不要一次性處理所有數(shù)據(jù)(這個(gè)時(shí)候正則已經(jīng)不是瓶頸了,JS執(zhí)行引擎才是瓶頸)。
但是,有些神奇的項(xiàng)目就是會有這種訴求,遇到這種情況時(shí),我們必須(不是可以,是必須)借助服務(wù)端來進(jìn)行數(shù)據(jù)處理,前端只做簡單的展示邏輯(即使是展示這么大量的數(shù)據(jù),渲染也會有比較明顯的卡頓和耗時(shí))。
如果沒有后端的支持,那么自己用Node搭建一個(gè)簡單的中轉(zhuǎn)處理服務(wù)都行。這個(gè)時(shí)候需要關(guān)注的,就是自己的Node服務(wù)如何能夠彈性擴(kuò)容了。
效果驗(yàn)證
在我的項(xiàng)目遇到的性能問題中,只使用了前兩個(gè)方案對引用的正則表達(dá)式進(jìn)行了優(yōu)化。我們可以來看一下優(yōu)化后的渲染耗時(shí)情況:

在通過對正則表達(dá)式進(jìn)行優(yōu)化后,我們的每次文本渲染時(shí)間從100ms直接降到了不到2ms。這可是50倍的性能提升。對于15000字的文本來說,這個(gè)速度可以算是沒有任何的性能影響了。
我們還試了試極限情況下1000000字的情況,渲染也能夠控制在20ms以內(nèi),這和之前相比,進(jìn)步還是很明顯的。
總結(jié)
正則表達(dá)式在我們的日常代碼使用中其是很常見的。但是稍有不慎我們就會遇到性能問題。大部分在寫代碼的過程中,不會去考慮這個(gè)正則表達(dá)式性能怎么樣,都會下意識覺得反正處理的文本長度不大,寫的再差也沒有什么影響。但是,在項(xiàng)目逐漸發(fā)展過程中,有可能由于產(chǎn)品策略調(diào)整或者數(shù)據(jù)的積累,某一個(gè)不起眼的正則表達(dá)式,就會對整個(gè)項(xiàng)目的性能產(chǎn)生決定性影響。
因此我們在具體開發(fā)的過程中一定要有性能的意識,我們寫的任意一個(gè)正則表達(dá)式都有可能會導(dǎo)致整個(gè)系統(tǒng)的性能問題。因此我們寫的每一個(gè)正則表達(dá)式都應(yīng)該盡可能的準(zhǔn)確,盡可能的減少執(zhí)行次數(shù)。
再遇到正則的性能問題時(shí),正則表達(dá)式的優(yōu)化手段主要有3個(gè):
我們需要盡可能的去讓我們的正則表達(dá)式準(zhǔn)確化,越準(zhǔn)確的正則表達(dá)式匹配時(shí),他的回溯情況就越少,所以它的性能就越高。 在正則表達(dá)式已經(jīng)沒有辦法再進(jìn)行優(yōu)化的情況下,我們可以先選取一些沒有回復(fù)情況的特征值進(jìn)行先置條件判斷,這樣的話,我們能夠盡量多的去避免一些無意義的好事匹配,優(yōu)化我們的性能。 借助其他線程或者服務(wù)來進(jìn)行正則處理,避免用戶卡頓。希望能夠通過上述的具體實(shí)戰(zhàn)優(yōu)化,能夠讓大家了解正則表達(dá)式在項(xiàng)目中對性能的影響,也歡迎大家在遇到正則表達(dá)式相關(guān)的問題時(shí),隨時(shí)討論交流,大家一起解決問題,一起進(jìn)步。
