天天看點

js 正則文法

原文:一次性搞懂javascript正規表達式之文法

看完原文,對正則中以前一知半解的捕獲組與非捕獲組、零寬斷言有了更深的了解。很感謝原文作者~~

普通字元

當我們寫

a

的時候,我們指的就是

a

;當我們寫

'hello 😀 regex'.match(/😀/);
// ["😀", index: 6, input: "hello 😀 regex", groups: undefined]
           

這就是普通字元,它在正則中的含義就是檢索它本身。除了正則規定的部分字元外,其餘的都是普通字元,包括各種人類語言,包括emoji,隻要能夠表達為字元串。

開始與結束

^

字元,它在正則中屬于元字元,通常代表的意義是文本的開始。說通常是因為當它在字元組中

[^abc]

另有含義,表示取反。

什麼叫文本的開始?就是如果它是正則主體的第一個符号,那緊跟着它的字元必須是被比對文本的第一個字元。

'regex'.match(/^r/);
// ["r", index: 0, input: "regex", groups: undefined]
           

問題來了,如果^不是正則的第一個符号呢?

'regex'.match(/a^r/);
// null
           

是以,關于它有三點需要注意:

  • 作為比對文本開始元字元的時候必須是正則主體的第一個符号,否則正則無效。
  • 它比對的是一個位置,而不是具體的文本。
  • 它在其他規則中有另外的含義。

$

字元與

^

正好相反。它代表文本的結束,并且沒有其他含義(其實是有的,但不是在正則主體内)。同樣,它必須是正則主體的最後一個符号。

'regex'.match(/x$/);
// ["x", index: 4, input: "regex", groups: undefined]
           

^

$

特殊的地方在于它比對的是一個位置。位置不像字元,它看不見,是以更不容易了解。

轉義

我們現在已經知道

$

比對文本的結束位置,它是元字元。但是如果我想比對

$

本身呢?比對一個美元符号的需求再常見不過了吧。是以我們得将它貶為庶民。

\

反斜杠就是幹這個的。

'price: $3.6'.match(/\$[0-9]+\.[0-9]+$/);
// ["$3.6", index: 7, input: "price: $3.6", groups: undefined]
           

你可以認為

\

也是一個元字元,它跟在另一個元字元後面,就能還原它本來的含義。

如果有兩個

\

呢?那就是轉義自身了。如果有三個

\

呢?我們得分成兩段去了解。以此類推。

普通字元前面跟了一個

\

是什麼效果?首先它們是一個整體,然後普通字元轉義後還是普通字元。

帶反斜杠的元字元

一般來說,普通字元前面帶反斜杠還是普通字元,但是有一些普通字元,帶反斜杠後反而變成了元字元。

要怪隻能怪計算機領域的常用符号太少了。

元字元 含義
\b 比對一個單詞邊界(boundary)
\B 比對一個非單詞邊界
\d 比對一個數字字元(digit)
\D 比對一個非數字字元
\s 比對一個空白字元(space)
\S 比對一個非空白字元
\w 比對一個字母或者一個數字或者一個下劃線(word)
\W 比對一個字母、數字和下劃線之外的字元

大寫代表反義。

\b 元字元

\b

比對的也是一個位置,而不是一個字元。單詞和空格之間的位置,就是所謂單詞邊界。

'hello regex'.match(/\bregex$/);
// ["regex", index: 6, input: "hello regex", groups: undefined]

'hello regex'.match(/\Bregex$/);
// null
           

所謂單詞邊界,對中文等其他語言是無效的。

'jiangshuying gaoyuanyuan huosiyan'.match(/\bgaoyuanyuan\b/);
// ["gaoyuanyuan", index: 13, input: "jiangshuying gaoyuanyuan huosiyan", groups: undefined]

'江疏影 高圓圓 霍思燕'.match(/\b高圓圓\b/);
// null
           

是以

\b

翻譯一下就是

^\w|\w$|\W\w|\w\W

\d 元字元

\d

比對一個數字,注意,這裡的數字不是指JavaScript中的數字類型,因為文本全是字元串。它指的是代表數字的字元。

'123'.match(/\d/);
// ["1", index: 0, input: "123", groups: undefined]
           

\s 元字元

\s

比對一個空白字元。

這裡需要解釋一下什麼是空白字元。

空白字元不是空格,它是空格的超集。很多人說它是

\f\n\r\t\v

的總和,其中

\f

是換頁符,

\n

是換行符,

\r

是回車符,

\t

是水準制表符,

\v

是垂直制表符。是這樣麼?

'a b'.match(/\w\s\w/);
// ["a b", index: 0, input: "a b", groups: undefined]

'a b'.match(/\w\f\w/);
// null

'a b'.match(/\w\n\w/);
// null

'a b'.match(/\w\r\w/);
// null

'a b'.match(/\w\t\w/);
// null

'a b'.match(/\w\v\w/);
// null

'a b'.match(/\w \w/);
// ["a b", index: 0, input: "a b", groups: undefined]
           

其實正确的寫法是

空格\f\n\r\t\v

的總和,集合裡面包含一個空格,可千萬别忽略了。诶,難道空格在正則中的寫法就是

空一格

麼,是的,就是這樣随意。

這個集合中很多都是不可列印字元,估計隻有

\n

是我們的老朋友。是以,如果不需要區分空格和換行的話,那就大膽的用

\s

吧。

\w 元字元

\w

比對一個字母或者一個數字或者一個下劃線。為什麼要将它們放一起?想一想JavaScript中的變量規則,包括很多應用的使用者名都隻能是這三樣,是以把它們放一起挺友善的。

不過要注意,字母指的是26個英文字母,其他的不行。

'正則'.match(/\w/);
// null
           

負陰抱陽

如果我們将大寫和小寫的帶反斜杠的元字元組合在一起,就能比對任何字元。是的,不針對任何人。

'@regex'.match(/[\s\S]/);
// ["@", index: 0, input: "@regex", groups: undefined]
           

方括号的含義我們先按下不表。

道生一

.

在正則中的含義仙風道骨,它比對換行符之外的任意單個字元。

如果文本不存在換行符,那麼

.

[\b\B]

[\d\D]

[\s\S]

[\w\W]

是等價的。

如果文本存在換行符,那麼

(.|\n)

[\b\B]

[\d\D]

[\s\S]

[\w\W]

'@regex'.match(/./);
// ["@", index: 0, input: "@regex", groups: undefined]
           

量詞

前面我們一直在強調,一個元字元隻比對一個字元。即便強大如.它也隻能比對一個。

那比對

gooooogle

的正則是不是得寫成

/gooooogle/

呢?

正則冷笑,并向你發射一個蔑視。

如果比對的模式有重複,我們可以聲明它重複的次數。

? 重複零次或者一次
  • | 重複一次或者多次,也就是至少一次
  • | 重複零次或者多次,也就是任意次數

    {n} | 重複n次

    {n,} | 重複n次或者更多次

    {n,m} | 重複n次到m次之間的次數,包含n次和m次

有三點需要注意:

  • ?

    在諸如比對http協定的時候非常有用,就像這樣:

    /http(s)?/

    。它在正則中除了是量詞還有别的含義,後面會提到。
  • 我們習慣用

    /.*/

    來比對若幹對我們沒有價值的文本,它的含義是

    若幹除換行符之外的字元

    。比如我們需要文本兩頭的格式化資訊,中間是什麼無所謂,它就派上用場了。不過它的性能可不好。
  • {n,m}

    之間不能有空格,空格在正則中是有含義的。

關于量詞最令人困惑的是:它重複什麼?

它重複緊貼在它前面的某個集合。第一點,必須是緊貼在它前面;第二點,重複一個集合。最常見的集合就是一個字元,當然正則中有一些元字元能夠将若幹字元變成一個集合,後面會講到。

'gooooogle'.match(/go{2,5}gle/);
// ["gooooogle", index: 0, input: "gooooogle", groups: undefined]
           

如果一個量詞緊貼在另一個量詞後面會怎樣?

'gooooogle'.match(/go{2,5}+gle/);
// Uncaught SyntaxError: Invalid regular expression: /go{2,5}+gle/: Nothing to repeat
           

貪婪模式與非貪婪模式

前面提到量詞不能緊跟在另一個量詞後面,馬上要👋👋打臉了。

