一次性搞懂JavaScript正規表示式之引擎

馬蹄疾發表於2018-11-15

本文是『horseshoe·Regex專題』系列文章之一,後續會有更多專題推出

GitHub地址:github.com/veedrin/hor…

部落格地址(文章排版真的很漂亮):veedrin.com

如果覺得對你有幫助,歡迎來GitHub點Star或者來我的部落格親口告訴我

我們說正規表示式是語言無關的,是因為驅動正規表示式的引擎是相似的。鑑於正規表示式是一種古老的語法,它的引擎也在歷史長河中衍生出了幾個大的分支。

我會關注到正規表示式引擎這樣比較底層的實現,緣起於在一次業務實踐中,追蹤到一個由正則引起的BUG。業務中使用的一個markdown解析庫Remarkable在解析一段不規則文字時引起瀏覽器崩潰,除錯之後發現是某一個正則在匹配時陷入了死迴圈,嚴格的說(後來才知道)是匹配花費了過多時間導致瀏覽器卡死。

我當時很震驚,正則匹配的效能不是很高的麼?匹配到就是匹配到,沒匹配到就是沒匹配到,怎麼會在裡面走不出來了呢?

有限自動機

什麼叫有限自動機(Finite Automate)呢?

我們把有限自動機理解為一個機器人,在這個機器人眼裡,所有的事物都是由有限節點組成的。機器人按照順序讀取有限節點,並表達成有限狀態,最終機器人輸出接受或者拒絕作為結束。

關注它的兩個特點:

  • 有限狀態集。
  • 只根據當前狀態和當前輸入來決定下一個狀態。它有點一板一眼。

怎麼理解第二個特點?我們看一個例子:

'aab'.match(/a*?b/);
// ["aab", index: 0, input: "aab", groups: undefined]複製程式碼

我們知道*?是非貪婪匹配,按照我們人類靈活的尿性,直接把匹配結果ab甩他臉上。

但有限自動機不會。第一步它用a匹配a非常完美,然後發現對於a是非貪婪模式,於是試著用b匹配下一個a,結果非常沮喪。於是它只能繼續用a匹配,匹配成功後依然沒忘非貪婪特性,繼續試著用b匹配下一個字元b,成功,收官。

其實寫出這段程式碼的開發者想要的結果應該是ab,但有限自動機從來不仰望星空,只低頭做事,一板一眼的根據當前狀態和當前輸入來決定下一個狀態。

DFA與NFA

有限自動機大體上又可以分為兩類:DFA是確定性有限自動機的縮寫,NFA是非確定性有限自動機的縮寫。

我沒辦法告訴你DFA與NFA在原理上的差別,但我們們可以探討一下它們在處理正則上的表現差異。

總的來說,DFA可以稱為文字主導的正則引擎,NFA可以稱為表示式主導的正則引擎。

怎麼講?

我們常常說用正則去匹配文字,這是NFA的思路,DFA本質上其實是用文字去匹配正則。哪個是攻,哪個是受,大家心裡應該有個B數了吧。

我們來看一個例子:

'tonight'.match(/to(nite|knite|night)/);
複製程式碼

如果是NFA引擎,表示式占主導地位。表示式中的to不在話下。然後就面臨三種選擇,它也不嫌累,每一種都去嘗試一下,第一個分支在t這裡停止了,接著第二個分支在k這裡也停止了。終於在第三個分支柳暗花明,找到了自己的歸宿。

換作是DFA引擎呢,文字占主導地位。同樣文字中的to不在話下。文字走到n時,它發現正則只有兩個分支符合要求,經過i走到g的時候,只剩一個分支符合要求了。當然,還要繼續匹配。果然沒有令它失望,第三個分支完美符合要求,下班。

大家發現什麼問題了嗎?

只有正規表示式才有分支和範圍,文字僅僅是一個字元流。這帶來什麼樣的後果?就是NFA引擎在匹配失敗的時候,如果有其他的分支或者範圍,它會返回,記住,返回,去嘗試其他的分支。而DFA引擎一旦匹配失敗,就結束了,它沒有退路。

這就是它們之間的本質區別。其他的不同都是這個特性衍生出來的。

