從零開始學正則(上)

2023-04-01 06:00:40

本文屬於我在前端團隊的第二次分享,由於正則篇幅比較長,全文大概3W字左右,所以分為了上下篇,本文總體上來說屬於我正則學習專欄的彙總,文章很大程度上借鑑了老姚《JavaScript正則迷你書》,並在其基礎上做了拓展,以下是分享原文。

引、我為什麼學正則?

19年年底,我入行前端正好兩年半,不會籃球也不會正則,無奈的是當時有幾個需求正好與正則掛鉤,某個困難我請教了前後端組長,結果發現沒一個人能解答我的問題,也因此我決定與正則結緣,2019年12月13日,我在第一篇正則部落格文章中寫到,公司前端組沒一個人懂正則,等我學會我將是組裡第一個會正則的人!

我在當時的要求也不算高,讓自己能看懂別人所寫的正則,以及能寫出已知規則的正則,在半個月學習後,我確實達到了目的,而本次分享,我也希望幫助大家達到這個水平。

但需要記住,不要嘗試把正則的所有概念都背下來,理解概念就好,等有需要腦子裡立馬知道使用哪部分知識可以幫助自己解決問題,再對應複習即可。

一、前置工具篇

正則覺得難無非難在兩點,看不懂(長正則易讀性都很差)與不會寫(不敢保證寫的對),針對這兩點我先分享幾個工具,兩個專門用於看,兩個專門用於線上寫線上測,算是學習正則前的前置知識。

1.1正則圖解神器 Regulex

Regulex 這個工具也是我在閱讀正則迷你書時,作者所推薦的一個工具。長正則因為分組多正規表示式長,我們單看程式碼可能從到到哪是一個組都看的費勁,但通過圖解工具,你的分組,每個分組起到什麼作用都非常清晰。

比如我們現在要實現一個正則,匹配任意三個連續且完全相同的數位,比如:

const regex = /^(\d)\1{2}$/g;
regex.test(123);// false
regex.test(111);// true
regex.test(555);// true

/^(\d)\1{2}$/g是一個結構相對簡單的正則,但對於瞭解基本概念但讀正則有點費勁的同學,心裡肯定會想,這是個啥玩意?我們將這段正則複製到 Regulex 中,圖解圖下:

Group #1 代表分組1,對應正則也就是(\d)這一段,而Digit也解釋了含義,表示匹配一個任意數位。

Backref 表示反向參照,參照誰?緊接著一個 #1 表示反向參照分組1,而 Backref #1 被一個2 times 連通,代表這一段匹配 2 次。

那麼總結來說,(\d) 匹配一個任意精確的數位,反向參照再匹配這個數位兩次,加起來就是一個數位重複匹配3次,所以這也是為什麼123匹配失敗,因為當匹配到 1 時,\1{2}此時已經被確定成再反向參照匹配 1 兩次了。

1.2 圖解視覺化工具 regex-vis

與 Regulex 類似,regex-vis 也是一款用於圖解正則的工具,比如正則:

const regex = /23{2,}/

表示匹配 2 開頭,以及 3 結尾,且 3 出現最少 2 次。

1.3正則線上偵錯工具regexr

如果說 Regulex 與 regex-vis 主要用來幫助我們讀,那麼 regexr 能非常方便的幫助我們線上寫正則,比如在日常開發中我們寫了一個正則,看上去似乎滿足了需求,又擔心它會不會出乎意料的匹配到我們不想要的內容,regexr就能起到一個很好的測試作用。

regexr 介面非常簡單,一共分為三個區域,上方 Expression 用來寫你的正則,下方 Text 用於寫你的測試用例,而最下方的工具列Tools能讓你寫一個正則做更多事。

比如在上圖中,我定義了一個正則 /\d([a-z]+)/,以及一個例子 123abc12 ,於是被匹配的區域成功高亮;在更下方工具列,我們選擇了Replace,我們希望將匹配內容替換成 ❀ ,於是在下面我們看到了替換完成的結果 12❀12。

你完全可以將一個正則寫好貼上去,然後把很多個正則匹配邊界情況的例子統統加入到Text中,用於檢驗你的正則是否符合你的預期。而在 Expression 右側,我們還能切換變成語言,以及選用更多修飾符,比如是否啟用全域性匹配,是否多行,是否區分大小寫等等。

我在給前同事解釋分組與反向參照的過程中,他問了我一個這個問題,為什麼下面這段程式碼輸出是 true:

new RegExp(/(?:[0-9]){1}-([a-z])\1{3}/).test('111111-bbbb')// true

對於他而言,他的理解是(?:[0-9]){1}這一段明明限定了只匹配1個任意數位,那為什麼後面用例這麼多個1還匹配成功了,單看這代程式碼,好像真是這麼回事,但只要你把這個正則貼到 regexr 中你完全不會有這個疑慮:

因為這個正則並沒有限定從什麼地方開始匹配數位1,他給的這個例子中確實有一小段符合要求,當然返回是true。若想達到他的預期,只需要在正則前加一個^即可。

new RegExp(/^(?:[0-9]){1}-([a-z])\1{3}/).test('111111-bbbb')// false

1.4 vscode 外掛 Regex Previewer

除了線上網站,有時候我們寫程式碼過程中也想立刻測試正則是否生效,Regex Previewer 就是一個這樣的外掛。在安裝之後,程式碼中所有正則程式碼的上角都會出現一個 test 按鈕,點選後它會幫你新開一個 tab 並提供部分用例,且匹配的內容會幫你高亮。需要注意的是,目前不支援正則構造器的寫法。

二、正則的兩種模糊匹配

正規表示式是一種匹配模式,要麼匹配字元(符合規則的字元),要麼匹配位置(符合規則字元所在的位置)。

正則之所以強大,是因為正則能實現模糊匹配;在實際開發中,我們往往需要匹配某一範圍的資料。舉個貼切的例子,當驗證使用者輸入郵箱格式是否正確,除了 @ 等固定字元以外,使用者輸入的具體字元我們是無法估量和統計的,精準匹配顯得無能為力,也只有模糊匹配能巧妙解決這個問題。

正規表示式的模糊匹配分為橫向模糊與縱向模糊兩種:

2.1 橫向模糊

不難理解,橫向模糊表示正則匹配的字元長度是不確定的,我們可以通過正則的量詞實現橫向匹配。不知道大家有沒有在B站看到過 233 的彈幕,233 是一個網路用語,表示大笑的意思。但因為個人輸入隨心的習慣,可能打出2333,233333 等不定長度的彈幕,那麼我們匹配彈幕中有多少 233 大笑可以用正則這麼寫:

const regex = /23{2,}/;

這裡量詞 {2,} 表示前面的3會出現2次或者更多次,量詞後面會專門介紹,我們來試試這個正則:

const regex = /23{2,}/g; 
const str = '223 233 2323 2333';
const result = str.match(regex);
//["233", "2333"]

注意正則後面有個小寫的字母 g,這是正則修飾符之一,g 為 global 的簡寫,表示全域性匹配。若不加 g ,match 方法只會匹配第一個符合條件的字元,關於修飾符後文會詳細介紹。

2.2 縱向模糊匹配

縱向模糊匹配是指具體某一位置可能有多種字元的情況,橫向模糊可以用量詞實現,而縱向模糊匹配可以使用字元組實現,比如:

const regex = /[abc]/;

這段正則表示可匹配字母 a b c 其中一個,我們來看一個簡單的例子:

const regex = /a[1-3]c/g;
const str = "a0c a1c a2c a3c a4c";
const result = str.match(regex);
result //["a1c", "a2c", "a3c"]

在這個例子中我們使用了字元組 [1-3] ,它本質上與 [123] 效果相同,但因為是連貫數位所以支援範圍簡寫。下面介紹具體介紹正則字元組。

三、正則字元組

在上一個例子中我們已經瞭解到字元組[123]可用範圍表示法寫成[1-3],這是非常有用的,設想一想,我們現在想匹配數位1-9,字母a-f,要寫全的話就得這樣[123456789abcdef],但通過範圍表示法只用短短的[1-9a-f],是不是很奈斯:

現在知道了連字元 - 的作用,那麼現在我們就是要匹配1 - 3其中任意字元怎麼做呢?有三種寫法可解決這個問題,寫成[-13][13-]或者使用跳脫符 \ 表示 [1\-3] 即可。

3.1.排除字元組

縱向模糊匹配還存在一種情況,就是某個位置可以是除了某幾個字元之外的任意字元,比如我希望是除了1-3 之外的任意字元,那麼我們可以使用[^1-3]表示,注意這裡使用了脫字元 ^

3.2.常用簡寫

瞭解了字元組範圍表示法,那麼想匹配數位 0 到 9 可以寫成 [0-9],其實它還有一種更簡單的寫法\d,估計這部分是很多人常忘記的知識,我們來做個整理:

字元組 含義
\d [0-9]表示是一位數位,digit數位。
\D [^0-9]表示除數位以外的任意字元。
\w [0-9a-zA-Z_]表示數位,大小寫字母和下劃線,word簡寫,又稱單詞字元。
\W [^0-9a-zA-Z_],非單詞字元。
\s [ \t\v\n\r\f]表示空白符。包含空格,水平製表符,垂直製表符,換行符,回車符,換頁符。
\S [^ \t\v\n\r\f],非空白符。
. [^\n\r\u2028\u2029],萬用字元,表示除了換行符,回車符,行分隔符和段分隔符之外任意字元。

空格:顧名思義,就是我們理解的空格

水平製表符\t:類似於tab鍵縮排的效果,一般系統中水平製表符佔8列,所以根據你按的次數佔據8*N列。

垂直製表符\v:讓文字從下一行開始輸出,且開始的列數為\v前字元的後一列。

換行符\n:從下一行開頭開始輸出。

回車符\r:這裡的回車不是我們理解的 enter 回車另起一行開始輸出,而是回到當前行開頭輸出,還可能將已輸入文字替換,替換這一點根據環境不同表現不同。

換頁符\f:在輸出\f後面文字之前,會先將當前螢幕清空,類似於先清除再輸出。

行分隔符和段分隔符,找了一圈也沒看到好的解釋,這裡還望有緣人指點。

那麼如果我們想匹配任意字元,有這幾種寫法[/d/D][/w/W][/s/S][^],其實不難理解,以[/d/D]為例,就是匹配數位以及除了數位以外的所有字元,這不就是所有字元了嗎。

四、正則量詞

在講述正則橫向模糊匹配時已有使用量詞的例子,量詞表示某個字元的重複次數,我們也將常用量詞做個整理:

量詞 含義
至少出現m次,最多出現n次。
至少出現m次,沒有上限。
等價於{m,m},固定出現m次
? 等價於{0,1},要麼不出現,要麼出現一次。
+ 等價於{1,},至少出現1次,沒有上限。
* 等價於{0,},表示出現任意次數,可以不出現,也可以任意次,包容性比?和+大。

4.1.貪婪匹配和惰性匹配

正則預設就是貪婪匹配,貪婪就是在量詞匹配規則範圍內最大限度的去匹配字元,我們來看個簡單的例子:

const str = "ab abb abbb abbbb abbbbb";
const regex = /ab{2,4}/g;
const result = str.match(regex);
result //["abb", "abbb", "abbbb", "abbbb"]

在這個例子中,我們匹配 2-4 個字母b,你給 2 個我要,給 3 個我要,哪怕給 5 個我也要盡我所能拿 4 個,是不是很貪心。

惰性與貪婪相反,惰性匹配就是在量詞匹配範圍內以最小限度去匹配字元,無慾無求做人本分,我們只需要在量詞後接個 ? 即是惰性匹配,看個例子:

const str = "ab abb abbb abbbb abbbbb";
const regex = /ab{2,4}?/g;
const result = str.match(regex);
result //["abb", "abb", "abb", "abb"]

大家會不會覺得惰性匹配情況下這個次數4是不是沒意義了呢?其實並不是沒意義,儘管惰性匹配是以最小2次為匹配規則,但被匹配的字元前提條件是滿足 2-4 之間,4還是起到了限制條件,我們改改例子再看:

const str = "abc abbc abbbc abbbbc abbbbbc";
const regex = /ab{2,4}?c/g;
const result = str.match(regex);
result //["abbc", "abbbc", "abbbbc"]

上述例子中當匹配到欄位 abbbbbc 時因為字母b已經超過範圍,所以不在匹配範圍內。惰性可以理解為,在匹配範圍內拿最少的東西,我可以過的無慾無求,但也得過的溫飽活得下去才行啊。

五、 正則多選分支

如果說橫向模糊匹配和縱向模糊匹配都是一種匹配模式,那如果需要同時使用多種模式怎麼辦呢,這裡我們就可以使用管道符 | 實現這一點,來看個簡單的例子:

const str = "a0c a1c a2c a3c abc abbc abbbc abbbbc";
const regex = /a[1-3]c|ab{1,3}c/g;
const result = str.match(regex);
result //["a1c", "a2c", "a3c", "abc", "abbc", "abbbc"]

在這個例子中,我們使用了縱向模糊匹配和橫向模糊匹配兩種模式。

需要注意的是,分支匹配也是惰性匹配,即前面的匹配模式能滿足,後面就不匹配了,來看個例子:

const str = "userName";
const regex = /user|userName/g;
const result = str.match(regex);
result //["user"]

這非常類似於js短路運運算元中的 || ,以 a || b 為例,倘若 a 為真那麼 b 就不判斷了。

function fn1() {
  console.log(1);
  return true;
};

function fn2() {
  console.log(2);
  return true;
};
fn1() || fn2(); //1

我們再來個反轉,前文雖說條件匹配是惰性,但這個前提也是一開始第一個條件能匹配上,但事實上,匹配角度正則還是期望能拿到更多字串,我們再看個例子:

const str = "userName";
const regex = /Name|userName/g;
const result = str.match(regex);
result //["userName"]

哎?怎麼不是匹配 Name 欄位,其實站在匹配角度,正則就是拿條件一個個試,字串的匹配規則就是從左往右的匹配,當左側一開始無法匹配成功(user 和 Name 對應不上),正則就會想會不會其它條件能匹配上,那我就繼續先嚐試,結果第二個條件 useName 完美契合,所以第一個 Name 反而匹配不上了。

那假設除了第一個條件,其餘條件都匹配不上呢?再比如這個例子:

const str = "userName";
const regex = /Name|userAge/g;
const result = str.match(regex);
result //["Name"]

同樣還是從左往右匹配,一開始 Name 和 userName 開頭無法匹配,正則同樣還是繼續嘗試分支其它情況,結果userAge 這個條件直接匹配不上,沒條件可以走了,正則這時候就會回溯,完整的拿不到吧,那繼續退而求其次,匹配回溯再從第一個條件開始,拿 Name 進行部分匹配,哎,這下發現 userName 裡面確實有一部分可以匹配上,於是得到了 Name。

你看,一個小小的條件匹配,裡面其實藏著不少細節,我們總結下:

  • 條件匹配是惰性的,如果第一個條件能跟字串第一個字元開始匹配上,那後續條件就不用匹配了。
  • 條件匹配也是貪婪的,如果第一個字元匹配不上,那就先放棄區域性匹配,繼續試試其它條件,除非都完成匹配失敗,再回溯所有條件進行區域性匹配。

那麼到這裡,我們先做個總結,大家可以看著思維導圖回顧下知識點:

讓我們來兩個練習,嘗試寫出匹配 24 小時制的正則匹配(只包含小時分鐘即可),以及匹配16進位制顏色值的正則,注意,16進位制顏色是支援 #dddddd#ddd 兩種。

我們先來解決24小時制時間匹配的正則,首先二十四小時制的時間一般是 09:30 或者 23:59 這樣,小時的第一位數位可能是[0-2]三種情況之一,當為 0,1 時,第二位數位可以是[0-9]任意數位,當為 2 時第二位數位只能是 0-3 之間的數位。第三位數位只能是 0-5 之間的數位,最後一位數位只能是 0-9 之間。

整理下資訊:

  • 當第一位數位為 0 1 時,第二位數位可以是[0-9]任意數位,比如 00、09、19。
  • 當第二位數位為 2 時,第二位只能是 21 22 23,固定的。
  • 第三位數位只能是[0-5],逢五進一,比如 01 59,不可能大於 5。
  • 第四位數位範圍[0-9],結合第三位很好理解 00 09 50,逢九進一。

綜上,我們只用對於小時的兩種情況做個分支,分鐘固定的範圍即可,所以正則可以寫成這樣:

const regex = /^([01][0-9]|[2][0-3]):[0-5][0-9]$/;
regex.test("00:07"); //true
regex.test("23:59"); //true

注意,匹配小時的分支我們使用了一對圓括號包裹,表示這是一個組,而組內包含了兩個分支情況,如果不加圓括號正則解析時會將管道符 |左右兩側理解成兩個分支,如下圖,很明顯這不是我們想要的規則:

其次,在正則內部開頭和結尾我們分別使用了^$兩個符號,這表示正則匹配時嚴格以字串開頭和結尾中間的內容為匹配物件,如果不加效果就是這樣:

const regex = /([01][0-9]|[2][0-3]):[0-5][0-9]/;
regex.test("0000:0709");//true

上面這代正則匹配為 true 是因為字串中間有一部分是符合規則,所以如果我們想匹配一個欄位從頭到尾是否符合規則,一定得記得加上 ^$ 符號限制從頭到尾整個字元都得符合規則。

我們再來分析16進位制顏色,提前查了下,每個字母範圍均為[0-9a-fA-F],但由於顏色值可以簡寫,比如 #ffffff 可以簡寫成 #fff,所以存在 6 位與 3 位的情況,結合分組,正則可以這麼寫:

const regex = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;
regex.test("#e4393c"); //true
regex.test("#2b99ff"); //true

六 、 正則中的位置

注意,這裡所說的位置並不是我們遍歷陣列時所使用的索引概念,正則匹配的位置又稱為錨,是指相鄰字元之間的位置,比如下圖一個字元 hello 中,每個箭頭就是一個位置:

正規表示式中,匹配位置的字元又稱為錨,在文章開頭我們已經見過了 ^$ 兩個錨,其實你已經能猜到這兩個代表了開頭和結尾的兩個箭頭的位置,我們來驗證下位置的概念,看個簡單的例子:

const str = '聽風是風';
const regex = /^|$/g;
const result = str.replace(regex, '❀'); //❀聽風是風❀

可以看到兩個位置被替換成了花朵,此時字串的開頭位置與結尾位置發生了變化,開頭變成了花朵左邊,結尾位置變為第二朵花的右邊。

七、理解正則的錨

除了常用的 ^$ ,還有其它正則提前定義的錨,我們一一細說。

7.1 ^ 脫字元

**^** 脫字元:匹配開頭,在多行中匹配行開頭,比如:

const str = '聽風\n是風';
// 這裡的修飾符m表示匹配多行
const regex = /^/mg;
const result = str.replace(regex, '❀');

注意,正則結尾新增了一個 mg ,g(global)前面有解釋表示全域性匹配,表示一行從左到右完整匹配一遍;而m(more)表示多行匹配,mg就是多行全域性匹配,每行不管文字多長,都完全匹配一遍。

7.2 $ 美元符號

**$**美元符號:匹配結尾,在多行中匹配行尾。

7.3 \b 單詞邊界

**\b**單詞邊界:表示\w(單詞字元)與\W(非單詞字元)之間,\w(單詞字元)與 ^ (脫字元)之間,以及\w (單詞字元)與 $ 之間的位置,有點難理解,先看個例子:

const str = '[echo].123';
const regex = /\b/g;
const result = str.replace(regex, '❀'); //[❀echo❀].❀123❀

上面解析有點長,我們縮短點,\b表示\w\W^$之間的位置,而\w範圍是[0-9a-zA-Z_],那麼我們再看上面的例子,為了方便理解,我們拆分細說:

從左往右看,首先 ^[ 之間不滿足,再到 [e 之間,[ 是非單詞符而 e 是單詞符,滿足條件。

echo 由於四個字母都是單詞符,直接跳過,o] 又滿足了條件。

]. 之間很明顯不符合,再看 .1 又滿足了條件。

123都是單詞符,跳過,直接到了尾部 3$ ,滿足條件。

7.4 \B 非單詞邊界

**\B**非單詞邊界,意思與 \b 相反,匹配 \w\w\W\W^\W\W$ 之間的位置,還是上面的例子,我們改改匹配條件:

const str = '[echo].123';
const regex = /\B/g;
const result = str.replace(regex, '❀'); //❀[e❀c❀h❀o]❀.1❀2❀3

可以看到 ^ 與 [ 之間,以及單詞符與單詞符之間都滿足了條件。

7.5 正向先行斷言 (?=p)

**(?=p)**正向先行斷言:p表示一個匹配模式,即匹配所有滿足條件p的欄位的前面位置,有點繞口,看個簡單的例子:

const str = 'hello';
const regex = /(?=l)/g;
const result = "hello".replace(regex, '❀'); //he❀l❀lo

這裡就是先在字串中找到字母 l,然後再找到 l 前面的位置就是目標位置。為了方便,直接利用前面位置理解的圖,也就是這兩個紅框了:

7.6 負向先行斷言 (?!p)

那麼(?!p)(?=p)就是反過來的表示負(反)向先行斷言,還是上面的例子,我們改改條件,也就是下圖中綠框中的位置:

const str = 'hello';
const regex = /(?!l)/g;
const result = "hello".replace(regex, '❀'); //❀h❀ell❀o❀

如果不看這個圖,我不知道大家有沒有這樣的疑惑,不對啊,前面解釋 \b單詞邊界時,是從 ^脫字元 開始判斷的,脫字元也不滿足條件前面也應該加朵❀,最終輸出難道不應該是 ❀❀h❀ell❀o❀ 這樣嗎?o後面有❀ 是因為o後面還有個 不滿足條件所以才這樣啊。

記住,^和主動理解成兩個隱藏字元,我們現在是在匹配位置。

所以 /(?=l)/g 就是在找 l 前面的位置,而 /(?!l)/g 本質上來說就是找不是字母 l 前面的其它所有位置。

那為什麼 \b單詞邊界還能從 ^ 開始判斷呢,因為概念就包含了判斷\w^ 之間的位置,在判斷單詞邊界時,這兩個特殊位置就像兩個隱藏字元一樣,也成了判斷位置的條件。而在判斷(?!p)(?=p)時,主要p不是^,那麼此時的 ^$ 單純作為兩個位置,不會主動作為判斷條件參與判斷,這一點千萬不要弄混了!!!

7.7 正向後發斷言 (?<=p)

正向後發斷言**(?<=p)**與正向先行斷言類似都是匹配位置,區別在於正向先行斷言是匹配符合條件前的位置,而正向後發斷言是匹配符合條件後面的位置:

const str = 'hello';
const regex = /(?<=l)/g;
const result = "hello".replace(regex, '❀'); //'hel❀l❀o'

總結來說,先行就是位置在匹配結果的前面,後發就是位置在匹配結果的後面。

7.8 負向後發斷言 (?<!p)

這裡大家應該能秒懂呢,反正就是匹配與正向後發斷言完全相反的位置就對了(除了兩個l之後的所有位置),不好理解的概念,就基於好理解的概念進行取反。

const str = 'hello';
const regex = /(?<!l)/g;
const result = "hello".replace(regex, '❀'); //'❀h❀e❀llo❀'

八、位置的特性

到這裡你也許有點迷糊,本來就是找位置,結果 ^$ 作為位置應該是被找的物件,怎麼還反客為主成了找位置的條件了,位置和位置之間難道還有位置?正則裡還真是這樣。

我們可以將位置理解成一個空字元" ",就像上圖的箭頭,一個hello可以寫成這樣:

"hello" = "" + "h" + "" + "e" + "" + "l" + "" + "l" + "" + "o" + "";

它甚至還能寫成這樣,站在位置的角度,位置能是無限個:

"hello" = "" + "" + "hello"

以正則的角度,我們測試一個單詞是否為hello甚至可以寫成這樣:

const str = 'hello';
const regex = /^^^^^hello$$$$$$$$$$$$/g;
const result = regex.test(str); //true

當然這是我們站在匹配正則位置的角度抽象理解成這樣的,畢竟真的給字串加空格,字串就真的變樣了,\b單詞邊界會拿^$這兩個特殊位置作為判斷其它位置的條件,記住這一點就好了。

到這裡我們整理下位置(錨)的知識點:

九、分組和分支結構

9.1.分組基礎

在正則中,圓括號 () 表示一個分組,即括號內的正則是一個整體,表示一個子表示式。

我們知道 /ab+/ 表示匹配a加上一個或多個b的組合,那如果我們想匹配ab的多次組合呢?這裡就可以使用()包裹ab:

const str = 'abab  ababab aabbaa';
const regex = /(ab)+/g;
const result = str.match(regex); //["abab", "ababab", "ab"]

在分支中使用括號也是非常常見的,比如這個例子:

const str1 = 'helloEcho';
const str2 = 'helloKetty';
const regex = /^hello(Echo|Ketty)$/;
const result1 = regex.test(str1); //true
const result2 = regex.test(str2); //true

若我們不給分組加括號,此時的分支就變成了helloEcho和Ketty,很明顯這就是不是我們想要的。(TODO 注意正則尾部未加全域性匹配 g,如果加了第二個驗證為false,原因參考)。

9.2.分組參照

不知道大家在以往看正規表示式時有沒有留意到$1$2類似的字元,這類字元表示正則分組參照,對於正則使用是非常重要的概念。我們來看一個簡單的例子:

寫一個匹配 yyyy-mm-dd 的正則:

const regex = /(\d{4})-(\d{2})-(\d{2})/;

通過圖解我們能發現每個分組上面多了類似Group #1的分組編號,是不是已經聯想到$1相關的字元了呢?沒錯,這裡$1,$2正是對應的分組編號。

這裡我們提前科普兩個方法,一個是字串的match方法,一個是正則的exec方法,它們都用於匹配正則相符欄位,看個例子:

const result1 = '2019-12-19'.match(regex);
const result2 = regex.exec('2019-12-19');
console.log(result1);
console.log(result2);

可以看到雖然方法寫法不同,但結果一模一樣,我們來解釋下匹配的結果。

2019-12-19為正則最終匹配到的結果,"2019", "12", "19"這三個分別為group1,group2,group3三個分組匹配的結果,index: 0 為匹配結果的開始位置,input: "2019-12-19"為被匹配的輸入欄位,groups表示捕獲組的匹配結果,如果該欄位的值為undefined,則說明當前正規表示式沒有定義任何捕獲組。

我們可以通過$1,$2直接存取上面例子中各分組匹配到的結果。這裡我們展示一個完整的例子,在使用過一次正則後輸出RegExp物件,可以看到此物件上有眾多屬性,再通過 RegExp.$1 我們能直接拿到分組1的匹配結果:

const regex = /(\d{4})-(\d{2})-(\d{2})/;
const string = "2019-12-19";
//注意,這裡你得先使用一次正則,match test,replace等方法都行
regex.exec(string);
console.dir(RegExp);
console.log(RegExp.$1); // "2019" 
console.log(RegExp.$2); // "02" 
console.log(RegExp.$3); // "119"

現在我們要明白一個概念,$1 表示的就是Group #1的匹配結果,它就像一個變數,儲存了匹配到的實際值。那麼知道了這一點我們能做什麼呢?比如我們將 yyyy-mm-dd 修改為 dd/mm/yyy 格式。

const result = string.replace(regex, '$3/$2/$1'); // 19/12/2019
console.log(result);

這段程式碼等價於:

const result = string.replace(regex, function () {
  return RegExp.$3 + "/" + RegExp.$2 + "/" + RegExp.$1; // 19/12/2019
});

同時也等價於:

const result = string.replace(regex, function (match, year, month, day) {
  console.log(match, year, month, day);//2019-12-19 2019 12 19
  return day + "/" + month + "/" + year;//19/12/2019
});

所以看到這,大家也不要糾結第一個修改中'$3/$2/$1'欄位如何關聯上的分組匹配結果,知道是正則底層實現這麼去用就對了。

9.3 groups的bug

上文提到如果你的正則有定義分組,那麼匹配結果中的groups欄位將展示你分組以及對應的結果,但這其實會有bug:

const regex = /(\d{4})-(\d{2})-(\d{2})/;
const match = regex.exec('2022-03-11');
console.log(match.groups);// undefined

這段程式碼我們其實定義了3個分組,我們預期的groups欄位輸出應該是如下:

{
  "1": "2022",
  "2": "03",
  "3": "11"
}

這是因為,在ES6之前,JavaScript並沒有原生支援groups欄位,只有通過第三方庫或者自己手動解析正規表示式的分組才能得到捕獲組的匹配結果。

從ECMAScript 2018(ES9)開始,JavaScript引入了具名捕獲組和 groups 屬性,可以通過實現具名捕獲組來存取匹配的結果:

const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = regex.exec('2022-03-11');
console.log(match.groups);
// 輸出結果
{
  year: '2022',
  month: '03',
  day: '11'
}

十、反向參照

10.1 基本概念

除了像在上文API中那樣使用分組一樣,還有一個比較常見的就是在正則自身中使用分組,即代指之前已經出現過的分組,又稱為反向參照。我們通過一個例子來了解反向參照。

現在我們需要一個正則能同時匹配 2019-12-19 2016/12/19 2016.12.19 這三種欄位,正則我們可以這麼寫:

const regex = /\d{4}[-\/\.]\d{2}[-\/\.]\d{2}/;
regex.test('2019-12-19'); //true
regex.test('2019/12/19'); //true
regex.test('2019.12.19'); //true

通過圖解我們也知道這個正則其實有個問題,它甚至能匹配 2019-12.19 格式的欄位

regex.test('2019-12.19'); //true

那現在我們要求前後兩個分隔符一定相同時才能匹配成功怎麼做呢,這裡就需要使用反向參照,像這樣:

const regex = /\d{4}([-\/\.])\d{2}\1\d{2}/;
regex.test('2019-12-19'); //true
regex.test('2019/12/19'); //true
regex.test('2019.12.19'); //true
regex.test('2019-12.19'); //false
regex.test('2019/12-19'); //false

這裡的 \1 就是反向參照,除了代指前面出現過的分組([-/.])以外,在匹配時它的分支選擇也會與前者分組同步,說直白點,當前面分組選擇的是 - 時,後者也會選擇 - 然後才去匹配欄位。

10.2 參照巢狀

有個問題,括號也會存在巢狀的情況,如果多層巢狀反向參照會有什麼規則呢?我們來看個例子:

const regex = /^((\d)(\d(\d)))\1\2\3\4$/;
'1231231233'.match(regex); // true 
console.log( RegExp.$1 ); // 123 
console.log( RegExp.$2 ); // 1 
console.log( RegExp.$3 ); // 23 
console.log( RegExp.$4 ); // 3

通過例子與圖解應該不難理解,當存在多個括號巢狀時,從$1-$9的順序對應括號巢狀就是從外到內,從左到右的順序。

$1 對應的是 ((\d)(\d(\d)))$2 對應的是第一個 (\d)$3 對應的是 (\d(\d))$4 對應的是 $3 中的 (\d)

雖然我們在前面說的是$1-$9,準確來說,只要你的分組夠多,我們甚至能使用$1000都行,比如:

const regex = /(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)(l)\12+/;
const string = "abcdefghijkllll";
regex.test(string);//true
console.log(RegExp.$12);//undefined

可以看到 \12 確實指向了前面的(l)分組,但由於RegExp物件只提供了 $1-$9 的屬性,所以這裡我們輸出RegExp.$12undefined

還有一個問題,如果我們反向參照了不存在的分組會怎麼樣呢?很好理解,直接看個例子:

const regex = /\1\2\3/;
const string = "\1\2\3";
regex.test(string);//true
console.log(RegExp.$1);//為空

由於在\1前面不存在任何分組,所以這裡的\1\2\3就單純變成跳脫符\和三個數位 123 了,不會代指任何分組。

最後一點,分組後面如果有量詞,分組會記錄匹配的最後一次的資料,看個例子:

const regex = /(\w)+/;
const string = "abcde";
console.log(regex.exec(string));// ["abcde", "e", index: 0, input: "abcde", groups: undefined]

可以看到分組匹配的結果為e,也就是最後捕獲的資料,但index還是為 0,表示捕獲結果的開始位置。

所以在分組有量詞的情況下使用反向參照,它也會指向捕獲最大次數最後一次的結果。

const regex = /(\w)+\1/;
regex.test('abcdea');//false
regex.test('abcdee');//true

const regex1 = /(\w)+\1/;
regex1.test('abcdee');
console.log(RegExp.$1);//e

十一、非捕獲括號

在前面講述分組匹配以及反向參照時,我們都知道正則其實將分組匹配的結果都儲存起來了,不然也不會有反向參照這個功能,那麼如果我們不需要使用反向參照,說直白點就是不希望分組去記錄那些資料,怎麼辦呢?這裡就可以使用非捕獲括號了。

寫法很簡單,就是在正則條件加上 ?: 即可,例如(?:p)(?:p1|p2|p3),我們來做個試驗,看看最終match 輸出結果:

const regex = /(ab)+/;
const string = "ababa aab ababab";
string.match(regex);
console.log(RegExp.$1);//ab
javascript
const regex = /(?:ab)+/;
const string = "ababa aab ababab";
string.match(regex);
console.log(RegExp.$1);//空

我們分別在正則分組 ab前面加或不加 ?:,再分別輸出 RegExp.$1 ,可以看到普通分組記錄了最後一次的匹配結果,而非捕獲括號單純起到了匹配作用,並沒有去記錄匹配結果。

那麼到這裡,第三章知識全部解釋完畢,我們來做一個技術總結,大家可以參照下方思維導圖回顧知識點,看看是否還熟記於心頭。

最後留兩個思考題,請模擬實現 trim方法,即使用正則去除字串開頭與結尾的空白符。第二個,請將my name is echo每個單詞首字母轉為大寫。