'https'.match(/http(s)?/);
// ["https", "s", index: 0, input: "https", groups: undefined]

'https'.match(/http(s)??/);
// ["http", undefined, index: 0, input: "https", groups: undefined]
           

然而,我的臉是這麼好打的?

緊跟在

?

後面的

?

它不是一個量詞,而是一個模式切換符,從貪婪模式切換到非貪婪模式。

貪婪模式在正則中是預設的模式,就是在既定規則之下比對盡可能多的文本。因為正則中有量詞,它的重複次數可能是一個區間,這就有了取舍。

緊跟在量詞之後加上

?

就可以開啟非貪婪模式。怎麼省事怎麼來。

這裡的要點是,

?

必須緊跟着量詞,否則的話它自己就變成量詞了。

字元組

正則中的普通字元隻能比對它自己。如果我要比對一個普通字元,但是我不确定它是什麼,怎麼辦?

'grey or gray'.match(/gr[ae]y/);
// ["grey", index: 0, input: "grey or gray", groups: undefined]
           

方括号在正則中表示一個區間,我們稱它為字元組。

首先,字元組中的字元集合隻是所有的可選項,最終它隻能比對一個字元。

然後,字元組是一個獨立的世界,元字元不需要轉義。

'$'.match(/[$&@]/);
// ["$", index: 0, input: "$", groups: undefined]
           

最後,有兩個字元在字元組中有特殊含義。

^

在字元組中表示取反,不再是文本開始的位置了。

'regex'.match(/[^abc]/);
// ["r", index: 0, input: "regex", groups: undefined]
           

如果我就要

^

呢?前面已經講過了,轉義。

-

本來是一個普通字元,在字元組中搖身一變成為連字元。

'13'.match(/[1-9]3/);
// ["13", index: 0, input: "13", groups: undefined]
           

連字元的意思是比對範圍在它的左邊字元和右邊字元之間。

如果我這樣呢?

'abc-3'.match(/[0-z]/);
// ["a", index: 0, input: "abc-3", groups: undefined]

'xyz-3'.match(/[0-c]/);
// ["3", index: 4, input: "xyz-3", groups: undefined]

'xyz-3'.match(/[0-$]/);
// Uncaught SyntaxError: Invalid regular expression: /[0-$]/: Range out of order in character class
           

發現什麼了沒有?隻有兩種字元是可以用連字元的:英文字母和數字。而且英文字母可以和數字連起來,英文字母的順序在後面。這和撲克牌

1 2 3 4 5 6 7 8 9 10 J Q K

是一個道理。

捕獲組與非捕獲組

我們已經知道量詞是怎麼回事了,我們也知道量詞隻能重複緊貼在它前面的字元。

如果我要重複的是一串字元呢?

'i love you very very very much'.match(/i love you very +much/);
// null

'i love you very very very much'.match(/i love you v+e+r+y+ +much/);
// null
           

這樣肯定是不行的。是時候請圓括号出山了。

'i love you very very very much'.match(/i love you (very )+much/);
// ["i love you very very very much", "very ", index: 0, input: "i love you very very very much", groups: undefined]
           

圓括号的意思是将它其中的字元集合打包成一個整體,然後量詞就可以操作這個整體了。這和方括号的效果是完全不一樣的。

而且預設的,圓括号的比對結果是可以捕獲的。

正則内捕獲

現在我們有一個需求,比對

<div>

标簽。

'<div>hello regex</div>'.match(/<div>.*<\/div>/);
// ["<div>hello regex</div>", index: 0, input: "<div>hello regex</div>", groups: undefined]
           

這很簡單。但如果我要比對的是任意标簽,包括自定義的标簽呢?

'<App>hello regex</App>'.match(/<([a-zA-Z]+)>.*<\/\1>/);
// ["<App>hello regex</App>", "App", index: 0, input: "<App>hello regex</App>", groups: undefined]
           

這時候就要用到正則的捕獲特性。正則内捕獲使用

\數字

的形式,分别對應前面的圓括号捕獲的内容。這種捕獲的引用也叫反向引用。

我們來看一個更複雜的情況:

'<App>hello regex</App><p>A</p><p>hello regex</p>'.match(/<((A|a)pp)>(hello regex)+<\/\1><p>\2<\/p><p>\3<\/p>/);
// ["<App>hello regex</App><p>A</p><p>hello regex</p>", "App", "A", "hello regex", index: 0, input: "<App>hello regex</App><p>A</p><p>hello regex</p>", groups: undefined]
           

如果有嵌套的圓括号,那麼捕獲的引用是先遞歸的,然後才是下一個頂級捕獲。

正則外捕獲

'@abc'.match(/@(abc)/);
// ["@abc", "abc", index: 0, input: "@abc", groups: undefined]

RegExp.$1;
// "abc"
           

沒錯,

RegExp

就是構造正則的構造函數。如果有捕獲組,它的執行個體屬性

$數字

會顯示對應的引用。

如果有多個正則呢?

'@abc'.match(/@(abc)/);
// ["@abc", "abc", index: 0, input: "@abc", groups: undefined]

'@xyz'.match(/@(xyz)/);
// ["@xyz", "xyz", index: 0, input: "@xyz", groups: undefined]

RegExp.$1;
// "xyz"
           

RegExp

構造函數的引用隻顯示最後一個正則的捕獲。

另外還有一個字元串執行個體方法也支援正則捕獲的引用,它就是

replace

方法。

'hello **regex**'.replace(/\*{2}(.*)\*{2}/, '<strong>$1</strong>');
// "hello <strong>regex</strong>"
           

實際上它才是最常用的引用捕獲的方式。

捕獲命名

這是ES2018的新特性。

使用

\數字

引用捕獲必須保證捕獲組的順序不變。現在開發者可以給捕獲組命名了,有了名字以後,引用起來更加确定。

'<App>hello regex</App>'.match(/<(?<tag>[a-zA-Z]+)>.*<\/\k<tag>>/);
// ["<App>hello regex</App>", "App", index: 0, input: "<App>hello regex</App>", groups: {tag: "App"}]
           

在捕獲組内部最前面加上

?<key>

,它就被命名了。使用

\k<key>

文法就可以引用已經命名的捕獲組。

是不是很簡單?

通常情況下,開發者隻是想在正則中将某些字元當成一個整體看待。捕獲組很棒,但是它做了額外的事情,肯定需要額外的記憶體占用和計算資源。于是正則又有了非捕獲組的概念。

'@abc'.match(/@(abc)/);
// ["@abc", "abc", index: 0, input: "@abc", groups: undefined]

'@abc'.match(/@(?:abc)/);
// ["@abc", index: 0, input: "@abc", groups: undefined]
           

隻要在圓括号内最前面加上

?:

辨別,就是告訴正則引擎:我隻要這個整體,不需要它的引用,你就别費勁了。從上面的例子也可以看出來,

match

方法傳回的結果有些許不一樣。

個人觀點:我覺得正則的捕獲設計應該反過來,預設不捕獲,加上

?:

辨別後才捕獲。因為大多數時候開發者是不需要捕獲的,但是它又懶得加

?:

辨別,會有些許性能浪費。

分支

有時候開發者需要在正則中使用

或者

'高圓圓'.match(/陳喬恩|高圓圓/);
// ["高圓圓", index: 0, input: "高圓圓", groups: undefined]
           

|

就代表

或者

。字元組其實也是一個多選結構,但是它們倆有本質差別。字元組最終隻能比對一個字元,而分支比對的是左邊所有的字元或者右邊所有的字元。

我們來看一個例子:

'我喜歡高圓圓'.match(/我喜歡陳喬恩|高圓圓/);
// ["高圓圓", index: 3, input: "我喜歡高圓圓", groups: undefined]
           

因為

|

是将左右兩邊一切兩半,然後比對左邊或者右邊。是以上面的正則顯然達不到我們想要的效果。這個時候就需要一個東西來縮小分支的範圍。诶,你可能已經想到了:

'我喜歡高圓圓'.match(/我喜歡(?:陳喬恩|高圓圓)/);
// ["我喜歡高圓圓", index: 0, input: "我喜歡高圓圓", groups: undefined]
           

沒錯,就是圓括号。

零寬斷言