首先,正規表示式在計算機看來只是一串符號,正則引擎首先肯定要解析它。NFA引擎只需要編譯就好了;而DFA引擎則比較繁瑣,編譯完還不算,還要遍歷出表示式中所有的可能。因為對DFA引擎來說機會只有一次,它必須得提前知道所有的可能,才能匹配出最優的結果。

所以,在編譯階段,NFA引擎比DFA引擎快。

其次,DFA引擎在匹配途中一遍過,溜得飛起。相反NFA引擎就比較苦逼了,它得不厭其煩的去嘗試每一種可能性,可能一段文字它得不停返回又匹配,重複好多次。當然運氣好的話也是可以一遍過的。

所以,在執行階段,NFA引擎比DFA引擎慢。

最後,因為NFA引擎是表示式占主導地位,所以它的表達能力更強,開發者的控制度更高,也就是說開發者更容易寫出效能好又強大的正則來,當然也更容易造成效能的浪費甚至撐爆CPU。DFA引擎下的表示式,只要可能性是一樣的,任何一種寫法都是沒有差別(可能對編譯有細微的差別)的,因為對DFA引擎來說,表示式其實是死的。而NFA引擎下的表示式,高手寫的正則和新手寫的正則,效能可能相差10倍甚至更多。

也正是因為主導權的不同,正則中的很多概念,比如非貪婪模式、反向引用、零寬斷言等只有NFA引擎才有。

所以,在表達能力上,NFA引擎秒殺DFA引擎。

當今市面上大多數正則引擎都是NFA引擎,應該就是勝在表達能力上。

測試引擎型別

現在我們知道正規表示式的驅動引擎分為兩大類:DFA引擎與NFA引擎。

但是因為NFA引擎比較靈活,很多語言在實現上有細微的差別。所以後來大家弄了一個標準,符合這個標準的正則引擎就叫做POSIX NFA引擎,其餘的就只能叫做傳統型NFA引擎咯。

我們來看看JavaScript到底是哪種引擎型別吧。

'123456'.match(/\d{3,6
}/
);
// ["123456", index: 0, input: "123456", groups: undefined]'123456'.match(/\d{3,6
}?/
);
// ["123", index: 0, input: "123456", groups: undefined]複製程式碼

《精通正規表示式》書中說POSIX NFA引擎不支援非貪婪模式,很明顯JavaScript不是POSIX NFA引擎。

TODO: 為什麼POSIX NFA引擎不支援也沒有必要支援非貪婪模式?

區分DFA引擎與傳統型NFA引擎就簡單咯,捕獲組你有麼?花式零寬斷言你有麼?

結論就是:JavaScript的正則引擎是傳統型NFA引擎。

回溯

現在我們知道,NFA引擎是用表示式去匹配文字,而表示式又有若干分支和範圍,一個分支或者範圍匹配失敗並不意味著最終匹配失敗,正則引擎會去嘗試下一個分支或者範圍。

正是因為這樣的機制,引申出了NFA引擎的核心特點——回溯。

首先我們要區分備選狀態和回溯。

什麼是備選狀態?就是說這一個分支不行,那我就換一個分支,這個範圍不行,那我就換一個範圍。正規表示式中可以商榷的部分就叫做備選狀態。

備選狀態是個好東西,它可以實現模糊匹配,是正則表達能力的一方面。

回溯可不是個好東西。想象一下,面前有兩條路,你選擇了一條,走到盡頭發現是條死路,你只好原路返回嘗試另一條路。這個原路返回的過程就叫回溯,它在正則中的含義是吐出已經匹配過的文字。

我們來看兩個例子:

'abbbc'.match(/ab{1,3
}c/
);
// ["abbbc", index: 0, input: "abbbc", groups: undefined]'abc'.match(/ab{1,3
}c/
);
// ["abc", index: 0, input: "abc", groups: undefined]複製程式碼

第一個例子,第一次a匹配a成功,接著碰到貪婪匹配,不巧正好是三個b貪婪得逞,最後用c匹配c成功。

正則 文字
/a/ a
/ab{1,3
}/
ab
/ab{1,3
}/
abb
/ab{1,3
}/
abbb
/ab{1,3
}c/
abbbc

第二個例子的區別在於文字只有一個b。所以表示式在匹配第一個b成功後繼續嘗試匹配b,然而它見到的只有黃臉婆c。不得已將c吐出來,委屈一下,畢竟貪婪匹配也只是儘量匹配更多嘛,還是要臣服於匹配成功這個目標。最後不負眾望用c匹配c成功。

正則 文字
/a/ a
/ab{1,3
}/
ab
/ab{1,3
}/
abc
/ab{1,3
}/
ab
/ab{1,3
}c/
abc

請問,第二個例子發生回溯了嗎?

並沒有。

誒,你這樣就不講道理了。不是把c吐出來了嘛,怎麼就不叫回溯了?

回溯是吐出已經匹配過的文字。匹配過程中造成的匹配失敗不算回溯。

為了讓大家更好的理解,我舉一個例子:

你和一個女孩子(或者男孩子)談戀愛,接觸了半個月後發現實在不合適,於是提出分手。這不叫回溯,僅僅是不合適而已。

你和一個女孩子(或者男孩子)談戀愛,這段關係維持了兩年,並且已經同居。但由於某些不可描述的原因,疲憊掙扎之後,兩人最終還是和平分手。這才叫回溯。

雖然都是分手,但你們應該能理解它們的區別吧。

網路上有很多文章都認為上面第二個例子發生了回溯。至少根據我查閱的資料,第二個例子發生的情況不能被稱為回溯。當然也有可能我是錯的,歡迎討論。

我們再來看一個真正的回溯例子:

'ababc'.match(/ab{1,3
}c/
);
// ["abc", index: 2, input: "ababc", groups: undefined]複製程式碼

匹配文字到ab為止,都沒什麼問題。然而蒼天饒過誰,後面既匹配不到b,也匹配不到c。引擎只好將文字ab吐出來,從下一個位置開始匹配。因為上一次是從第一個字元a開始匹配,所以下一個位置當然就是從第二個字元b開始咯。

正則 文字
/a/ a
/ab{1,3
}/
ab
/ab{1,3
}/
aba
/ab{1,3
}/
ab
/ab{1,3
}c/
aba
/a/ ab
/a/ aba
/ab{1,3
}/
abab
/ab{1,3
}/
ababc
/ab{1,3
}/
abab
/ab{1,3
}c/
ababc

一開始引擎是以為會和最早的ab走完餘生的,然而命運弄人,從此天涯。

這他媽才叫回溯!

還有一個細節。上面例子中的回溯並沒有往回吐呀,吐出來之後不應該往回走嘛,怎麼往後走了?

我們再來看一個例子:

'"abc"def'.match(/".*"/);
// [""abc"", index: 0, input: ""abc"def", groups: undefined]複製程式碼

因為.*是貪婪匹配,所以它把後面的字元都吞進去了。直到發現目標完不成,不得已往回吐,吐到第二個"為止,終於匹配成功。這就好比結了婚還在外面養小三,幾經折騰才發現家庭才是最重要的,自己的行為背離了初衷,於是幡然悔悟。

正則 文字
/”/
/”.*/ “a
/”.*/ “ab
/”.*/ “abc
/”.*/ “abc”
/”.*/ “abc”d
/”.*/ “abc”de
/”.*/ “abc”def
/”.*”/ “abc”def
/”.*”/ “abc”de
/”.*”/ “abc”d
/”.*”/ “abc”

我想說的是,不要被回溯字迷惑了。它的本質是把已經吞進去的字元吐出來。至於吐出來之後是往回走還是往後走,是要根據情況而定的。

回溯引發的瀏覽器卡死慘案

現在我邀請讀者回到文章開始提起的正則BUG。

`<
img src=# onerror=’alert(document.cookie)/>
<
!--‘<
img src=https://avatar.veedrin.com />
`
.match(/<
!--([^-]+|[-][^-]+)*-->
/g
);
複製程式碼

這是測試妹子用於測試XSS攻擊的一段程式碼,測試的腦洞你不要去猜。正則是Remarkable用於匹配註釋的,雖然我沒搞清楚到底為什麼這樣寫。src我篡改了一下,不影響效果。

不怕事大的可以去Chrome開發者工具跑上一跑。

不賣關子。它會導致瀏覽器卡死,是因為分支和範圍太多了。[^-]+是一個範圍,[-][^-]+是一個範圍,[^-]+|[-][^-]+是一個分支,([^-]+|[-][^-]+)*又是一個範圍。另外注意,巢狀的分支和範圍生成的備選狀態是呈指數級增長的。

我們知道這段語句肯定會匹配失敗,因為文字中壓根就沒有-->
。那瀏覽器為什麼會卡死呢?因為正則引擎的回溯實在過多,導致瀏覽器的CPU程式飆到98%以上。這和你在Chrome開發者工具跑一段巨大運算量的for迴圈是一個道理。

但是呢,正則永遠不會走入死迴圈。正則引擎叫有限狀態機,就是因為它的備選狀態是有限的。既然是有限的,那就一定可以遍歷完。10的2次方叫有限,10的200000000次方也叫有限。只不過計算機的硬體水平有限,容不得你進行這麼大的運算量。我以前也以為是正則進入了死迴圈,其實這種說法是不對的,應該叫瀏覽器卡死或者撐爆CPU。

那麼,怎麼解決?

最粗暴也最貴的方式當然是換一臺計算機咯。拉一臺超級計算機過來肯定是可以打服它的吧。

第二就是減少分支和範圍,尤其是巢狀的分支和範圍。因為分支和範圍越多,備選狀態就越多,早早的就匹配成功還好,如果匹配能成功的備選狀態在很後頭或者壓根就無法匹配成功,那你家的CPU就得嗷嗷叫咯。

我們來看一下:

`<
img src=# onerror=’alert(document.cookie)/>
<
!--‘<
img src=https://avatar.veedrin.com />
-->
`
.match(/<
!--([^-]+|[-][^-]+)*-->
/g
);
// ["<
!--‘↵<
img src=https://avatar.veedrin.com />
-->
"]
複製程式碼

你看,備選狀態再多,我已經找到了我的白馬王子,你們都歇著去吧。

這個正則我不知道它這樣寫的用意何在,所以也不知道怎麼優化。明白備選狀態是回溯的罪魁禍首就好了。

第三就是縮減文字。會發生回溯的情況,其實文字也是一個變數。你想想,總要往回跑,如果路途能短一點是不是也不那麼累呢?

'<
!--<
img src=https://jd.com>
'
.match(/<
!--([^-]+|[-][^-]+)*-->
/g
);
// null複製程式碼

試的時候悠著點,不同的瀏覽器可能承受能力不一樣,你可以一個個字元往上加,看看極限在哪裡。

當然,縮減文字是最不可行的。正則正則,就是不知道文字是什麼才用正則呀。

優化正規表示式

現在我們知道了控制回溯是控制正規表示式效能的關鍵。

控制回溯又可以拆分成兩部分:第一是控制備選狀態的數量,第二是控制備選狀態的順序。

備選狀態的數量當然是核心,然而如果備選狀態雖然多,卻早早的匹配成功了,早匹配早下班,也就沒那麼多糟心事了。

至於面對具體的正規表示式應該如何優化,那就是經驗的問題了。思考和實踐的越多,就越遊刃有餘。無他,唯手熟爾。

工具

[ regex101 ]是一個很多人推薦過的工具,可以拆分解釋正則的含義,還可以檢視匹配過程,幫助理解正則引擎。如果只能要一個正則工具,那就是它了。

[ regexper ]是一個能讓正則的備選狀態視覺化的工具,也有助於理解複雜的正則語法。

本文是『horseshoe·Regex專題』系列文章之一,後續會有更多專題推出

GitHub地址:github.com/veedrin/hor…

部落格地址(文章排版真的很漂亮):veedrin.com

如果覺得對你有幫助,歡迎來GitHub點Star或者來我的部落格親口告訴我

Regex專題一覽

? 語法

? 方法

? 引擎

來源:https://juejin.im/post/5becc2aef265da6110369c93

相關文章