JS 的正規表示式

考拉海購前端團隊發表於2017-09-11

正規表示式

一種幾乎可以在所有的程式設計語言裡和所有的計算機平臺上使用的文書處理工具。它可以用來查詢特定的資訊(搜尋),也可以用來查詢並編輯特定的資訊(替換)。
核心是 匹配,匹配位置或者匹配字元

先簡單的介紹一下語法

基本元字元

  1. . : 匹配除了換行符之外的任何單個字元
  2. \ : 在非特殊字元之前的反斜槓表示下一個字元是特殊的,不能從字面上解釋。例如,沒有前\的'b'通常匹配小寫'b',無論它們出現在哪裡。如果加了'\',這個字元變成了一個特殊意義的字元,反斜槓也可以將其後的特殊字元,轉義為字面量。例如,模式 /a*/ 代表會匹配 0 個或者多個 a。相反,模式 /a\*/ 將 '*' 的特殊性移除,從而可以匹配像 "a*" 這樣的字串。
  3. | : 邏輯或操作符
  4. [] :定義一個字符集合,匹配字符集合中的一個字元,在字符集合裡面像 .\這些字元都表示其本身
  5. [^]:對上面一個集合取非
  6. - :定義一個區間,例如[A-Z],其首尾字元在 ASCII 字符集裡面

數量元字元

  1. {m,n} :匹配前面一個字元至少 m 次至多 n 次重複,還有{m}表示匹配 m 次,{m,}表示至少 m 次
  2. + : 匹配前面一個表示式一次或者多次,相當於 {1,},記憶方式追加(+),起碼得有一次
  3. * : 匹配前面一個表示式零次或者多次,相當於 {0,},記憶方式乘法(*),可以一次都沒有
  4. ? : 單獨使用匹配前面一個表示式零次或者一次,相當於 {0,1},記憶方式,有嗎?,有(1)或者沒有(1),如果跟在任何量詞*,+,?,{}後面的時候將會使量詞變為非貪婪模式(儘量匹配少的字元),預設是使用貪婪模式。比如對 "123abc" 應用 /\d+/ 將會返回 "123",如果使用 /\d+?/,那麼就只會匹配到 "1"。

位置元字元

  1. ^ : 單獨使用匹配表示式的開始
  2. \$ : 匹配表示式的結束
  3. \b:匹配單詞邊界
  4. \B:匹配非單詞邊界
  5. (?=p):匹配 p 前面的位置
  6. (?!p):匹配不是 p 前面的位置

特殊元字元

  1. \d[0-9],表示一位數字,記憶方式 digit
  2. \D[^0-9],表示一位非數字
  3. \s[\t\v\n\r\f],表示空白符,包括空格,水平製表符(\t),垂直製表符(\v),換行符(\n),回車符(\r),換頁符(\f),記憶方式 space character
  4. \S[^\t\v\n\r\f],表示非空白符
  5. \w[0-9a-zA-Z],表示數字大小寫字母和下劃線,記憶方式 word
  6. \W[^0-9a-zA-Z],表示非單詞字元

標誌字元

  1. g : 全域性搜尋 記憶方式global
  2. i :不區分大小寫 記憶方式 ignore
  3. m :多行搜尋

在 js 中的使用

支援正則的 String 物件的方法

  1. search
    search 接受一個正則作為引數,如果參入的引數不是正則會隱式的使用 new RegExp(obj)將其轉換成一個正則,返回匹配到子串的起始位置,匹配不到返回-1
  2. match
    接受引數和上面的方法一致。返回值是依賴傳入的正則是否包含 g ,如果沒有 g 標識,那麼 match 方法對 string 做一次匹配,如果沒有找到任何匹配的文字時,match 會返回 null ,否則,會返回一個陣列,陣列第 0 個元素包含匹配到的文字,其餘元素放的是正則捕獲的文字,陣列還包含兩個物件,index 表示匹配文字在字串中的位置,input 表示被解析的原始字串。如果有 g 標識,則返回一個陣列,包含每一次的匹配結果

     var str = 'For more information, see Chapter 3.4.5.1';
     var re = /see (chapter \d+(\.\d)*)/i;
     var found = str.match(re);
     console.log(found);
     // (3) ["see Chapter 3.4.5.1", "Chapter 3.4.5.1", ".1", index: 22, input: "For more information, see Chapter 3.4.5.1"]
     // 0:"see Chapter 3.4.5.1"
     // 1:"Chapter 3.4.5.1"
     // 2:".1"
     // index:22
     // input:"For more information, see Chapter 3.4.5.1"
     // length:3
     // __proto__:Array(0)
    
     // 'see Chapter 3.4.5.1' 是整個匹配。
     // 'Chapter 3.4.5.1''(chapter \d+(\.\d)*)'捕獲。
     // '.1' 是被'(\.\d)'捕獲的最後一個值。
     // 'index' 屬性(22) 是整個匹配從零開始的索引。
     // 'input' 屬性是被解析的原始字串。複製程式碼
     var str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
     var regexp = /[A-E]/gi;
     var matches_array = str.match(regexp);
    
     console.log(matches_array);
     // ['A', 'B', 'C', 'D', 'E', 'a', 'b', 'c', 'd', 'e']複製程式碼
  3. replace
    接受兩個引數,第一個是要被替換的文字,可以是正則也可以是字串,如果是字串的時候不會被轉換成正則,而是作為檢索的直接量文字。第二個是替換成的文字,可以是字串或者函式,字串可以使用一些特殊的變數來替代前面捕獲到的子串
變數名 代表的值
$$ 插入一個 "$"。
$& 插入匹配的子串。
$` 插入當前匹配的子串左邊的內容。
$' 插入當前匹配的子串右邊的內容。
$n 假如第一個引數是 RegExp物件,並且 n 是個小於100的非負整數,那麼插入第 n 個括號匹配的字串。
```
var re = /(\w+)\s(\w+)/;
var str = "John Smith";
var newstr = str.replace(re, "$2, $1");
// Smith, John
console.log(newstr);
```複製程式碼

如果是函式的話,函式入參如下,返回替換成的文字

變數名 代表的值
match 匹配的子串。(對應於上述的$&。)
p1,p2,... 假如replace()方法的第一個引數是一個RegExp 物件,則代表第n個括號匹配的字串。(對應於上述的$1,$2等。)
offset 匹配到的子字串在原字串中的偏移量。(比如,如果原字串是“abcd”,匹配到的子字串是“bc”,那麼這個引數將是1)
string 被匹配的原字串。
function replacer(match, p1, p2, p3, offset, string) {
  // p1 is nondigits, p2 digits, and p3 non-alphanumerics
  return [p1, p2, p3].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer);
// newString   abc - 12345 - #$*%複製程式碼
  1. split
    接受兩個引數,返回一個陣列。第一個是用來分割字串的字元或者正則,如果是空字串則會將元字串中的每個字元以陣列形式返回,第二個引數可選作為限制分割多少個字元,也是返回的陣列的長度限制。有一個地方需要注意,用捕獲括號的時候會將匹配結果也包含在返回的陣列中

     var myString = "Hello 1 word. Sentence number 2.";
     var splits = myString.split(/\d/);
    
     console.log(splits);
     // [ "Hello ", " word. Sentence number ", "." ]
    
     splits = myString.split(/(\d)/);
     console.log(splits);
     // [ "Hello ", "1", " word. Sentence number ", "2", "." ]複製程式碼

正則物件的方法

  1. test
    接受一個字串引數,如果正規表示式與指定的字串匹配返回 true 否則返回 false
  2. exec
    同樣接受一個字串為引數,返回一個陣列,其中存放匹配的結果。如果未找到匹配,則返回值為 null。
    匹配時,返回值跟 match 方法沒有 g 標識時是一樣的。陣列第 0 個表示與正則相匹配的文字,後面 n 個是對應的 n 個捕獲的文字,最後兩個是物件 index 和 input
    同時它會在正則例項的 lastIndex 屬性指定的字元處開始檢索字串 string。當 exec() 找到了與表示式相匹配的文字時,在匹配後,它將把正則例項的 lastIndex 屬性設定為匹配文字的最後一個字元的下一個位置。
    有沒有 g 標識對單詞執行 exec 方法是沒有影響的,只是有 g 標識的時候可以反覆呼叫 exec() 方法來遍歷字串中的所有匹配文字。當 exec() 再也找不到匹配的文字時,它將返回 null,並把 lastIndex 屬性重置為 0。
    var string = "2017.06.27";
    var regex2 = /\b(\d+)\b/g;
    console.log( regex2.exec(string) );
    console.log( regex2.lastIndex);
    console.log( regex2.exec(string) );
    console.log( regex2.lastIndex);
    console.log( regex2.exec(string) );
    console.log( regex2.lastIndex);
    console.log( regex2.exec(string) );
    console.log( regex2.lastIndex);
    // => ["2017", "2017", index: 0, input: "2017.06.27"]
    // => 4
    // => ["06", "06", index: 5, input: "2017.06.27"]
    // => 7
    // => ["27", "27", index: 8, input: "2017.06.27"]
    // => 10
    // => null
    // => 0複製程式碼
    其中正則例項lastIndex屬性,表示下一次匹配開始的位置。

比如第一次匹配了“2017”,開始下標是0,共4個字元,因此這次匹配結束的位置是3,下一次開始匹配的位置是4。

從上述程式碼看出,在使用exec時,經常需要配合使用while迴圈:

var string = "2017.06.27";
var regex2 = /\b(\d+)\b/g;
var result;
while ( result = regex2.exec(string) ) {
    console.log( result, regex2.lastIndex );
}
// => ["2017", "2017", index: 0, input: "2017.06.27"] 4
// => ["06", "06", index: 5, input: "2017.06.27"] 7
// => ["27", "27", index: 8, input: "2017.06.27"] 10複製程式碼

正則的匹配

字元匹配

精確匹配就不說了,比如/hello/,也只能匹配字串中的"hello"這個子串。
正規表示式之所以強大,是因為其能實現模糊匹配。

匹配多種數量

{m,n}來匹配多種數量,其他幾種形式(+*?)都可以等價成這種。比如

var regex = /ab{2,5}c/g;
var string = "abc abbc abbbc abbbbc abbbbbc abbbbbbc";
console.log( string.match(regex) ); // ["abbc", "abbbc", "abbbbc", "abbbbbc"]複製程式碼

貪婪和非貪婪:

預設貪婪

var regex = /\d{2,5}/g;
var string = "123 1234 12345 123456";
console.log( string.match(regex) ); // ["123", "1234", "12345", "12345"]複製程式碼

兩次後面加一個 ? 就可以表示非貪婪,非貪婪時

var regex = /\d{2,5}?/g;
var string = "123 1234 12345 123456";
console.log( string.match(regex) ); // ["12", "12", "34", "12", "34", "12", "34", "56"]複製程式碼
匹配多種情況

用字元組[]來匹配多種情況,其他幾種形式(\d\D\s\S\w\W)都可以等價成這種。比如

var regex = /a[123]b/g;
var string = "a0b a1b a2b a3b a4b";
console.log( string.match(regex) ); // ["a1b", "a2b", "a3b"]複製程式碼

如果字元組裡面字元特別多的話可以用-來表示範圍,比如[123456abcdefGHIJKLM],可以寫成[1-6a-fG-M],用[^0-9]表示非除了數字以外的字元
多種情況還可以是多種分支,用管道符來連線|,比如

var regex = /good|goodbye/g;
var string = "goodbye";
console.log( string.match(regex) ); // ["good"]複製程式碼

這個例子可以看出分支結構也是惰性的,匹配到了就不再往後嘗試了。

例子

掌握這兩種方式就可以解決比較簡單的正則問題了。

  1. 最多保留2位小數的數字
    /^([1-9]\d*|0)(\.\d{1,2})?$/
  2. 電話號碼
    /(\+86)?1\d{10}/
  3. 身份證
    /^(\d{15}|\d{17}([xX]|\d))$/

位置匹配

什麼是位置

位置是相鄰字元之間的,比如,有一個字串 hello ,這個字串一共有6個位置 *h*e*l*l*o* , *代表位置

image
image

上面說到了 6 種位置元字元

  1. ^$ 匹配字元的開頭和結尾,比如
    /^hello$/ 匹配一個字串,要符合這樣的條件,字串開頭的位置,緊接著是 h 然後是 e,l,l,o 最後是字串結尾的位置
    位置還可以被替換成字串,比如
    'hello'.replace(/^|$/g, '#') 結果是 #hello#
  2. /b/B 匹配單詞邊界和非單詞邊界,單詞邊界具體指 \w([a-zA-Z0-9_]) 和 \W 之間的位置,包括 \w^ 以及 $ 之間的位置,比如
    'hello word [js]_reg.exp-01'.replace(/\b/g, '#') 結果是 #hello# #word# [#js#]#_reg#.#exp#-#01#
  3. (?=p)(?!p) 匹配 p 前面的位置和不是 p 前面位置,比如
    'hello'.replace(/(?=l)/g, '#') 結果是 he#l#lo
    'hello'.replace(/(?!l)/g, '#') 結果是 #h#ell#o#
位置的特性

字元與字元之間的位置可以是多個。在理解上可以將位置理解成空字串 '',比如
hello 可以是一般的 '' + 'h' + 'e' + 'l' + 'l' + 'o' + '',也可以是 '' + '' + '' + '' + 'h' + 'e' + 'l' + 'l' + 'o' + ''
所以/^h\Be\Bl\Bl\Bo$/.test('hello') 結果是 true,/^^^h\B\B\Be\Bl\Bl\Bo$$$/.test('hello') 結果也是 true

例子
  1. 千分位,將 123123123 轉換成 123,123,123
    數字是從後往前數,也就是以一個或者多個3位數字結尾的位置換成 ',' 就好了,寫成正則就是
    123123213.replace(/(?=(\d{3})+$)/g, ',') 但是這樣的話會在最前面也加一個 ',' 這明顯是不對的。所以還得繼續改一下正則
    要求匹配到的位置不是開頭,可以用 /(?!^)(?=(\d{3})+$)/g 來表示。
    換種思路來想,能不能是以數字開頭然後加上上面的條件呢,得出這個正則 /\d(?=(\d{3})+$)/g,但是這個正則匹配的結果是 12,12,123,發現這個正則匹配的不是位置而是字元,將數字換成了 ',' 可以得出結論,如果要求一個正則是匹配位置的話,那麼所有的條件必須都是位置。

分組

分組主要是括號的使用

分組和分支結構

在分支結構中,括號是用來表示一個整體的,(p1|p2),比如要匹配下面的字串

I love JavaScript
I love Regular Expression複製程式碼

可以用正則/^I love (JavaScript|Regular Expression)$/ 而不是 /^I love JavaScript|Regular Expression$/
表示一個整體還比如 /(abc)+/ 一個或者多個 abc 字串
上面這些使用 () 包起來的地方就叫做分組

'I love JavaScript'.match(/^I love (JavaScript|Regular Expression)$/)
// ["I love JavaScript", "JavaScript", index: 0, input: "I love JavaScript"]複製程式碼

輸出的陣列第二個元素,"JavaScript" 就是分組匹配到的內容

引用分組

提取資料

比如我們要用正則來匹配一個日期格式,yyyy-mm-dd,可以寫出簡單的正則/\d{4}-\d{2}-\d{2}/,這個正則還可以改成分組形式的/(\d{4})-(\d{2})-(\d{2})/
這樣我們可以分別提取出一個日期的年月日,用 String 的 match 方法或者用正則的 exec 方法都可以

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-08-09";
console.log( string.match(regex) ); 
// => ["2017-08-09", "2017", "08", "09", index: 0, input: "2017-08-09"]複製程式碼

也可以用正則物件建構函式的全域性屬性 $1 - $9 來獲取

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-08-09";

regex.test(string); // 正則操作即可,例如
//regex.exec(string);
//string.match(regex);

console.log(RegExp.$1); // "2017"
console.log(RegExp.$2); // "08"
console.log(RegExp.$3); // "09"複製程式碼
替換

如果想要把 yyyy-mm-dd 替換成格式 mm/dd/yyyy 應該怎麼做。
String 的 replace 方法在第二個引數裡面可以用 $1 - $9 來指代相應的分組

var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-08-09";
var result = string.replace(regex, "$2/$3/$1");
console.log(result); // "08/09/2017"
等價
var result = string.replace(regex, function() {
    return RegExp.$2 + "/" + RegExp.$3 + "/" + RegExp.$1;
});
console.log(result); // "08/09/2017"
等價
var regex = /(\d{4})-(\d{2})-(\d{2})/;
var string = "2017-08-09";
var result = string.replace(regex, function(match, year, month, day) {
    return month + "/" + day + "/" + year;
});
console.log(result); // "08/09/2017"複製程式碼

反向引用

之前匹配日期的正則在使用的時候發現還有另外兩種寫法,一共三種

2017-08-09

2017/08/09

2017.08.09複製程式碼

要匹配這三種應該怎麼寫正則,第一反應肯定是把上面那個正則改一下/(\d{4})[-/.](\d{2})[-/.](\d{2})/,把 - 改成 [-/.] 這三種都可以
看上去沒問題,我們多想想就會發現,這個正則把 2017-08.09 這種字串也匹配到了,這個肯定是不符合預期的。
這個時候我們就需要用到反向引用了,反向引用可以在匹配階段捕獲到分組的內容 /(\d{4})([-/.])(\d{2})\2(\d{2})/

那麼出現括號巢狀怎麼辦,比如
var regex = /^((\d)(\d(\d)))\1\2\3\4$/;
var string = "1231231233";
console.log( regex.test(string) ); // true
console.log( RegExp.$1 ); // 123
console.log( RegExp.$2 ); // 1
console.log( RegExp.$3 ); // 23
console.log( RegExp.$4 ); // 3複製程式碼

巢狀的括號以左括號為準

引用了不存在的分組呢

如果在正則裡面引用了前面不存在的分組,這個時候正則會匹配字元本身,比如\1就匹配\1

非捕獲分組

我們有時候只是想用括號原本的功能而不想捕獲他們。這個時候可以用(?:p)表示一個非捕獲分組

例子

  1. 駝峰改短橫

    function dash(str) {
    return str.replace(/([A-Z])/g, '-$1').toLowerCase();
    }複製程式碼
  2. 獲取連結的 search 值連結:https://www.baidu.com?name=jawil&age=23

    function getParamName(attr) {
    
    let match = RegExp(`[?&]${attr}=([^&]*)`) //分組運算子是為了把結果存到exec函式返回的結果裡
     .exec(window.location.search)
    //["?name=jawil", "jawil", index: 0, input: "?name=jawil&age=23"]
    return match && decodeURIComponent(match[1].replace(/\+/g, ' ')) // url中+號表示空格,要替換掉
    }
    console.log(getParamName('name'))  // "jawil"複製程式碼
  3. 去掉字串前後的空格

    function trim(str) {
     return str.replace(/(^\s*)|(\s*$)/g, "")
    }複製程式碼
  4. 判斷一個數是否是質數

    function isPrime(num) {
    return !/^1?$|^(11+?)\1+$/.test(Array(num+1).join('1'))
    }複製程式碼

這裡首先是把一個數字變成1組成的字串,比如11就是 '1111111111' 11個1 然後正則分兩部分,第一部分是匹配空字串或者1,第二部分是先匹配兩個或者多個1,非貪婪模式,那麼先會匹配兩個1,然後將匹配的兩個1分組,後面就是匹配一個或者多個'2個1',就相當於整除2,如果匹配成功就證明不是質數,如果不成功就會匹配3個1,然後匹配多個3個1,相當於整除3,這樣一直下去會一直整除到自己本身。如果還是不行就證明這個數字是質數。

回溯

正則是怎麼匹配的

有這麼一個字串 'abbbc' 和這麼一個正則 /ab{1,3}bbc/
/ab{1,3}bbc/.test('abbbc') 我們一眼可以看出來是 true,但是 JavaScript 是怎麼匹配的呢

image
image

回溯

例如我們上面的例子,回溯的思想是,從問題的某一種狀態(初始狀態)出發,搜尋從這種狀態出發所能達到的所有“狀態”,當一條路走到“盡頭”的時候(不能再前進),再後退一步或若干步,從另一種可能“狀態”出發,繼續搜尋,直到所有的“路徑”(狀態)都試探過。這種不斷“前進”、不斷“回溯”尋找解的方法,就稱作“回溯法”

貪婪和非貪婪的匹配都會產生回溯,不同的是貪婪的是先儘量多的匹配,如果不行就吐出一個然後繼續匹配,再不行就再吐出一個,非貪婪的是先儘量少的匹配。如果不行就再多匹配一個,再不行就再來一個
分支結構也會產生回溯,比如/^(test|te)sts$/.test('tests') 前面括號裡面的匹配過程是先匹配到 test 然後繼續往後匹配匹配到字元 s 的時候還是成功的,匹配到 st 的時候發現不能匹配, 所以會回到前面的分支結構的其他分支繼續匹配,如果不行的話再換其他分支。

讀正則

讀懂其他人寫的正則也是一個很重要的方面。

結構和操作符

結構:字元字面量、字元組、量詞、錨字元、分組、選擇分支、反向引用。
操作符:

  1. 轉義符 \
  2. 括號和方括號 (...)、(?:...)、(?=...)、(?!...)、[...]
  3. 量詞限定符 {m}、{m,n}、{m,}、?、*、+
  4. 位置和序列 ^ 、$、 \元字元、 一般字元
  5. 管道符(豎槓) |

操作符的優先順序是從上到下,由高到低的,所以在分析正則的時候可以根據優先順序來拆分正則,比如
/ab?(c|de*)+|fg/

  1. 因為括號是一個整體,所以/ab?()+|fg/,括號裡面具體是什麼可以放到後面再分析
  2. 根據量詞和管道符的優先順序,所以a, b?, ()+和管道符後面的f, g
  3. 同理分析括號裡面的c|de* => cd, e*
  4. 綜上,這個正則描述的是
    image
    image

    以這種模式來分析,再複雜的正則都可以看懂。有一個視覺化的正則分析網站

相關文章