引言
JS 正則表達式是 JS 學習過程中的一大難點,繁雜的匹配模式足以讓人頭大,不過其復雜性和其學習難度也賦予了它強大的功能。文章從 JS 正則表達式的正向前瞻說起,實現否定匹配的案例。本文適合有一定 JS 正則表達式基礎的同學,如果對正則表達式並不了解,還需先學習基礎再來觀摩這門否定大法。
一、標簽過濾需求
不知道大家在寫JS有沒有遇到過這樣的情況,當你要處理一串字符串時,需要寫一個正則表達式來匹配當中不是 XXX 的文本內容。聽起來好像略有些奇怪,匹配不是 XXX 的內容,不是 XXX 我匹配它干嘛啊,我要啥匹配啥不就完了。你還別說,這個玩意還真的有用,不管你遇沒遇到過,反正我是遇到了。具體的需求例如:當你收到一串HTML代碼,需要對這一串HTML代碼過濾,將里面所有的非<p>標簽都改為<p>。這里肯定有不少同學就要嫌棄了,“將所有標簽都改為<p>,那就把任意標簽都改為<p>不就完了?”,於是乎一行代碼拍腦袋而生:
1 var str = '<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>'; 2 var reg = /<(\/?).*?>/g; 3 var newStr = str.replace(reg, "<$1p>"); 4 console.log(newStr);//<p>,<p>,<p>,<p>,</p>,</p>,</p>,</p>
注意這個方法中有一個引用符 “$1” ,這個的意思引用正則的表達式的第1個分組,可以用$N來表示在正則表達式中的第N個捕獲的引用。就那上面的例子來說,"(\/?)"這個一個表達式的含義是,"\/"這個字符出現0次或者1次,而$1這個引用呢就相當於和“\/”這個字符門當戶對的大閨女,她已下定決心此生非"\/"不嫁。所以當匹配到有一個“\/”的時候,$1這個引用就把它捕獲下來,從現在起,你的就是我的,我的就是你的啦,因此$1等價於"(\/?)"所匹配到的字符;反之如果沒有匹配到"\/"這個字符,那$1這個引用就得空守閨房,獨立熬過一個又一個漫長的夜晚,因為它內心極度的空虛,所以$1就等價於""(也就是空串)。
這里先聊了聊引用和捕獲的概念,因為后面還會用到它。那么話說回來,剛才那一串正則,不是已經完美的實現了需求了嗎?還研究什么否定匹配啊?各位看官別急,且聽小生慢慢道來。我們都知道,需求這個東西,肯定是會改嘀(◐ˍ◑)。現在改一改需求:當你收到一串HTML代碼,需要對這一串HTML代碼過濾,將里面所有的非<p>或者<div>標簽都改為<p>。WTF?這算哪門子需求?話說我當時也是這種反應。我們現在分析一下這個需求到底要干嘛,也就是說,保留原HTML代碼中的<p>和<div>,將其他標簽統一修改為<p>。咦...這下可不好弄了,剛才那串代碼看上去貌似行不通了。所以說這時候就只能用排除法了,排除掉<p>和<div>,替換掉其他的標簽。那么問題也就來了,如何排除?
二、正則前瞻表達式
在正則表達式當中有個東西叫做前瞻,有的管它叫零寬斷言:
表達式 | 名稱 | 描述 |
(?=exp) | 正向前瞻 | 匹配后面滿足表達式exp的位置 |
(?!exp) | 負向前瞻 | 匹配后面不滿足表達式exp的位置 |
(?<=exp) | 正向后瞻 | 匹配前面滿足表達式exp的位置(JS不支持) |
(?<!exp) | 負向后瞻 | 匹配前面不滿足表達式exp的位置(JS不支持) |
由於 JS 原生不支持后瞻,所以這里就不研究它了。我們來看看前瞻的作用:
1 var str = 'Hello, Hi, I am Hilary.'; 2 var reg = /H(?=i)/g; 3 var newStr = str.replace(reg, "T"); 4 console.log(newStr);//Hello, Ti, I am Tilary.
在這個DEMO中我們可以看出正向前瞻的作用,同樣是字符"H",但是只匹配"H"后面緊跟"i"的"H"。就相當於有一家公司reg,這時候有多名"H"人員前來應聘,但是reg公司提出了一個硬條件是必須掌握"i"這項技能,所以"Hello"就自然的被淘汰掉了。
那么負向前瞻呢?道理是相同的:
1 var str = 'Hello, Hi, I am Hilary.'; 2 var reg = /H(?!i)/g; 3 var newStr = str.replace(reg, "T"); 4 console.log(newStr);//Tello, Hi, I am Hilary.
在這個DEMO中,我們把之前的正向前瞻換成了負向前瞻。這個正則的意思就是,匹配"H",且后面不能跟着一個"i"。這時候"Hello"就可以成功的應聘了,因為reg公司修改了他們的招聘條件,他們說"i"這門技術會有損公司的企業文化,所以我們不要了。
三、前瞻的非捕獲性
說到這里,讓我們回到最初的那個需求,讓我們先用負向前瞻來實現第一個需求:將所有非<p>標簽替換為<p>。話說同學們剛學完了負向前瞻,了解到了JS的博大精深,心中暗生竊喜,提筆一揮:
1 var str = '<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>'; 2 var reg = /<(\/?)(?!p)>/g; 3 var newStr = str.replace(reg, "<$1p>"); 4 console.log(newStr);//<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>
What?為什么不起作用呢?說好的否定大法呢?這里就得聊一聊前瞻的一個特性,前瞻是非捕獲性分組,什么玩意是非捕獲性分組呢?還記得前面那位非"\/"不嫁的大閨女$1嗎,人家為什么那么一往情深,是因為她早已將"\/"的心捕獲了起來,而前瞻卻是非捕獲性分組,也就是你捕獲不到人家。也就是說無法通過引用符"\n"或者"$n"來對其引用:
1 var str = 'Hello, Hi, I am Hilary.'; 2 var reg = /H(?!i)/g; 3 var newStr = str.replace(reg, "T$1"); 4 console.log(newStr);//T$1ello, Hi, I am Hilary.
注意其中輸出的語句,前面我們可以看到,如果引用符沒有匹配到指定的字符,那么就會顯示空串"",可是這里是直接顯示了整個引用符"$1"。這是因為前瞻表達式根本就沒有捕獲,沒有捕獲也就沒有引用。
非捕獲性是前瞻的一個基本特征,前瞻的另外一個特性是不吃字符,意思就是前瞻的作用只是為了匹配滿足前瞻表達式的字符,而不匹配前瞻本身。也就是說前瞻不會修改匹配位置,這么說我自己都覺得晦澀,我們還是來看看代碼吧︽⊙_⊙︽:
1 var str = 'Hello, Hi, I am Handsome Hilary.'; 2 var reg = /H(?!i)e/g; 3 var newStr = str.replace(reg, "T"); 4 console.log(newStr);//Tllo, Hi, I am Handsome Hilary.
注意觀察輸出的字符串,前瞻的作用僅僅是匹配出滿足前瞻條件的字符"H",匹配出了"Hello"和"Handsome"當中的H,但同時前瞻不會吃字符,也就是不會改變位置,接下來還是會緊接着"H"開始繼續往下匹配,這時候匹配條件是"e",於是"Hello"中的"He"就匹配成功了,而"Handsome"中的"Ha"則匹配失敗。
1. /H(?!i)/g --> Hello, Hi, I am Handsome Hilary. 2. /H(?!i)e/g --> Hello, Hi, I am Handsome Hilary.
四、用前瞻實現標簽過濾
既然前瞻是非捕獲性的,而且還不吃字符,那么了解到這些特征后我們現在終於可以完成我們的需求了吧?因為它不吃字符,所以具體的標簽字符還得由我們自己來吃:
1 var str = '<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>'; 2 var reg = /<(\/?)(?!p|\/p).*?>/g; 3 var newStr = str.replace(reg, "<$1p>"); 4 console.log(newStr);//<p>,<p>,<p>,<p>,</p>,</p>,</p>,</p>
聊了這么半天,終於解決了咱們的第一個需求,注意當中的".*?",雖然這里匹配的是任意字符,但是別忘了,有了前面的負向前瞻,我們匹配到的都是后面不會緊跟着"p"或者"/p"的字符"<"。
/<(?!p|\/p)/g --> <div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>
注意在這里用了一個管道符"|"來匹配"\/p",雖然前面已經有了"(\/?)"匹配結束符,但是切記這里的分組選項不能省略,因為這里的量詞是可以出現0次。我們來試想一下如果用"/<(\/?)(?!p).*?>/g"來匹配"</p>"這個標簽,當量次匹配到"/"的時候,發現可以匹配,便記錄下來,然后對"/"進行前瞻判斷,但是后面卻接着一個"p"於是不能匹配,丟掉;注意這時"(\/?)"的匹配字符是0個,於是乎轉而對"<"進行前瞻判斷,這里的"<"后面緊接着的是"/p"而不是"p",於是乎成功匹配,所以這個標簽會被替換掉;而且,由於之前的分組匹配到的字符是0個,也就是沒有匹配到字符,所以后面的引用是個空串。
1 var str = '<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>'; 2 var reg = /<(\/?)(?!p).*?>/g; 3 var newStr = str.replace(reg, "<$1p>"); 4 console.log(newStr);//<p>,<p>,<p>,<p>,</p>,</p>,<p>,</p>
完成了第一個過濾需求,那么第二個過濾需求也就自然而然的完成了,這時候,就算有那么五六個標簽需要保留,咱們也不用怕了:
1 var str = '<div>,<p>,<h1>,<span>,</span>,</h1>,</p>,</div>'; 2 var reg = /<(\/?)(?!p|\/p|div|\/div).*?>/g; 3 var newStr = str.replace(reg, "<$1p>"); 4 console.log(newStr);//<div>,<p>,<p>,<p>,</p>,</p>,</p>,</div>
總結
JS 的正向前瞻只是正則表達式當中一部分,沒相當就這么一部分還有着這么多的奧妙呢。
在使用正向前瞻,我們需要注意的是:
- 前瞻是非捕獲性的:其特征是無法引用。
- 前瞻不消耗字符:前瞻只匹配滿足前瞻表達式的字符,而不匹配其本身。
話說,咱們的需求就到這了嗎?真的就完了嗎?同學們覺得過癮不?有些同學覺得可能差不多了,需要消化一段時間,但是絕對有那么一部分同學還完全沒過癮呢,沒關系,最后留給大家一道思考題,截止到我寫這篇博客為止,我還沒有想出一個解決辦法呢(ง •_•)ง。
需求如下:當你收到一串HTML代碼,需要對這一串HTML代碼過濾,將里面所有的非<p>或者<div>標簽都改為<p>,並且保留所有標簽的樣式,要求只使用一個正則表達式,例如:
//輸入 var input = '<div class="beautiful">,<p class="provocative">,<h1 class="attractive" id="header">,<span class="sexy">,</span>,</h1>,</p>,</div>'; //輸出 var output = '<div class="beautiful">,<p class="provocative">,<p class="attractive" id="header">,<p class="sexy">,</p>,</p>,</p>,</div>';
如果你有好的解決方案,歡迎在評論區留言,大家一起學習。
參考文獻:
devinran —— 《相愛相殺——正則與瀏覽器的愛恨情仇》
Barret Lee —— 《進階正則表達式》