從零開始學正則(三),理解正則的分組與反向引用


 壹 ❀ 引

我在從零開始學正則(二)這篇文章中介紹了正則位置的概念,以及匹配位置常用的六個錨,位置相關的知識雖然不多,不過理解起來還挺費勁。在文章結尾留下了兩個問題,一問寫一個正則將"12345678"變成千位分隔符表示法 "12,345,678";二問驗證密碼長度在6-12位之間,且至少包含數字,小寫字母與大寫字母其中兩種。

我們先來分析第一個問題,所謂千位分隔符其實就是從右往左每隔三位數加進一個逗號。有個問題,我們理解的正則匹配都是從左往右,怎么反過來?這里就可以使用 $ 匹配尾部,表示從尾部開始匹配,改變匹配方向。

從右往左,逗號都在3n(n>=1,使用量詞+表示)個數字前面,很明顯這是要匹配位置,這里可以使用 (?=p) 正向先行斷言解決,所以正則是這樣:

var str = '12345678';
var regex = /(?=(\d{3})+$)/g;
var result = str.replace(regex, ','); //12,345,678

怎么理解這個正則呢,(\d{3})+$ 是一個匹配條件,也就是找 (\d{3})+$ 前面的位置,其中 (\d{3}) 是一個組,這個組會出現1次或更多次,所以后面跟了+,又因為要從尾部開始匹配,所以還有個$,不難吧?

看着貌似沒問題,但如果我們要將123456789改為千位分隔符就出現問題了:

var str = '123456789';
var regex = /(?=(\d{3})+$)/g;
var result = str.replace(regex, ','); //,123,456,789

很遺憾,如果被處理的字符串剛好是三的倍數,就會出現頭部多一個逗號的情況,這不是我們想要的,怎么辦呢?其實可以使用 (?!p) 負向先行斷言表示除了開頭的位置,開頭的位置是誰?當然是脫字符^啦。於是我們加個條件:

var str = '123456789';
var regex = /(?!^)(?=(\d{3})+$)/g;
var result = str.replace(regex, ','); //123,456,789

那么現在正則的意思就是,匹配不是開頭的且是三倍數前面的位置,這里(?!^)和(?=(\d{3})+$)是兩個組,表示並列關系,就像JavaScript中的&&,注意不要與管道符 | 弄混淆了,管道符表示分支,即滿足其一即可,就像JavaScript中的 ||

我們來接着分析第二個問題,驗證密碼長度在6-12,且必須包含大小寫字母數字其中兩種。如果只是6-12位大小寫字母與數字都還好,只需這樣:

var str = "abcdef";
var regex = /^[0-9a-zA-Z]{6,12}$/g;
var result = regex.test(str); //true

那么我們如何驗證字符串是否包含一個數字呢,這里直接上結論,使用 (?=.*[0-9]) 可以做到,我來詳細解釋下意思:

首先 (?=.*[0-9]) 的本意是看能不能找到.*[0-9]前面的位置,如果能找到那說明至少有一個.*[0-9],所以我們只需要明白.*[0-9]是什么意思就好了。

[0-9] 好理解,0-9之間的任意一個數字,那為什么不直接寫成 (?=[0-9]) 呢,如果說單純判斷有沒有數字,准確來說 (?=[0-9]) 是沒問題的,我們來測試一下:

var regex = /(?=[0-9])/g;
regex.test('1') //true
regex.test('a1') //true
regex.test('❀1❀') //true
regex.test('a') //false

但現在要求是至少包含兩種數字和大/小寫字母其中兩種,我們假設是包含數字和小寫字母,按常理來說正則應該是這樣,我們測試下:

var regex = /(?=[0-9])(?=[a-z])/g;
regex.test('1a') //false
regex.test('aa1') //false
regex.test('11a') //false

結果發現全部為false,因為此時正則表達式是希望找一個既在數字前又在小寫字母前的位置。或者反過來理解,當同時存在數字和小寫字母時,一定有一個位置同時在數字和字母前。理解這個關鍵點,問題就迎刃而解了。

我們先單純以1a為例,哪個位置既在1前面,又在字母a前面?毫無疑問就是 ^,所以我們改寫正則:

var regex = /(?=[0-9])(?=1[a-z])/g;
regex.test('1a') //true

你看,這不就為true了。再看例子aa1,這個位置有兩個,可以是 a 與 a1 中間,也可以是 ^,比如我們以查a與a1中間的位置為例:

var regex = /(?=a[0-9])(?=[a-z])/g;
regex.test('aa1') //true

或者以查 ^ 為例:

var regex = /(?=aa[0-9])(?=[a-z])/g;
regex.test('aa1') //true

你看,只要我們能找到共同位置,就表示同時存在兩種字符。

但有個問題,這幾個例子都是我們寫死的,字符結構固定。實際開發中我們也不知道數字前面有沒有字符,字母前有沒有數字,有幾個數字,怎么辦呢?只要加上 .* 就好了,. 表示通配符,*表示量詞{0,},即任意字符出現任意次數。

我們再看 /(?=.*[0-9])(?=.*[a-z])/g,這不就是找一個既在數字前又在小寫字母前的正則嗎。那么我們再結合6-12位長度,結合起來就是這樣:

var regex = /(?=.*[0-9])(?=.*[a-z])^[0-9a-zA-Z]{6,12}$/g;
regex.test('1aaaaa') //true
regex.test('a12345') //true
regex.test('aaaaaa') //true
regex.test('111111') //true

為什么 (?=.*[0-9])(?=.*[a-z]) 是寫在 ^ 前面?在正則第二章介紹中我們已經知道位置其實是很抽象的東西,如果用空字符""表示位置,它可以是無數個。所以我們可以理解為在 ^ 前面還有無數個看不見的位置,那么只要你的字符同時擁有小寫字母和數字,就一定能在開頭位置 ^ 前找到這個位置,我們將上面的正則抽象成js語句,它更像這樣:

if(位置===(?=.*[0-9])&& 位置===(?=.*[a-z])){
  ^[0-9a-zA-Z]{6,12}$;
};

這只是數字和小寫字母的情況,我們還得結合數字和大寫字母,小寫字母和大字母,所以最終正則就是這樣:

var regex =/((?=.*[0-9])(?=.*[a-z])|(?=.*[0-9])(?=.*[A-Z])|(?=.*[a-z])(?=.*[AZ]))^[0-9A-Za-z]{6,12}$/

除了使用正向先行斷言,我們還可以使用負向先行斷言,即輸入字段不能同時為數字,同時為小寫字母,同時為大寫字母,正則為:

var regex = /(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/;

關於這條正則我只能貼出來給大家看看,確實有點無力解釋,請教了公司幾個資歷老的員工,都無法解答。我突然明白原書推薦第一遍不求甚解的讀是啥意思了,這兩道題我光分析,查資料整理用了半天....還是因為我太菜的緣故吧。若有有緣人看到,能幫我解答那是最好不過了。

那么關於題目先分析到這里,不知道大家有沒有發現,上述題目解答中對於分組括號使用特別頻繁,我在解答題目時也發現像量詞+*寫在括號內和括號外傳達的意思完全不同,那么本篇主要對於正則表達式的括號使用展開分析。

說在前面,正則學習系列文章均為我閱讀 老姚《JavaScript正則迷你書》的讀書筆記,文中所有正則圖解均使用regulex制作。那么本文開始!

 貳 ❀ 分組和分支結構

1.分組基礎

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

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

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

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

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

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

2.分組引用

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

寫一個匹配 yyyy-mm-dd 的正則(這里先不考慮月不超過12之類的情況)

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

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

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

var result1 = '2019-12-19'.match(regex);
var 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表示一個捕獲組數組或undefined(如果沒有定義命名捕獲組)。

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

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var 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 格式。

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

這段代碼等價於:

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

同時也等價於:

var 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'字段如何關聯上的分組匹配結果,知道是正則底層實現這么去用就對了。

 叄 ❀ 反向引用

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

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

var 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

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

var 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 就是反向引用,除了代指前面出現過的分組([-\/\.])以外,在匹配時它的分支選擇也會與前者分組同步,說直白點,當前面分組選擇的是 - 時,后者也會選擇 - 然后才去匹配字段。

有個問題,括號也會存在嵌套的情況,如果多層嵌套反向引用會有什么規則呢?我們來看個例子:

var 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都行,比如:

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

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

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

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

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

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

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

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

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

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

var regex1 = /(\w)+\1/;
regex.test('abcdee');
console.log(RegExp.$1);//2

 肆 ❀ 非捕獲括號

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

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

var regex = /(ab)+/;
var string = "ababa aab ababab";
string.match(regex);
console.log(RegExp.$1);//ab

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

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

 伍 ❀ 總結

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

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

那么本文就寫到這里了。我要開始學習第四章了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM