前端正則二三事

程式碼君的自白發表於2018-01-22

前言

正規表示式是軟體領域為數不多的偉大創作。與之相提並論是分組交換網路、Web、Lisp、雜湊演算法、UNIX、編譯技術、關係模型、物件導向等。正則自身簡單、優美、功能強大、妙用無窮。

學習正規表示式,語法並不難,稍微看些例子,多可照葫蘆畫瓢。但三兩篇快餐文章,鮮能理解深刻。再遇又需一番查詢,竹籃打水一場空。不止正則,其他技術點同樣,需要系統的學習。多讀經典書籍,站在巨人肩膀前行。

本文前半部分複述正則基礎知識,覆蓋常見功能結構;次節探討正則匹配原理、解決思路、常用優化。為避免泛泛而談、自以為是,文中理論示例,多引用《精通正規表示式》 等相關書籍。能力有限,邊學邊悟,不求譁眾取寵,但求實實在在。

第一章 正則基礎

正規表示式嚴謹來講,是一種描述字串結構模式的形式化表達方法。起始於數學領域,流行於 Perl 正則引擎。JavaScript 從 ES 3 引入正規表示式,ES 6 擴充套件對正規表示式支援。

字元組

顧名思義,字元組是一組字元,表示在同一位置可能出現的多種字元。語法是在方括號 [ ] 之間列出可能出現的字元。字元組支援 - 範圍表示法、^ 排除型字元組。

範圍表示法中,範圍一般由字元對應碼值確定,碼值小的字元在前,碼值大的字元在後(參考ASCII、Unicode碼錶)。排除型字元組表示,匹配除[ ]中之外的任意字元,注意仍然需要匹配一個字元。示例如下。

字元組 含義
[ab] 匹配 a 或 b
[0-9] 匹配 0 或 1 或 2 ... 或 9
[^ab] 匹配 除 a、b 任意字元

對於常見 [0-9]、[a-z] 等字元組,正規表示式提供簡記形式。

字元組 含義
\d 表示 [0-9],數字字元
\D 表示 [^0-9],非數字字元
\w 表示 [_0-9a-zA-Z],單詞字元,注意下劃線
\W 表示 [^_0-9a-zA-Z],非單詞字元
\s 表示 [ \t\v\n\r\f],空白符
\S 表示 [^ \t\v\n\r\f],非空白符
. 表示 [^\n\r\u2028\u2029]。萬用字元,匹配除換行符、回車符、行分隔符、段分隔符外任意字元

注意上述 \d、\w、\s 匹配規則,是針對 ASCII 編碼而言。對於 Unicode 字元匹配,參照 ES6擴充套件 段落。

量詞

量詞也稱重複,可以匹配固定或非固定長度字元。其常見形式是 {m,n}注意逗號之後絕不能有空格,表示連續出現最少 m 次,最多 n 次。

量詞又可分為,匹配優先量詞(貪婪量詞)、忽略優先量詞(惰性量詞)。對於不確定是否要匹配時,匹配優先量詞會嘗試匹配,忽略量詞則選擇不匹配。

示例如下,貪婪正則匹配 abc, 惰性正則匹配 a 字元。

const greedyRe = /\w+/
const lazyRe = /\w+?/

greedyRe.exec('abc')
lazyRe.exec('abc')
複製程式碼

常見量詞形式:

匹配優先量詞 忽略優先量詞 含義
{m,n} {m,n}? 表示至少出現 m 次,至多 n 次
{m,} {m,}? 表示至少出現 m 次
{m} {m}? 表示必須出現 m 次,等價 {m,m}
? ?? 等價 {0,1}
+ +? 等價 {1,}
* *? 等價 {0,}

這裡需要注意,. 能匹配幾乎所有字元,而 * 表示可以為任意數目,.* 通常用來表示一組任何字元。如果正則中使用 .*,而又不真正瞭解其中原理,就有可能事與願違。關於 .* 問題,則匹配原理章節後有詳細討論。

括號

括號在正則中主要有三種用途:分組,將相關的元素歸攏,構成單個元素;多選結構,(...|...),規定可能出現的多個子表示式;引用分組,儲存子表示式匹配文字,供之後引用。

分組與多選顯而易見,對於引用分組會存在兩種情形:在 JS API 裡引用,在正規表示式裡引用。前者可通過 $數字 提取相應順序分組,後者通過 \數字 形式引用,即反向引用。注意引用不存在的分組時,正則不會報錯,只是匹配反向引用字元本身。例如 \2 就是匹配 "\2""\2" 表示對 "2" 進行轉義。

示例,將 yyyy-mm-dd 時間,替換 mm/dd/yyyy 格式。

const re = /(\d{4})-(\d{2})-(\d{2})/
const str = '2018-01-12'
const result = str.replace(re, '$2/$3/$1')
console.log(result)
// => '01/12/2018'
複製程式碼

示例,利用反向引用匹配成對標籤,如<h1>title</h1>。(注:未考慮標籤屬性和標籤巢狀情形)

const re = /<([a-zA-Z0-9]+)>.*?<\/\1>/
const str = '<h1>title</h1>'
const result = re.test(str)
console.log(result)
// => true
複製程式碼

事實上,正規表示式只要出現括號,在匹配時就會把括號內的子表示式儲存起來提供引用。如果不需要引用,儲存資料無疑會影響效能。正規表示式提供 非捕獲分組(non-capturing group)。類似於普通捕獲分組,只是在開括號後緊跟問號和冒號 (?:...)

錨點與斷言

正規表示式中有些結構並不真正匹配文字,只負責判斷在某個位置左/右側的文字是否符合要求,被稱為錨點。常見錨點有三類:行起始/結束位置、單詞邊界、環視。在 ES5 中共有 6 個錨點。

錨點 含義
^ 匹配開頭,多行匹配中匹配行開頭
$ 匹配結尾,多行匹配中匹配行結尾
\b 單詞邊界,\w 與 \W 之間位置
\B 非單詞邊界
(?=p) 該位置後面字元要匹配 p
(?!p) 該位置後面字元不匹配 p

需要注意,\b 也包括 \w^ 之間的位置,以及 \w$ 之間的位置。如圖所示。

前端正則二三事

(?=...)(?!...) 分別表示肯定順序環視(positive lookahead)和否定順序環視(negative lookahead),也有翻譯為正向先行斷言和負向先行斷言。比如 (?=p) 可以理解成,接下來的字元與 p 匹配,但不包括 p 匹配的字元。

看個典型示例,數字千位新增分隔符。比如把 "12345678" 變成 "12,345,678"。簡單分析可得出,插入位置必須滿足: “左邊有數字,右邊數字的個數正好是3的倍數”。前者用否定順序環視避免開頭位置,後者可用肯定順序環視判斷。

const re = /(?!^)(?=(\d{3})+$)/g
const str = '123456789'
const result = str.replace(re, ',')
console.log(result)
// => '123,456,789'
複製程式碼

修飾符

修飾符是指匹配時使用的模式規則。ES5 中存在三種匹配模式:忽略大小寫模式、多行模式、全域性匹配模式,對應修飾符如下。

修飾符 含義
i 不區分大小寫匹配
m 允許匹配多行
g 執行全域性匹配

更多修飾符細節,多行匹配模式,^ 匹配一行的開頭和字串的開頭,$ 匹配行的結束和字串的結束。全域性模式,需要找到所有的匹配項,而不是找到第一個後就停止。

ES6擴充套件

ES6 中正規表示式新增 u 修飾符,含義為 “Unicode 模式”,用來正確處理大於 \uFFFF 的 Unicode 字元。也就是可以處理 4 個位元組的 UTF-16 編碼。

/^\uD83D/u.test('\uD83D\uDC2A')
// => false
/^\uD83D/.test('\uD83D\uDC2A')
// => true
複製程式碼

除了 u 修飾符,ES6 還新增 y 修飾符,叫作 “粘連”(sticky)修飾符。y 也是全域性匹配,但會確保匹配必須從剩餘第一個位置開始。

const s = 'aaa_aa_a'
const r1 = /a+/g
const r2 = /a+/y

r1.exec(s)  // ['aaa']
r2.exec(s)  // ['aaa']

r1.exec(s)  // ['aa']
r2.exec(s)  // null
複製程式碼

上述在第二次執行,g 修飾符沒有位置要求,會返回匹配結果。而 y 修飾符要求匹配必須從頭部開始,所以返回 null。將正規表示式修改為 /a+_/yy 修飾符會返回結果。

ES 規範中也有對 後行斷言 和 具名分組的提案。詳細可參考《ES6 標準入門》第5章。

經典示例

驗證密碼問題

密碼格式要求,長度 6-12 位,由數字、小寫字母和大寫字母組成,但必須至少包括 2 種字元。如果寫成多個正則來判斷,比較容易,單一正則較為麻煩。為節約篇幅,筆者簡短描述解法。

首先判斷長度 6-12 位的三種字元,可用 [0-9a-zA-Z]{6,12} 判斷。至少包括 2 種字元,可以具體分為大小寫字母、數字與小寫字母、數字與大寫字母三種情形。要求必須包含某類字元,可以使用環視 (?=...) 來描述。比如同時包含數字和小寫字母,可用 (?=.*[0-9])(?=.*[a-z]) 判斷。相應判斷多種情況,得到正則如下。

/((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[A-Z]))^[0-9A-Za-z]{6,12}$/
複製程式碼

另一種解法,至少包括兩種字元,也就是不能全部都是數字,不能全部都是小寫字母,也不能全部都是大寫字母。要求 “不能全部都是數字” 可用順序否定環視,(?!^[0-9]{6-12}$)。完整正則如下。

/(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/
複製程式碼

去除文字首尾空白字元

去除文字首尾空白字元,最簡單也是效率最高的方式是使用兩條正則替換,/^\s+//\s+$/。當然也可通過一條正規表示式解決問題,不過效率會有不同。下文列出三兩條作為示例。

const str = '  hello world  '

// 解法 0:兩次替換方式
str.replace(/^\s+/, '').replace(/\s+$/, '')

// 解法 1:使用全域性匹配
str.replace(/^\s+|\s$/g, '')

// 解法 2:使用惰性量詞
str.replace(/\s*(.*?)\s*$/, '$1')

// 解法 3:使用貪婪量詞
str.replace(/^\s*((?:.*\S)?)\s*$/, '$1')

複製程式碼

日期字元匹配

yyyy-mm-dd 為例,如 “2017-01-17”。時間匹配需要注意的是資料取值範圍:月份只能在 0-12 之間,正則為 (0?[1-9]|1[012]);日期一般在 0-31 之間,正則為 (0?[1-9]|[12][0-9]|3[01]);年份多無限制,\d{4} 即可。完整正則如下:。

/\d{4}-(0?[1-9]|1[012])-(0?[1-9]|[12][0-9]|3[01])/
複製程式碼

匹配路徑

檔案路徑區分系統,UNIX 路徑 和 Windows 路徑。UNIX 路徑示例:“/usr/local”,“/usr/bin/node” 。

這裡需要注意的地方是,檔案或目錄名不能包含 “\ / : * % < > | " ?” 、換行符空字元,所以匹配檔名的表示式是 [^\\/:*%<>|"?\r\n]。路徑內部所有目錄名之後必須有 “/”,所以可以用 [^\\/:*%<>|"?\r\n]+/ 匹配。末尾如果是目錄,可能以 “/” 結尾,最後部分為 [^\\/:*%<>|"?\r\n]+/?。注意 “/” 字元需要轉移,完整正則字面量如下。

/\/([^\/:*%<>|"?\r\n]+\/)*[^\/:*%<>|"?\r\n]+\/?/
複製程式碼

Windows路徑匹配與上同理,開頭需要匹配 “碟符:\”,檔名中不能包含 “\ / : * < > | " ? \n \r” 字元。Windows 路徑名稱示例 “C:\book\regular.pdf”,完整正則如下。

/[a-zA-Z]:\\([^\/:*<>|"?\r\n]+\\)*[^\/:*<>|"?\r\n]+\\?/
複製程式碼

匹配URL

相對來說,匹配URL的表示式比較複雜,主機名、路徑名、引數等部分都有複雜的規範。這裡給出一個相對簡單的正則示例。

/(https?|ftp):\/\/[^/?:]+(:[0-9]{1-5})?(\/?|(\/[^/]+)*(\?[^\s"']+)?)/
複製程式碼

最開始是協議名,可能是 http、https、ftp。然後匹配 “:\”;主機名使用相對簡單的 [^/?:]+ 匹配,然後用 (:[0-9]{1,5})? 匹配可能出現的埠部分(埠最大為65536,簡要規定為 5 位數字)。URL 還可能包含路徑以及引數,分別用 (/[^/]+)*(\?[^\s"']+)? 匹配。

匹配成對的HTML Tag

比如匹配 <h3>regular expression</h3> 字串。最常見的辦法就是用 <[^>]+> 匹配 HTML 標籤。如果 tag 中含有 “>” 就不能正常匹配了,例如 <input value=">">,當然這種情況也很少見。

面對上述情況,可以區分情形。“<...>” 中允許出現引號文字和非引號文字(除 “>” 和 引號之外的任意字元 )。HTML引文可以用單引號,也可以用雙引號,可用 ("[^"]*"|'[^']'*|[^'">]) 來匹配除 Tag 名稱以外字元。

對於 HTML 標籤名稱,第一個字元必須是 [a-z]。為匹配完整匹配標籤名稱,用肯定順序環視 (?=[\s>]) 保證它之後是空白字元或 “>”。完整表示式如下。

/<([a-z][a-z0-9]*)(?=[\s>])('[^']*'|"[^"]*"|[^'">])*>.*?<\/\1>/
複製程式碼

再多說兩句,上述正規表示式並不支援標籤巢狀情形。例如目標字串 <h1>hello<h1>world</h1></h1>,實際匹配字元為 <h1>hello<h1>world</h1>。本質上是因為正規表示式無法匹配任意深度的巢狀結構。

第二章 正則原理

對於固定字串的處理,簡單的字串匹配演算法(類KMP演算法)相較更快;但如果進行復雜多變的字元處理,正規表示式速度則更勝一籌。那正規表示式具體匹配原理是什麼?打破砂鍋問到底,接下來一探究竟。注意後文會涉及編譯原理相關知識,筆者儘量簡短淺顯描述,如想了解更多可參考文末資料。

有窮自動機

正規表示式引擎實現採用一種特殊理論模型:有窮自動機(Finite Automata)也叫有限狀態自動機(finite-state machine)。這種模型具有有限個狀態,可以根據不同條件在狀態之間轉移。對映到正則場景,引擎首先根據表示式生成自動機,而後根據輸入字串在狀態之間遊走,判斷是否匹配。

以正規表示式 a(bb)+a 為例,對應有限狀態自動機如下。

DFA

上圖有窮自動機中,S0、S1、...、S4是各個狀態,S0為開始狀態,S4為最終狀態;狀態轉移過程為:當前狀態S0,輸入字元a,則轉移到S1,如果輸入不是a,那麼直接退出。自動機對字串 abbbba 匹配流程如下。

對應匹配過程

同一個正規表示式對應的有窮自動機不止一臺,這些有窮自動機是等價的。正規表示式 a(bb)+a 對應的所有等價自動機如圖。

所有

根據狀態的確定與否,可以將自動機(正則引擎)分為兩類:一類是確定型有窮自動機Definite Finite Automata 簡DFA),任何時候所處的狀態都是確定的;另一種是非確定性有窮自動機Non-definite Finite Automata 簡稱NFA),即某個時刻自動機所處狀態是不確定的。如上圖 NFA 第一個示例,輸入 ab 之後再輸入 b,此時所處狀態是不確定的,可能在 S1,也可能在 S3。

實際上,大多數程式都採用 NFA 引擎,其中也包括 JavaScript。這是為什麼呢?從正規表示式出發,構建 NFA 的難度要小於 DFA,構建 NFA 的時間更短;再有 NFA 在某一時刻狀態不唯一,這決定在匹配過程中需要儲存可能的狀態,相比之下 DFA 狀態是唯一確定的。故NFA 具有很多 DFA 無法提供的功能,比如捕獲分組 (...),反向引用 \num,環視功能 (?=),惰性量詞 +?*?等。後文討論情形也限於 NFA 引擎。

回溯

討論匹配原理,永遠繞不過去 “回溯”。NFA 匹配時,正則引擎並不確定當前狀態,只能將所有可能狀態儲存,逐一嘗試。當一條路走不通時,回溯到先前狀態,選擇其他狀態再次嘗試,如此以往,直到最終完成狀態。不難發現,回溯的過程本質是深度優先搜尋。

以正規表示式 /".*"/ 為例,字串 "hello" 匹配過程如下。

匹配過程

從圖中可以看出,在匹配過程中 .* 曾經匹配 hello",但為了保證表示式最後一個 " 匹配,不得不回退先前狀態。這種 “嘗試失敗-重新選擇” 的過程就是回溯。假如將字串變為 "hello" world ,回溯次數又將會增加。.* 會優先嚐試匹配剩餘 hello" world 字串,而後不斷回溯。

常見回溯形式

造成回溯主要緣由是 NFA 不能確定當前狀態,需要不斷嘗試前進回溯。常見非確定狀態形式有:貪婪量詞、惰性量詞、分支結構情形。

貪婪量詞

上面的示例其實是貪婪量詞相關,比如 /".*"/ ,對於非確定狀態會優先選擇匹配。雖然區域性匹配是貪婪的,但也要滿足整體正確匹配。皮之不存,毛將焉附之理。

惰性量詞

惰性量詞語法上是在貪婪量詞之後加個問號。表示在滿足條件情況下,儘可能少的匹配。雖然惰性量詞不貪,但有時也會出現回溯現象。看個示例。

/^\d+?\d{1,3}$/
複製程式碼

對於目標字串 “12345”,我們很容易能理解:\d+? 首先僅會匹配一個字元,但後續 \d{1,3}$ 最多隻能匹配三個字元,此時 $ 並不能匹配結尾,當前匹配失敗。後續回溯使 \d+? 匹配兩字元,從而整體匹配。

分支結構

分支結構在 JavaScript 中也是惰性的,比如 /Java|JavaScript/,匹配 JavaScript 字串時,得到的結果是 Java。引擎會優先選擇最左端的匹配結果。

分支結構可能前面的子模式形成區域性匹配,如果後面表示式整體不匹配時,仍然會嘗試剩下的分支。相應這種嘗試也可看成一種回溯。

濫用 .* 問題

. 幾乎能匹配任何字元,而 * 又表示 “匹配優先”,所以 .* 經常匹配到文字末尾。當需要整體模式匹配時,只能不斷回溯 “交還” 字串。但有時會出現問題,看個示例。

const re = /".*"/
const str = 'The great "pleasure" in "life" is doing what people say you cannot do'
const result = re.exec(str)
console.log(result[0])
// => "pleasure" in "life"
複製程式碼

瞭解匹配原理之後,結果也就顯而易見。.* 會一直匹配到字串末尾,為了最後雙引號能夠匹配,.* 不斷交還字元直到全域性匹配。但這並不是我們期望的結果,那如何獲得 "pleasure" 字串?

關鍵點在於,我們希望匹配的不是雙引號之間的“任何”文字,而是“除雙引號以外的任何文字”。如果用 [^"]* 取代 .*,就不會出現上面問題。

再看多字元引文問題,匹配 <b>love</b> is <b>love</b> 標籤 b 中內容。與雙引號字串例子相似,使用 <b>.*</b> 會匹配全部字串。假如僅匹配開頭 b 標籤,<b>[^</b>]*</b> "排除型字元組"正則顯然不可行。字元組僅排除包括單個字元,而不是一組字元。

解決問題方式有,使用惰性量詞。<b>.*?</b> 正則中 .*? 結構對於可匹配字元優先忽略。但假如字串變為 “<b>love is <b>love</b></b>”,此時上述正則會匹配 “<b>love is <b>love</b>”,可見惰性量詞並不是排除類的完美解決方式。

更優方式,使用排除環視,比如 (?!</b>) 表示只有 “</b>” 不在字串中當前位置時才能成功。把點號替換為 ((?!</?b>).) 得到正規表示式如下:

/<b>((?!</?b>).)*</b>/
複製程式碼

有序多選結構陷阱

有序多選分支結構容許使用者控制期望的匹配,但也有時也會不明就裡出些問題。假如需要匹配 “Jan 31” 之類的日期,首先需要將日期部分拆開。用 0?[1-9] 匹配可能以 0 開頭的前九天,用 [12][0-9] 處理十號到二十九號,用 3[01] 處理最後兩天。連線起來就是。

/Jan (0?[1-9]|[12][0-9]|3[01])/
複製程式碼

但假如以上述表示式匹配 “Jan 31”,有序多選分支只會捕獲 “Jan 3”。其實這裡主要是因為,引擎是以優先選擇最左端的匹配結果為原則。(0?[1-9]) 分支可以匹配 3,後續分支不會繼續嘗試。

需要重新安排多選結構的順序,把能夠匹配的數字最短的放到最後:Jan ([12][0-9]|3[0-1]|0?[1-9])。通過示例可以看到,如果多選分支是有序的,而能夠匹配同樣文字的分支不止一個,就要小心安排多選分支的先後順序。

避免“指數級”匹配

舉個簡短栗子,假如存在正規表示式 ([^\\"]+)*,匹配非引號非轉義字元(詳情見《精通正規表示式》P227)。目標字串 makudonarudo ,星號可能會迭代 12 次,每次迭代中 [^\\"] 匹配一個字元,星號還可能迭代 11 次,前 11次 迭代中 [^\\"] 匹配一個字元,最後匹配兩個字元,或者 10、9、8 次等等。

多個量詞修飾同一分組,間接相當於指數級匹配時間複雜度。上例中,長度為 12 的字串存在 4096 種可能(2的12次方)。除DFA 和 傳統型NFA 引擎外,還有一種 POSIX NFA 引擎。其和傳統型NFA引擎主要差別在於,傳統型NFA在遇到第一個完整匹配時會停止。而 POSIX NFA 會嘗試匹配所有情形,尋找最長匹配字元。並且不難想象,如果沒有完整匹配,即使傳統型NFA也需要嘗試所有可能。所以儘可能避免 *、+ 量詞巢狀情形。

如何從表示式角度避免指數級匹配,內容較為複雜,犧牲可讀性和可維護性。筆者這裡不再搬運文字,詳情可閱讀《精通正規表示式》P261,消除迴圈章節。

常識性優化措施

  • 使用非捕獲型括號

    如果不需要引用括號內文字,請使用非捕獲型括號 (?:...)。這樣不但能夠節省捕獲的時間,而且會減少回溯使用的狀態數量。

  • 消除不必要括號

    非必要括號有時會阻止引擎優化。比如,除非需要知道 .* 匹配的最後一個字元,否則請不要使用 (.)*

  • 不要濫用字元組

    避免單個字元的字元組。例如 [.][*],可以通過轉義轉換為 \.\*\

  • 使用起始錨點

    除非特殊情況,否則以 .* 開頭的正規表示式都應該在最前面新增 ^。如果表示式在字串的開頭不能匹配,顯然在其他位置也不能匹配。

  • 從量詞中提取必須元素

    xx* 替代 x+ 能夠保留匹配必須的 “x”。同樣道理,-{5,7} 可以寫作 -----{0,2}

  • 提取多選結構開頭的必須元素

    th(?:is|at) 替代 (?:this|that),就能暴露除必須的 “th”。

  • 忽略優先還是匹配優先?

    通常,使用忽略優先量詞(惰性)還是匹配優先量詞(貪婪)取決於正規表示式的具體需求。舉例來說,/^.*:/ 不同於 ^.*?:,因為前者匹配到最後的冒號,而後者匹配到第一個冒號。總的來說,如果目標字串很長,冒號會比較接近字串的開頭,就是用忽略優先。如果在接近字串末尾位置,就是用匹配優先量詞。

  • 拆分正規表示式

    有時候,應用多個小正規表示式的速度比單個正則要快的多。“大而全”的正規表示式必須在目標文字中的每個位置測試所有表示式,效率較為低下。典型例子可以參考前文, 去除字串開頭和結尾空白。

  • 將最可能匹配的多選分支放在前頭

    多選分支的擺放順序非常重要,上文有提及。總的來說,將常見匹配分支前置,有可能獲得更迅速更常見的匹配。

  • 避免指數級匹配

    從正規表示式角度避免指數級匹配,應儘可能減少 + * 量詞疊加,比如 ([^\\"]+)* 。從而減少可能匹配情形,加快匹配速度。

這裡簡述的僅是常見優化措施,當然在提高匹配效率時也需要平衡正規表示式的可讀性。不僅是效率,假如編寫正規表示式晦澀難懂,維護成本相應會也加重。正規表示式可讀性、可維護性也是很重要的一方面。

正則引擎

本節整體描述從正規表示式到DNA的構建過程。限於筆者本身能力有限,在此僅簡單描述正則編譯流程,以及過程中用到的演算法資料結構。具體細節,筆者也未曾實踐,就不再信口胡言。下文主要參考 編譯原理 課程視訊。

簡短回顧正則引擎工作流程,接受一條宣告式的規範(正規表示式),通過詞法分析自動生成器,生成詞法分析程式碼。如下圖所示,自動生成器將正規表示式變成 NFA,再轉換成 DFA 資料結構(DFA引擎),再到詞法分析演算法程式碼。

前端正則二三事

RE轉換NFA,從正規表示式到非確定有限狀態自動機,主要採用 Thompson 演算法。Thompson演算法基於對正規表示式的結構做歸納,對基本的RE 直接構造,對複合的 RE 遞迴構造。遞迴演算法,比較容易實現。

NFA轉換DFA,從非確定有限狀態自動機到確定有限狀態自動機,主要採用 子集構造演算法。DFA最小化,目的希望DFA儘可能小,效率儘可能高。一般採用 Hopcroft 最小化演算法

結語

正規表示式尤其複雜規則,初看頭昏腦漲。不過熟悉基本結構,拆分不同模組,也就慢慢理解。

囉裡囉嗦,拙文一篇。看完書記錄下,希望時間匆匆而過,回首有可回味之地。此篇文章以筆者目前功力,寫起來有些趕鴨子上架強人所難。但愧知識短淺,誠恐貽笑大方。不過另一方面來說,不斷挑戰自我,逃離舒適區,也算是一種成長。

多說幾句,近來發現自己,一起筆就有種老氣橫秋的趕腳。用老話說,少年不識愁滋味,為賦新詞強說愁。這可能與筆者的性格有關,不喜鬧,偏安靜。每篇文章竭盡向正經八股文靠攏,不希望文字中透露幼稚氣。文章自己慢慢讀來,也會發現有些地方不自然,刻意為之,後續儘量提高。

筆者能力有限,行文難免有所疏忽,如有誤導,敬請斧正。

參考文件

前端正則二三事

打個廣告,歡迎關注筆者公眾號

相關文章