正則中有一些元字元,它不比對字元,而是比對一個位置。比如之前提到的

^

$

^

的意思是說這個位置應該是文本開始的位置。

正則還有一些比較進階的比對位置的文法,它比對的是:在這個位置之前或之後應該有什麼内容。

零寬(zero-width)是什麼意思?指的就是它比對一個位置,本身沒有寬度。

斷言(assertion)是什麼意思?指的是一種判斷,斷言之前或之後應該有什麼或應該沒有什麼。

零寬肯定先行斷言

所謂的肯定就是判斷有什麼,而不是判斷沒有什麼。

而先行指的是向前看(lookahead),斷言的這個位置是為前面的規則服務的。

文法很簡單:圓括号内最左邊加上

?=

辨別。

'CoffeeScript JavaScript javascript'.match(/\b\w{4}(?=Script\b)/);
// ["Java", index: 13, input: "CoffeeScript JavaScript javascript", groups: undefined]
           

上面比對的是四個字母,這四個字母要滿足以下條件:緊跟着的應該是

Script

字元串,而且

Script

字元串應該是單詞的結尾部分。

是以,零寬肯定先行斷言的意思是:現在有一段正則文法,用這段文法去比對給定的文本。但是,滿足條件的文本不僅要比對這段文法,緊跟着它的必須是一個位置,這個位置又必須滿足一段正則文法。

說的再直白點,我要比對一段文本,但是這段文本後面必須緊跟着另一段特定的文本。零寬肯定先行斷言就是一個界碑,我要滿足前面和後面所有的條件,但是我隻要前面的文本。

我們來看另一種情況:

'CoffeeScript JavaScript javascript'.match(/\b\w{4}(?=Script\b)\w+/);
// ["JavaScript", index: 13, input: "CoffeeScript JavaScript javascript", groups: undefined]
           

上面的例子更加直覺,零寬肯定先行斷言已經比對過

Script

一次了,後面的

\w+

卻還是能比對

Script

成功,足以說明它的

零寬

特性。它為緊貼在它前面的規則服務,并且不影響後面的比對規則。

零寬肯定後行斷言

先行是向前看,那後行就是向後看(lookbehind)咯。

文法是圓括号内最左邊加上

?<=

'演員高圓圓 将軍霍去病 演員霍思燕'.match(/(?<=演員)霍\S+/);
// ["霍思燕", index: 14, input: "演員高圓圓 将軍霍去病 演員霍思燕", groups: undefined]
           

一個正則可以有多個斷言:

'演員高圓圓 将軍霍去病 演員霍思燕'.match(/(?<=演員)霍.+?(?=\s|$)/);
// ["霍思燕", index: 14, input: "演員高圓圓 将軍霍去病 演員霍思燕", groups: undefined]
           

零寬否定先行斷言

肯定是判斷有什麼,否定就是判斷沒有什麼咯。

?!

'TypeScript Perl JavaScript'.match(/\b\w{4}(?!Script\b)/);
// ["Perl", index: 11, input: "TypeScript Perl JavaScript", groups: undefined]
           

零寬否定後行斷言

文法是圓括号最左邊加上

?<!

'演員高圓圓 将軍霍去病 演員霍思燕'.match(/(?<!演員)霍\S+/);
// ["霍去病", index: 8, input: "演員高圓圓 将軍霍去病 演員霍思燕", groups: undefined]
           

修飾符

正規表達式除了主體文法,還有若幹可選的模式修飾符。

寫法就是将修飾符安插在正則主體的尾巴上。比如這樣:

/abc/gi

g 修飾符

g

global

的縮寫。預設情況下,正則從左向右比對,隻要比對到了結果就會收工。

g

修飾符會開啟全局比對模式,找到所有比對的結果。

'演員高圓圓 将軍霍去病 演員霍思燕'.match(/(?<=演員)\S+/);
// ["高圓圓", index: 2, input: "演員高圓圓 将軍霍去病 演員霍思燕", groups: undefined]

'演員高圓圓 将軍霍去病 演員霍思燕'.match(/(?<=演員)\S+/g);
// ["高圓圓", "霍思燕"]
           

i 修飾符

i

ignoreCase

的縮寫。預設情況下,

/z/

是無法比對

Z

