深入理解正則表達式

stjl4451 8年前發布 | 5K 次閱讀 正則表達式

學習了半年的正則表達式,也不能說一直學習吧,就是和它一直在打交道,如何用正則表達式解決自己的問題,并且還要考慮如何在匹配大量的文本時去優化它。慢慢的覺得正則已經成為自己的一項技能,逐漸的從一個正則表達式小白變成一個偽精通者。

new RegExp 和 // 正則對象創建區別

如果寫過 Python 的同學,都一定會知道 Python 中可以在字符串前面加個小寫的 r ,來表示防止轉義。防止轉義的意思就是說: str = r'\t' 等價于 str = '\\t' ,加了 r 會防止 \ 被轉義。

為什么要介紹這個,因為這就是 new RegExp 和 // 的區別,因為我們知道在正則表達式中會頻繁的使用轉義字符 \w\s\d 等,但是它們在內存中的是以 \\w\\s\\d 存儲的,看個例子:

//推薦寫法
var regex1 = /\w+/g;
regex1 // /\w+/g
//RegExp 寫法
var regex2 = new RegExp('\\w+','g');
regex2 // /\w+/g
//錯誤寫法
var regex3 = new RegExp('\w+','g');
regex3 // /w+/g

你也看出來了,錯誤寫法只能匹配 wwwww 這樣的字符串,曾經我就見過有人把他們弄混了,還說第一個第三個沒有區別。第二種方法的輸出,還是 /\w+/g ,中間還是要轉換,所以推薦第一種寫法。

當然,還有比較奇葩的:

1

2

var regex4 = new RegExp(/\w+/g);
regex4 // /\w+/g

i、g、m 修飾符

這幾個修飾符只是針對 JS 來說的,像 Python 中還有 re.S 表示 . 可以匹配換行符。

對于 i 表示忽略字母大小寫,不是很常用,因為它有很多替代品,比如: /[a-zA-Z]/ 可以用來替代 /[a-z]/i ,至于兩者處理長文本的時間效率,我自己沒有研究過,不下定論。

使用 i 需要注意的地方,就是 i 會對正則表達式的每一個字母都忽略大小寫,當我們需要部分單詞的時候,可以考慮一下 /(?:t|T)he boy/ 。

g 表示全局匹配,在印象中,可能很多人會覺得全局匹配就是當使用 match 的時候,把所有符合正則表達式的文本全部匹配出來,這個用途確實很廣泛,不過 g 還有其他更有意思的用途,那就是 lastIndex 參數。

var str = '1a2b3c4d5e6f',
    reg = /\d\w\d/g;
str.match(reg); //["1a2", "3c4", "5e6"]

為什么不包括 2b3,4d5 ,因為正則表達式匹配的時候,會用 lastIndex 來標記上次匹配的位置,正常情況下,已經匹配過的內容是不會參與到下次匹配中的。帶有 g 修飾符時,可以通過正則對象的 lastIndex 屬性指定開始搜索的位置,當然這僅僅局限于函數 exec 和 test(replace 沒研究過,沒聽說過可以控制 lastIndex,match 返回的是數組,無法控制 lastIndex),針對這個題目修改如下:

var str = '1a2b3c4d5e6f',
  reg = /\d\w\d/g;
var a;
var arr = [];
while(a = reg.exec(str)){
  arr.push(a[0]);
  reg.lastIndex -= 1;
}
arr //["1a2", "2b3", "3c4", "4d5", "5e6"]

m 表示多行匹配,我發現很多人介紹 m 都只是一行略過,其實關于 m 還是很有意思的。首先,來了解一下單行模式,我們知道 JavaScript 正則表達式中的 . 是無法匹配 \r\n (換行,各個系統使用不一樣) 的,像 Python 提供 re.S 表示 . 可以匹配任意字符,包括 \r\n ,在 JS 中如果想要表示匹配任意字符,只能用 [\s\S] 這種蹩腳的方式了(還有更蹩腳的 [\d\D],[.\s] )。這種模式叫做開啟或關閉單行模式,可惜 JS 中無法來控制。

多行模式跟 ^ $ 兩兄弟有關,如果你的正則表達式沒有 ^$,即時你開啟多行模式也是沒用的。正常的理解 /^123$/ 只能匹配字符串 123 ,而開啟多行模式 /^123$/g 能匹配 ['123','\n123','123\n','\n123\n'] ,相對于 ^$ 可以匹配 \r\n 了。

var str = '\na';
/^a/.test(str); //false
/^a/m.test(str); //true

有人說,m 沒用。其實在某些特殊的格式下,你知道你要匹配的內容會緊接著 \r\n 或以 \r\n 結尾,這個時候 m 就非常有用,比如 HTTP 協議中的請求和響應,都是以 \r\n 劃分每一行的,響應頭和響應體之間以 \r\n\r\n 來劃分,我們需要匹配的內容就在開頭,通過多行匹配,可以很明顯的提高匹配效率。

原理性的東西,我們還是要知道的,萬一以后會用到。

(?:) 和 (?=) 區別

在正則表達式中,括號不能亂用,因為括號就代表分組,在最終的匹配結果中,會被算入字匹配中,而 (?:) 就是來解決這個問題的,它的別名叫做非捕獲分組。

var str = 'Hello world!';
var regex = /Hello (\w+)/;
regex.exec(str); //["Hello world", "world"]
var regex2 = /Hello (?:\w+)/;
regex2.exec(str); //["Hello world"]

可以看到 (?:) 并不會把括號里的內容計入到子分組中。

關于 (?=),新手理解起來可能比較困難,尤其是一些很牛逼的預查正則表達式。其實還有個 (?!),不過它和 (?=) 是屬于一類的,叫做 正向肯定(否定)預查 ,它還有很多別名比如零寬度正預測先行斷言。但我覺得最重要的只要記住這兩點,預查和非捕獲。

預查的意思就是在之前匹配成功的基礎上,在向后預查,看看是否符合預查的內容。正因為是預查,lastIndex 不會改變,且不會被捕獲到總分組,更不會被捕獲到子分組。

var str = 'Hello world!';
var regex = /Hello (?=\w+)/;
regex.exec(str); //["Hello "]
//replace 也一樣
var regex2 = /(?:ab)(cd)/
'abcd'.replace(regex2,'$1') //"cd"

和 (?:) 區別是: 我習慣的會把匹配的總結果叫做總分組 ,match 函數返回數組每一項都是總分組,exec 函數的返回數組的第一項是總分組。(?:) 會把括號里的內容計入總分組,(?=) 不會把括號里的內容計入總分組。

說白了,還是強大的 lastIndex 在起作用。(?:) 和 (?=) 差別是有的,使用的時候要合適的取舍。

說了這么多關于 (?=) 的內容,下面來點進階吧!現在的需求是一串數字表示錢 “10000000”,但是在國際化的表示方法中,應該是隔三位有個逗號 “10,000,000”,給你一串沒有逗號的,替換成有逗號的。

var str = "10000000";
var regex = /\d(?=(\d{3})+$)/g;
str.replace(regex, '$&,'); //"10,000,000"

我們分析一下 regex, /\d(?=(\d{3})+$)/g 它是全局 g,實際上它匹配的內容只有一個 \d, (?=(\d{3})+$) 是預判的內容,之前說過,預判的內容不計入匹配結果,lastIndex 還是停留在 \d 的位置。 (?=(\d{3})+$) 到結尾有至少一組 3 個在一起的數字,才算預判成功。

\d = 1 的時候,不滿足預判,向后移一位, \d = 0 ,滿足預判,replace。

(?!) 前瞻判斷

(?=) 和 (?!) 叫做正向預查,但往往是正向這個詞把我們的思維給束縛住了。正向給人的感覺是只能在正則表達式后面來預判,那么 預判為什么不能放在前面呢 。下面這個例子也非常有意思。

一個簡單密碼的驗證,要保證至少包含大寫字母、小寫字母、數字中的兩種,且長度 8~20。

如果可以寫多個正則,這個題目很簡單,思路就是: /^[a-zA-Z\d]{8,20}$/ && !(/[a-z]+/) && !(/[A-Z]+/) && !(/\d+/) ,看著眼都花了,好長一串。

下面用 (?!) 前瞻判斷來實現:

var regex = /^(?![a-z]+$)(?![A-Z]+$)(?!\d+$)[a-zA-Z\d]{8,12}$/;
regex.test('12345678'); //false
regex.test('1234567a'); //true

分析一下,因為像 (?!) 預判不消耗 lastIndex,完全可以放到前面進行前瞻。 (?![a-z]+$) 的意思就是從當前 lastIndex (就是^)開始一直到 $,不能全是小寫字母, (?![A-Z]+$) 不能全是大寫字母, (?!\d+$) 不能全是數字, [a-zA-Z\d]{8,12}$ 這個是主體,判斷到這里的時候, lastIndex 的位置仍然是 0,這就是 (?!) 前瞻帶來的效率。

非貪婪與貪婪的問題

貪婪出現在 + * {1,} 這種不確定數量的匹配中,所謂的貪婪,表示正則表達式在匹配的時候,盡可能多的匹配符合條件的內容。比如 /hello.*world/ 匹配 'hello world,nice world' 會匹配到第二個 world 結束。

鑒于上面的情況,可以使用 ? 來實現非貪婪匹配。? 在正則表達式中用途很多,正常情況下,它表示前面那個字符匹配 0 或 1 次,就是簡化版的 {0,1} ,如果在一些不確定次數的限制符后面出現,表示非貪婪匹配。 /hello.*?world/ 匹配 'hello world,nice world' 的結果是 hello world 。

我剛開始寫正則的時候,寫出來的正則都是貪婪模式的,往往得到的結果和預想的有些偏差,就是因為少了 ? 的原因。

我初入正則的時候,非貪婪模式還給我一種錯覺。還是前面的那個例子,被匹配的內容換一下,用 /hello.*?world/ 匹配 'hello word,nice world' ,因為 word 不等于 world,在第一次嘗試匹配失敗之后,應該返回失敗,但結果卻是成功的,返回的是 'hello word,nice world' 。

一開始我對于這種情況是不理解的,但仔細想想也對,這本來就應該返回成功。至于如何在第一次嘗試匹配失敗之后,后面就不再繼續匹配,只能通過優化 .* 。如果我們把 .*?end 這樣子來看, .* 會把所有字符都吞進去,慢慢吐出最后幾個字符,和 end 比較,如果是貪婪,吐到第一個滿足條件的就停止,如果是非貪婪,一直吐到不能吐為止,把離自己最近的結果返回。

所以,貪婪是返回最近的一次成功匹配,而不是第一次嘗試。

避免回溯失控

回溯可以殺死一個正則表達式,這一點都不假。關于正則表達式回溯也很好理解,就是正則引擎發現由兩條路可以走時,它會選擇其中的一條,把另一條路保存以便回溯時候用。

比如正則 /ab?c/ 在成功匹配到 a 之后,后面可以有 b,也可以沒有 b,這時候要提供兩種選擇。還有其他類型的回溯,比如 /to(night|do)/ 。當然影響性能的回溯就要和 .* .+ .{m} 有關。

所謂的回溯失控,就是可供選擇的路徑太多,看一個常見回溯失控的例子,正則 /(A+A+)+B/ ,如果匹配成功,會很快返回,那么匹配失敗,非常可怕。比如來匹配 10 個 A AAAAAAAAAA ,假設第一個 A+ 吞了 9 個 A,整個正則吐出最后一個字符發現不是 B,知道吐完,還不能返回 false,第一個 A+ 吞 8 個 A,….回溯次數的復雜度是 n 的平方。

當然你可能會說,自己不會寫這樣傻的正則表達式。真的嗎?我們來看一個匹配 html 標簽的正則表達式, /<html>[\s\S]*?<head>[\s\S]*?</head>[\s\S]*?<body>[\s\S]*?</body>[\s\S]*?</html> (感覺這樣寫也很傻)。如果一切都 OK,匹配一個正常的 HTML 頁面,工作良好。但是如果不是以 </html> 結尾,每一個 [\s\S]*? 就會擴大其范圍,一次一次回溯查找滿足的一個字符串。

在說到回溯的同時,有時候還是要考慮一下 . * {} 查詢集合的問題,反正我的建議是盡量避免使用匹配任何字符的 [\s\S] ,這真的是有點太暴力了。因為我們寫正則的時候,都是以正確匹配的思路去寫的,同時還需要考慮如果匹配不成功,該如何盡快的讓 [a-zA-Z]* 集合盡快停止,比如 [^\r\n]* 在匹配單行時效果不錯,即時匹配失敗也可以快速停止。

總結

感覺這篇文章寫的很亂,東扯西扯的,大概我把我這幾個月以來所學到的正則表達式知識都寫在了這里,當然這并不包括一些基礎的知識。我覺得學習正則最主要的還是去練習,只有在實際項目中總結出來的正則經驗,才算自己正在掌握的,如果只是簡單的少一眼,時間久了,終究會忘記。共勉!

參考

RegExp對象 - 阮一峰

MSDN RegExp

進階正則表達式

如何找出文件名為 “.js” 的文件,但要過濾掉 “.min.js” 的文件。

代碼如下:

var regex = /^(?!.*\.min\.js$).+\.js$/;
regex.test('a.js'); //true
regex.test('b.min.js'); //false
regex.test('c.css'); //false

 

來自:http://yuren.space/blog/2016/11/05/深入理解正則表達式/

 

 本文由用戶 stjl4451 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!