的,是以我們有時候不得不這樣寫:

/[a-zA-Z]/

i

修飾符可以全局忽略大小寫。

很多時候我們不在乎文本是大寫、小寫還是大小寫混寫,這個修飾符還是很有用的。

'javascript is great'.match(/JavaScript/);
// null

'javascript is great'.match(/JavaScript/i);
// ["javascript", index: 0, input: "javascript is great", groups: undefined]
           

m 修飾符

m

multiline

的縮寫。這個修飾符有特定起作用的場景:它要和

^

$

搭配起來使用。預設情況下,

^

$

比對的是文本的開始和結束,加上

m

修飾符,它們的含義就變成了行的開始和結束。

`

abc
xyz
`.match(/xyz/);
// ["xyz", index: 5, input: "↵abc↵xyz↵", groups: undefined]

`

abc
xyz
`.match(/^xyz$/);
// null

`

abc
xyz
`.match(/^xyz$/m);
// ["xyz", index: 5, input: "↵abc↵xyz↵", groups: undefined]
           

y 修飾符

這是ES2015的新特性。

y

sticky

的縮寫。

y

修飾符有和

g

修飾符重合的功能,它們都是全局比對。是以重點在

sticky

上,怎麼了解這個

粘連

g

修飾符不挑食,比對完一個接着比對下一個,對于文本的位置沒有要求。但是

y

修飾符要求必須從文本的開始實施比對,因為它會開啟全局比對,比對到的文本的下一個字元就是下一次文本的開始。這就是所謂的粘連。

'a bag with a tag has a mag'.match(/\wag/g);
// ["bag", "tag", "mag"]

'a bag with a tag has a mag'.match(/\wag/y);
// null

'bagtagmag'.match(/\wag/y);
// ["bag", index: 0, input: "bagtagmag", groups: undefined]

'bagtagmag'.match(/\wag/gy);
// ["bag", "tag", "mag"]
           

有人肯定發現了存在某種問題或陰謀:你不是說

y

修飾符是全局比對麼?看上面的例子,單獨一個

y

修飾符用match方法怎麼并不是全局比對呢?

诶,這裡說來就話長了。

長話短說呢,就涉及到

y

修飾符的本質是什麼。它的本質有二:

  • 全局比對(先别着急打我)。
  • 從文本的

    lastIndex

    位置開始新的比對。lastIndex是什麼?它是正規表達式的一個屬性,如果是全局比對,它用來标注下一次比對的起始點。這才是粘連的本質所在。

不知道你們發現什麼了沒有:lastIndex是正規表達式的一個屬性。而上面例子中的match方法是作用在字元串上的,都沒有lastIndex屬性,休怪人家工作不上心。

const reg = /\wag/y;
reg.exec('bagtagmag');
// ["bag", index: 0, input: "bagtagmag", groups: undefined]

reg.exec('bagtagmag');
// ["tag", index: 3, input: "bagtagmag", groups: undefined]

reg.exec('bagtagmag');
// ["mag", index: 6, input: "bagtagmag", groups: undefined]
           

咱們換成正則方法exec,多次執行,正則的lastIndex在變,比對的結果也在變。全局比對無疑了吧。

s 修飾符

s

s

修飾符要和

.

搭配使用,預設情況下,

.

比對除了換行符之外的任意單個字元,然而它還沒有強大到無所不能的地步,是以正則索性給它開個挂。

s

修飾符的作用就是讓.可以比對任意單個字元。

s

singleline

`

abc
xyz
`.match(/c.x/);
// null

`

abc
xyz
`.match(/c.x/s);
// ["c↵x", index: 3, input: "↵abc↵xyz↵", groups: undefined]
           

u 修飾符

u

unicode

的縮寫。有一些Unicode字元超過一個位元組,正則就無法正确的識别它們。

u

修飾符就是用來處理這些不常見的情況的。

'𠮷'.match(/^.$/);
// null

'𠮷'.match(/^.$/u);
// ["𠮷", index: 0, input: "𠮷", groups: undefined]
           

𠮷

,與

同義。

筆者對Unicode認識尚淺,這裡不過多展開。

修飾符中用的最多的是

i

g

,并且是搭配一起用的。