JavaScript 編程精解 中文第三版 9、正則表達式

來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目

原文:Regular Expressionsjavascript

譯者:飛龍html

協議:CC BY-NC-SA 4.0java

自豪地採用谷歌翻譯git

部分參考了《JavaScript 編程精解(第 2 版)》程序員

一些人遇到問題時會認爲,「我知道了,我會用正則表達式。」如今它們有兩個問題了。github

Jamie Zawinskiweb

Yuan-Ma said, 'When you cut against the grain of the wood, much strength is needed. When you program against the grain of the problem, much code is needed.'正則表達式

Master Yuan-Ma,《The Book of Programming》express

程序設計工具技術的發展與傳播方式是在混亂中不斷進化。在此過程當中獲勝的每每不是優雅或傑出的一方,而是那些瞄準主流市場,並可以填補市場需求的,或者碰巧與另外一種成功的技術集成在一塊兒的工具技術。apache

本章將會討論正則表達式(regular expression)這種工具。正則表達式是一種描述字符串數據模式的方法。它們造成了一種小而獨立的語言,也是 JavaScript 和許多其餘語言和系統的一部分。

正則表達式雖然不易理解,可是功能很是強大。正則表達式的語法有點詭異,JavaScript 提供的程序設計接口也不太易用。但正則表達式的確是檢查、處理字符串的強力工具。若是讀者可以正確理解正則表達式,將會成爲更高效的程序員。

建立正則表達式

正則表達式是一種對象類型。咱們可使用兩種方法來構造正則表達式:一是使用RegExp構造器構造一個正則表達式對象;二是使用斜槓(/)字符將模式包圍起來,生成一個字面值。

let re1 = new RegExp("abc");
let re2 = /abc/;

這兩個正則表達式對象都表示相同的模式:字符a後緊跟一個b,接着緊跟一個c

使用RegExp構造器時,須要將模式書寫成普通的字符串,所以反斜槓的使用規則與往常相同。

第二種寫法將模式寫在斜槓之間,處理反斜槓的方式與第一種方法略有差異。首先,因爲斜槓會結束整個模式,所以模式中包含斜槓時,需在斜槓前加上反斜槓。此外,若是反斜槓不是特殊字符代碼(好比\n)的一部分,則會保留反斜槓,不像字符串中會將其忽略,也不會改變模式的含義。一些字符,好比問號、加號在正則表達式中有特殊含義,若是你想要表示其字符自己,須要在字符前加上反斜槓。

let eighteenPlus = /eighteen\+/;

匹配測試

正則表達式對象有許多方法。其中最簡單的就是test方法。test方法接受用戶傳遞的字符串,並返回一個布爾值,表示字符串中是否包含能與表達式模式匹配的字符串。

console.log(/abc/.test("abcde"));
// → true
console.log(/abc/.test("abxde"));
// → false

不包含特殊字符的正則表達式簡單地表示一個字符序列。若是使用test測試字符串時,字符串中某處出現abc(不必定在開頭),則返回true

字符集

咱們也可調用indexOf來找出字符串中是否包含abc。正則表達式容許咱們表達一些更復雜的模式。

假如咱們想匹配任意數字。在正則表達式中,咱們能夠將一組字符放在兩個方括號之間,該表達式能夠匹配方括號中的任意字符。

下面兩個表達式均可以匹配包含數字的字符串。

console.log(/[0123456789]/.test("in 1992"));
// → true
console.log(/[0-9]/.test("in 1992"));
// → true

咱們能夠在方括號中的兩個字符間插入連字符(),來指定一個字符範圍,範圍內的字符順序由字符 Unicode 代碼決定。在 Unicode 字符順序中,0 到 9 是從左到右彼此相鄰的(代碼從48到57),所以[0-9]覆蓋了這一範圍內的全部字符,也就是說能夠匹配任意數字。

許多常見字符組都有本身的內置簡寫。 數字就是其中之一:\ d[0-9]表示相同的東西。

  • \d任意數字符號
  • \w字母和數字符號(單詞符號)
  • \s任意空白符號(空格,製表符,換行符等相似符號)
  • \D非數字符號
  • \W非字母和數字符號
  • \S非空白符號
  • .除了換行符之外的任意符號

所以你可使用下面的表達式匹配相似於30-01-2003 15:20這樣的日期數字格式:

let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/;
console.log(dateTime.test("30-01-2003 15:20"));
// → true
console.log(dateTime.test("30-jan-2003 15:20"));
// → false

這個表達式看起來是否是很是糟糕?該表達式中一半都是反斜槓,影響讀者的理解,使得讀者難以揣摩表達式實際想要表達的模式。稍後咱們會看到一個稍加改進的版本。

咱們也能夠將這些反斜槓代碼用在方括號中。例如,[\d.]匹配任意數字或一個句號。可是方括號中的句號會失去其特殊含義。其餘特殊字符也是如此,好比+

你能夠在左方括號後添加脫字符(^)來排除某個字符集,即表示不匹配這組字符中的任何字符。

let notBinary = /[^01]/;
console.log(notBinary.test("1100100010100110"));
// → false
console.log(notBinary.test("1100100010200110"));
// → true

部分模式重複

如今咱們已經知道如何匹配一個數字。若是咱們想匹配一個整數(一個或多個數字的序列),該如何處理呢?

在正則表達式某個元素後面添加一個加號(+),表示該元素至少重複一次。所以/\d+/能夠匹配一個或多個數字字符。

console.log(/'\d+'/.test("'123'"));
// → true
console.log(/'\d+'/.test("''"));
// → false
console.log(/'\d*'/.test("'123'"));
// → true
console.log(/'\d*'/.test("''"));
// → true

星號(*)擁有相似含義,可是能夠匹配模式不存在的狀況。在正則表達式的元素後添加星號並不會致使正則表達式中止匹配該元素後面的字符。只有正則表達式沒法找到能夠匹配的文本時纔會考慮匹配該元素從未出現的狀況。

元素後面跟一個問號表示這部分模式「可選」,即模式可能出現 0 次或 1 次。下面的例子能夠匹配neighbouru出現1次),也能夠匹配neighboru沒有出現)。

let neighbor = /neighbou?r/;
console.log(neighbor.test("neighbour"));
// → true
console.log(neighbor.test("neighbor"));
// → true

咱們可使用花括號準確指明某個模式的出現次數。例如,在某個元素後加上{4},則該模式須要出現且只能出現 4 次。也可使用花括號指定一個範圍:好比{2,4}表示該元素至少出現 2 次,至多出現 4 次。

這裏給出另外一個版本的正則表達式,能夠匹配日期、月份、小時,每一個數字均可以是一位或兩位數字。這種形式更易於解釋。

let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/;
console.log(dateTime.test("30-1-2003 8:45"));
// → true

花括號中也能夠省略逗號任意一側的數字,表示不限制這一側的數量。所以{,5}表示 0 到 5 次,而{5,}表示至少五次。

子表達式分組

爲了一次性對多個元素使用*或者+,那麼你必須使用圓括號,建立一個分組。對於後面的操做符來講,圓括號裏的表達式算做單個元素。

let cartoonCrying = /boo+(hoo+)+/i;
console.log(cartoonCrying.test("Boohoooohoohooo"));
// → true

第一個和第二個+字符分別做用於boohooo字符,而第三個+字符則做用於整個元組(hoo+),能夠匹配hoo+這種正則表達式出現一次及一次以上的狀況。

示例中表達式末尾的i表示正則表達式不區分大小寫,雖然模式中使用小寫字母,但能夠匹配輸入字符串中的大寫字母B

匹配和分組

test方法是匹配正則表達式最簡單的方法。該方法只負責判斷字符串是否與某個模式匹配。正則表達式還有一個exec(執行,execute)方法,若是沒法匹配模式則返回null,不然返回一個表示匹配字符串信息的對象。

let match = /\d+/.exec("one two 100");
console.log(match);
// → ["100"]
console.log(match.index);
// → 8

exec方法返回的對象包含index屬性,表示字符串成功匹配的起始位置。除此以外,該對象看起來像(並且實際上就是)一個字符串數組,其首元素是與模式匹配的字符串——在上面的例子中就是咱們查找的數字序列。

字符串也有一個相似的match方法。

console.log("one two 100".match(/\d+/));
// → ["100"]

若正則表達式包含使用圓括號包圍的子表達式分組,與這些分組匹配的文本也會出如今數組中。第一個元素是與整個模式匹配的字符串,其後是與第一個分組匹配的部分字符串(表達式中第一次出現左圓括號的那部分),而後是第二個分組。

let quotedText = /'([^']*)'/;
console.log(quotedText.exec("she said 'hello'"));
// → ["'hello'", "hello"]

若分組最後沒有匹配任何字符串(例如在元組後加上一個問號),結果數組中與該分組對應的元素將是undefined。相似的,若分組匹配了多個元素,則數組中只包含最後一個匹配項。

console.log(/bad(ly)?/.exec("bad"));
// → ["bad", undefined]
console.log(/(\d)+/.exec("123"));
// → ["123", "3"]

分組是提取部分字符串的實用特性。若是咱們不僅是想驗證字符串中是否包含日期,還想將字符串中的日期字符串提取出來,並將其轉換成等價的日期對象,那麼咱們可使用圓括號包圍那些匹配數字的模式字符串,並直接將日期從exec的結果中提取出來。

不過,咱們暫且先討論另外一個話題——在 JavaScript 中存儲日期和時間的內建方法。

日期類

JavaScript 提供了用於表示日期的標準類,咱們甚至能夠用其表示時間點。該類型名爲Date。若是使用new建立一個Date對象,你會獲得當前的日期和時間。

console.log(new Date());
// → Mon Nov 13 2017 16:19:11 GMT+0100 (CET)

你也能夠建立表示特定時間的對象。

console.log(new Date(2009, 11, 9));
// → Wed Dec 09 2009 00:00:00 GMT+0100 (CET)
console.log(new Date(2009, 11, 9, 12, 59, 59, 999));
// → Wed Dec 09 2009 12:59:59 GMT+0100 (CET)

JavaScript 中約定是:使用從 0 開始的數字表示月份(所以使用 11 表示 12 月),而使用從1開始的數字表示日期。這很是容易使人混淆。要注意這個細節。

構造器的後四個參數(小時、分鐘、秒、毫秒)是可選的,若是用戶沒有指定這些參數,則參數的值默認爲 0。

時間戳存儲爲 UTC 時區中 1970 年以來的毫秒數。 這遵循一個由「Unix 時間」設定的約定,該約定是在那個時候發明的。 你能夠對 1970 年之前的時間使用負數。 日期對象上的getTime方法返回這個數字。 你能夠想象它會很大。

console.log(new Date(2013, 11, 19).getTime());
// → 1387407600000
console.log(new Date(1387407600000));
// → Thu Dec 19 2013 00:00:00 GMT+0100 (CET)

若是你爲Date構造器指定了一個參數,構造器會將該參數當作毫秒數。你能夠建立一個新的Date對象,並調用getTime方法,或調用Date.now()函數來獲取當前時間對應的毫秒數。

Date對象提供了一些方法來提取時間中的某些數值,好比getFullYeargetMonthgetDategetHoursgetMinutesgetSeconds。除了getFullYear以外該對象還有一個getYear方法,會返回使用兩位數字表示的年份(好比 93 或 14),但不多用到。

經過在但願捕獲的那部分模式字符串兩邊加上圓括號,咱們能夠從字符串中建立對應的Date對象。

function getDate(string) {
  let [_, day, month, year] =
    /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string);
  return new Date(year, month - 1, day);
}
console.log(getDate("30-1-2003"));
// → Thu Jan 30 2003 00:00:00 GMT+0100 (CET)

_(下劃線)綁定被忽略,而且只用於跳過由exec返回的數組中的,完整匹配元素。

單詞和字符串邊界

不幸的是,getDate會從字符串"100-1-30000"中提取出一個無心義的日期——00-1-3000。正則表達式能夠從字符串中的任何位置開始匹配,在咱們的例子中,它從第二個字符開始匹配,到倒數第二個字符爲止。

若是咱們想要強制匹配整個字符串,可使用^標記和$標記。脫字符表示輸入字符串起始位置,美圓符號表示字符串結束位置。所以/^\d+$/能夠匹配整個由一個或多個數字組成的字符串,/^!/匹配任何以感嘆號開頭的字符串,而/x^/不匹配任何字符串(字符串起始位置以前不可能有字符x)。

另外一方面,若是咱們想要確保日期字符串起始結束位置在單詞邊界上,可使用\b標記。所謂單詞邊界,指的是起始和結束位置都是單詞字符(也就是\w表明的字符集合),而起始位置的前一個字符以及結束位置的後一個字符不是單詞字符。

console.log(/cat/.test("concatenate"));
// → true
console.log(/\bcat\b/.test("concatenate"));
// → false

這裏須要注意,邊界標記並不匹配實際的字符,只在強制正則表達式知足模式中的條件時才進行匹配。

選項模式

假如咱們不只想知道文本中是否包含數字,還想知道數字以後是否跟着一個單詞(pigcowchicken)或其複數形式。

那麼咱們能夠編寫三個正則表達式並輪流測試,但還有一種更好的方式。管道符號(|)表示從其左側的模式和右側的模式任意選擇一個進行匹配。所以代碼以下所示。

let animalCount = /\b\d+ (pig|cow|chicken)s?\b/;
console.log(animalCount.test("15 pigs"));
// → true
console.log(animalCount.test("15 pigchickens"));
// → false

小括號可用於限制管道符號選擇的模式範圍,並且你能夠連續使用多個管道符號,表示從多於兩個模式中選擇一個備選項進行匹配。

匹配原理

從概念上講,當你使用exectest時,正則表達式引擎在你的字符串中尋找匹配,經過首先從字符串的開頭匹配表達式,而後從第二個字符匹配表達式,直到它找到匹配或達到字符串的末尾。 它會返回找到的第一個匹配,或者根本找不到任何匹配。

爲了進行實際的匹配,引擎會像處理流程圖同樣處理正則表達式。 這是上例中用於家畜表達式的圖表:

若是咱們能夠找到一條從圖表左側通往圖表右側的路徑,則能夠說「表達式產生了匹配」。咱們保存在字符串中的當前位置,每移動經過一個盒子,就驗證當前位置以後的部分字符串是否與該盒子匹配。

所以,若是咱們嘗試從位置 4 匹配"the 3 pigs",大體會以以下的過程經過流程圖:

  • 在位置 4,有一個單詞邊界,所以咱們經過第一個盒子。
  • 依然在位置 4,咱們找到一個數字,所以咱們經過第二個盒子。
  • 在位置 5,有一條路徑循環回到第二個盒子(數字)以前,而另外一條路徑則移動到下一個盒子(單個空格字符)。因爲這裏是一個空格,而非數字,所以咱們必須選擇第二條路徑。
  • 咱們目前在位置 6(pig的起始位置),而表中有三路分支。這裏看不到"cow""chicken",但咱們看到了"pig",所以選擇"pig"這條分支。
  • 在位置 9(三路分支以後),有一條路徑跳過了s這個盒子,直接到達最後的單詞邊界,另外一條路徑則匹配s。這裏有一個s字符,而非單詞邊界,所以咱們經過s這個盒子。
  • 咱們在位置 10(字符串結尾),只能匹配單詞邊界。而字符串結尾能夠當作一個單詞邊界,所以咱們經過最後一個盒子,成功匹配字符串。

回溯

正則表達式/\b([01]+b|\d+|[\da-f]h)\b/能夠匹配三種字符串:以b結尾的二進制數字,以h結尾的十六進制數字(即以 16 爲進制,字母af表示數字 10 到 15),或者沒有後綴字符的常規十進制數字。這是對應的圖表。

當匹配該表達式時,經常會發生一種狀況:輸入的字符串進入上方(二進制)分支的匹配過程,但輸入中並不包含二進制數字。咱們以匹配字符串"103"爲例,匹配過程只有遇到字符 3 時才知道進入了錯誤分支。該字符串匹配咱們給出的表達式,但沒有匹配目前應當處於的分支。

所以匹配器執行「回溯」。進入一個分支時,匹配器會記住當前位置(在本例中,是在字符串起始,剛剛經過圖中第一個表示邊界的盒子),所以若當前分支沒法匹配,能夠回退並嘗試另外一條分支。對於字符串"103",遇到字符 3 以後,它會開始嘗試匹配十六進制數字的分支,它會再次失敗,由於數字後面沒有h。因此它嘗試匹配進制數字的分支,因爲這條分支能夠匹配,所以匹配器最後的會返回十進制數的匹配信息。

一旦字符串與模式徹底匹配,匹配器就會中止。這意味着多個分支均可能匹配一個字符串,但匹配器最後只會使用第一條分支(按照出如今正則表達式中的出現順序排序)。

回溯也會發生在處理重複模式運算符(好比+*)時。若是使用"abcxe"匹配/^.*x/.*部分,首先嚐試匹配整個字符串,接着引擎發現匹配模式還須要一個字符x。因爲字符串結尾沒有x,所以*運算符嘗試少匹配一個字符。但匹配器依然沒法在abcx以後找到x字符,所以它會再次回溯,此時*運算符只匹配abc。如今匹配器發現了所需的x,接着報告從位置 0 到位置 4 匹配成功。

咱們有可能編寫須要大量回溯的正則表達式。當模式可以以許多種不一樣方式匹配輸入的一部分時,這種問題就會出現。例如,若咱們在編寫匹配二進制數字的正則表達式時,一時糊塗,可能會寫出諸如/([01]+)+b/之類的表達式。

若咱們嘗試匹配一些只由 0 與 1 組成的長序列,匹配器首先會不斷執行內部循環,直到它發現沒有數字爲止。接下來匹配器注意到,這裏不存在b,所以向前回溯一個位置,開始執行外部循環,接着再次放棄,再次嘗試執行一次內部循環。該過程會嘗試這兩個循環的全部可能路徑。這意味着每多出一個字符,其工做量就會加倍。甚至只需較少的一堆字符,就可以使匹配實際上永不停息地執行下去。

replace方法

字符串有一個replace方法,該方法可用於將字符串中的一部分替換爲另外一個字符串。

console.log("papa".replace("p", "m"));
// → mapa

該方法第一個參數也能夠是正則表達式,這種狀況下會替換正則表達式首先匹配的部分字符串。若在正則表達式後追加g選項(全局,Global),該方法會替換字符串中全部匹配項,而不是隻替換第一個。

console.log("Borobudur".replace(/[ou]/, "a"));
// → Barobudur
console.log("Borobudur".replace(/[ou]/g, "a"));
// → Barabadar

若是 JavaScript 爲replace添加一個額外參數,或提供另外一個不一樣的方法(replaceAll),來區分替換一次匹配仍是所有匹配,將會是較爲明智的方案。遺憾的是,由於某些緣由 JavaScript 依靠正則表達式的屬性來區分替換行爲。

若是咱們在替換字符串中使用元組,就能夠體現出replace方法的真實威力。例如,假設咱們有一個規模很大的字符串,包含了人的名字,每一個名字佔據一行,名字格式爲「姓,名」。若咱們想要交換姓名,並移除中間的逗號(轉變成「名,姓」這種格式),咱們可使用下面的代碼:

console.log(
  "Liskov, Barbara\nMcCarthy, John\nWadler, Philip"
    .replace(/(\w+), (\w+)/g, "$2 $1"));
// → Barbara Liskov
//   John McCarthy
//   Philip Wadler

替換字符串中的$1$2引用了模式中使用圓括號包裹的元組。$1會替換爲第一個元組匹配的字符串,$2會替換爲第二個,依次類推,直到$9爲止。也可使用$&來引用整個匹配。

第二個參數不只可使用字符串,還可使用一個函數。每次匹配時,都會調用函數並以匹配元組(也能夠是匹配總體)做爲參數,該函數返回值爲須要插入的新字符串。

這裏給出一個小示例:

let s = "the cia and fbi";
console.log(s.replace(/\b(fbi|cia)\b/g,
            str => str.toUpperCase()));
// → the CIA and FBI

這裏給出另外一個值得討論的示例:

let stock = "1 lemon, 2 cabbages, and 101 eggs";
function minusOne(match, amount, unit) {
  amount = Number(amount) - 1;
  if (amount == 1) { // only one left, remove the 's'
    unit = unit.slice(0, unit.length - 1);
  } else if (amount == 0) {
    amount = "no";
  }
  return amount + " " + unit;
}
console.log(stock.replace(/(\d+) (\w+)/g, minusOne));
// → no lemon, 1 cabbage, and 100 eggs

該程序接受一個字符串,找出全部知足模式「一個數字緊跟着一個單詞(數字和字母)」的字符串,返回時將捕獲字符串中的數字減一。

元組(\d+)最後會變成函數中的amount參數,而·(w+)元組將會綁定unit。該函數將amount轉換成數字(因爲該參數是d+`的匹配結果,所以此過程老是執行成功),並根據剩下 0 仍是 1,決定如何作出調整。

貪婪模式

使用replace編寫一個函數移除 JavaScript 代碼中的全部註釋也是可能的。這裏咱們嘗試一下:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}
console.log(stripComments("1 + /* 2 */3"));
// → 1 + 3
console.log(stripComments("x = 10;// ten!"));
// → x = 10;
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1  1

或運算符以前的部分匹配兩個斜槓字符,後面跟着任意數量的非換行字符。多行註釋部分較爲複雜,咱們使用[^](任何非空字符集合)來匹配任意字符。咱們這裏沒法使用句號,由於塊註釋能夠跨行,句號沒法匹配換行符。

但最後一行的輸出顯然有錯。

爲什麼?

在回溯一節中已經提到過,表達式中的[^]*部分會首先匹配全部它能匹配的部分。若是其行爲引發模式的下一部分匹配失敗,匹配器纔會回溯一個字符,並再次嘗試。在本例中,匹配器首先匹配整個剩餘字符串,而後向前移動。匹配器回溯四個字符後,會找到*/,並完成匹配。這並不是咱們想要的結果。咱們的意圖是匹配單個註釋,而非到達代碼末尾並找到最後一個塊註釋的結束部分。

由於這種行爲,因此咱們說模式重複運算符(+*?{})是「貪婪」的,指的是這些運算符會盡可能多地匹配它們能夠匹配的字符,而後回溯。若讀者在這些符號後加上一個問號(+?*???{}?),它們會變成非貪婪的,此時這些符號會盡可能少地匹配字符,只有當剩下的模式沒法匹配時纔會多進行匹配。

而這即是咱們想要的狀況。經過讓星號儘可能少地匹配字符,咱們能夠匹配第一個*/,進而匹配一個塊註釋,而不會匹配過多內容。

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1"));
// → 1 + 1

對於使用了正則表達式的程序而言,其中出現的大量缺陷均可歸咎於一個問題:在非貪婪模式效果更好時,無心間錯用了貪婪運算符。若使用了模式重複運算符,請首先考慮一下是否可使用非貪婪符號替代貪婪運算符。

動態建立RegExp對象

有些狀況下,你沒法在編寫代碼時準確知道須要匹配的模式。假設你想尋找文本片斷中的用戶名,並使用下劃線字符將其包裹起來使其更顯眼。因爲你只有在程序運行時才知道姓名,所以你沒法使用基於斜槓的記法。

但你能夠構建一個字符串,並使用RegExp構造器根據該字符串構造正則表達式對象。

這裏給出一個示例。

let name = "harry";
let text = "Harry is a suspicious character.";
let regexp = new RegExp("\\b(" + name + ")\\b", "gi");
console.log(text.replace(regexp, "_$1_"));
// → _Harry_ is a suspicious character.

因爲咱們建立正則表達式時使用的是普通字符串,而非使用斜槓包圍的正則表達式,所以若是想建立\b邊界,咱們不得不使用兩個反斜槓。RegExp構造器的第二個參數包含了正則表達式選項。在本例中,"gi"表示全局和不區分大小寫。

但因爲咱們的用戶是怪異的青少年,若是用戶將名字設定爲"dea+hl[]rd",將會發生什麼?這將會致使正則表達式變得沒有意義,沒法匹配用戶名。

爲了可以處理這種狀況,咱們能夠在任何有特殊含義的字符前添加反斜槓。

let name = "dea+hl[]rd";
let text = "This dea+hl[]rd guy is super annoying.";
let escaped = name.replace(/[^\w\s]/g, "\\$&");
let regexp = new RegExp("\\b(" + escaped + ")\\b", "gi");
console.log(text.replace(regexp, "_><_"));
// → This _dea+hl[]rd_ guy is super annoying.

search方法

字符串的indexOf方法不支持以正則表達式爲參數。

但還有一個search方法,調用該方法時須要傳遞一個正則表達式。相似於indexOf,該方法會返回首先匹配的表達式的索引,若沒有找到則返回 –1。

console.log("  word".search(/\S/));
// → 2
console.log("    ".search(/\S/));
// → -1

遺憾的是,沒有任何方式能夠指定匹配的起始偏移(就像indexOf的第二個參數),而指定起始偏移這個功能是很實用的。

lastIndex屬性

exec方法一樣沒提供方便的方法來指定字符串中的起始匹配位置。但咱們可使用一種比較麻煩的方法來實現該功能。

正則表達式對象包含了一些屬性。其中一個屬性是source,該屬性包含用於建立正則表達式的字符串。另外一個屬性是lastIndex,能夠在極少數狀況下控制下一次匹配的起始位置。

所謂的極少數狀況,指的是當正則表達式啓用了全局(g)或者粘性(y),而且使用exec匹配模式的時候。此外,另外一個解決方案應該是向exec傳遞的額外參數,但 JavaScript 的正則表達式接口能設計得如此合理纔是怪事。

let pattern = /y/g;
pattern.lastIndex = 3;
let match = pattern.exec("xyzzy");
console.log(match.index);
// → 4
console.log(pattern.lastIndex);
// → 5

若是成功匹配模式,exec調用會自動更新lastIndex屬性,來指向匹配字符串後的位置。若是沒法匹配,會將lastIndex清零(就像新構建的正則表達式對象lastIndex屬性爲零同樣)。

全局和粘性選項之間的區別在於,啓用粘性時,僅當匹配直接從lastIndex開始時,搜索纔會成功,而全局搜索中,它會搜索匹配可能起始的全部位置。

let global = /abc/g;
console.log(global.exec("xyz abc"));
// → ["abc"]
let sticky = /abc/y;
console.log(sticky.exec("xyz abc"));
// → null

對多個exec調用使用共享的正則表達式值時,這些lastIndex屬性的自動更新可能會致使問題。 你的正則表達式可能意外地在以前的調用留下的索引處開始。

let digit = /\d/g;
console.log(digit.exec("here it is: 1"));
// → ["1"]
console.log(digit.exec("and now: 1"));
// → null

全局選項還有一個值得深思的效果,它會改變match匹配字符串的工做方式。若是調用match時使用了全局表達式,不像exec返回的數組,match會找出全部匹配模式的字符串,並返回一個包含全部匹配字符串的數組。

console.log("Banana".match(/an/g));
// → ["an", "an"]

所以使用全局正則表達式時須要倍加當心。只有如下幾種狀況中,你確實須要全局表達式即調用replace方法時,或是須要顯示使用lastIndex時。這也基本是全局表達式惟一的應用場景了。

循環匹配

一個常見的事情是,找出字符串中全部模式的出現位置,這種狀況下,咱們能夠在循環中使用lastIndexexec訪問匹配的對象。

let input = "A string with 3 numbers in it... 42 and 88.";
let number = /\b(\d+)\b/g;
let match;
while (match = number.exec(input)) {
  console.log("Found", match[0], "at", match.index);
}
// → Found 3 at 14
//   Found 42 at 33
//   Found 88 at 40

這裏咱們利用了賦值表達式的一個特性,該表達式的值就是被賦予的值。所以經過使用match=re.exec(input)做爲while語句的條件,咱們能夠在每次迭代開始時執行匹配,將結果保存在變量中,當沒法找到更多匹配的字符串時中止循環。

解析INI文件

爲了總結一下本章介紹的內容,咱們來看一下如何調用正則表達式來解決問題。假設咱們編寫一個程序從因特網上獲取咱們敵人的信息(這裏咱們實際上不會編寫該程序,僅僅編寫讀取配置文件的那部分代碼,對不起)。配置文件以下所示。

searchengine=https://duckduckgo.com/?q=$1
spitefulness=9.7

; comments are preceded by a semicolon...
; each section concerns an individual enemy
[larry]
fullname=Larry Doe
type=kindergarten bully
website=http://www.geocities.com/CapeCanaveral/11451

[davaeorn]
fullname=Davaeorn
type=evil wizard
outputdir=/home/marijn/enemies/davaeorn

該配置文件格式的語法規則以下所示(它是普遍使用的格式,咱們一般稱之爲INI文件):

  • 忽略空行和以分號起始的行。
  • 使用[]包圍的行表示一個新的節(section)。
  • 若是行中是一個標識符(包含字母和數字),後面跟着一個=字符,則表示向當前節添加選項。
  • 其餘的格式都是無效的。

咱們的任務是將這樣的字符串轉換爲一個對象,該對象的屬性包含沒有節的設置的字符串,和節的子對象的字符串,節的子對象也包含節的設置。

因爲咱們須要逐行處理這種格式的文件,所以預處理時最好將文件分割成一行行文本。咱們使用第 6 章中的string.split("\n")來分割文件內容。可是一些操做系統並不是使用換行符來分隔行,而是使用回車符加換行符("\r\n")。考慮到這點,咱們也可使用正則表達式做爲split方法的參數,咱們使用相似於/\r?\n/的正則表達式,這樣能夠同時支持"\n""\r\n"兩種分隔符。

function parseINI(string) {
  // Start with an object to hold the top-level fields
  let currentSection = {name: null, fields: []};
  let categories = [currentSection];

  string.split(/\r?\n/).forEach(line => {
    let match;
    if (match = line.match(/^(\w+)=(.*)$/)) {
      section[match[1]] = match[2];
      section = result[match[1]] = {};
    } else if (!/^\s*(;.*)?$/.test(line)) {
      throw new Error("Line '" + line + "' is not valid.");
    }
  });

  return result;
}

console.log(parseINI(`
name=Vasilis
[address]
city=Tessaloniki`));
// → {name: "Vasilis", address: {city: "Tessaloniki"}}

代碼遍歷文件的行並構建一個對象。 頂部的屬性直接存儲在該對象中,而在節中找到的屬性存儲在單獨的節對象中。 section綁定指向當前節的對象。

有兩種重要的行 - 節標題或屬性行。 當一行是常規屬性時,它將存儲在當前節中。 當它是一個節標題時,建立一個新的節對象,並設置section來指向它。

這裏須要注意,咱們反覆使用^$確保表達式匹配整行,而非一行中的一部分。若是不使用這兩個符號,大多數狀況下程序也能夠正常工做,但在處理特定輸入時,程序就會出現不合理的行爲,咱們通常很難發現這個缺陷的問題所在。

if (match = string.match(...))相似於使用賦值做爲while的條件的技巧。你一般不肯定你對match的調用是否成功,因此你只能在測試它的if語句中訪問結果對象。 爲了避免打破else if形式的使人愉快的鏈條,咱們將匹配結果賦給一個綁定,並當即使用該賦值做爲if語句的測試。

國際化字符

因爲 JavaScript 最初的實現很是簡單,並且這種簡單的處理方式後來也成了標準,所以 JavaScript 正則表達式處理非英語字符時很是無力。例如,就 JavaScript 的正則表達式而言,「單詞字符」只是 26 個拉丁字母(大寫和小寫)和數字,並且因爲某些緣由還包括下劃線字符。像αβ這種明顯的單詞字符,則沒法匹配\w(會匹配大寫的\W,由於它們屬於非單詞字符)。

因爲奇怪的歷史性意外,\s(空白字符)則沒有這種問題,會匹配全部 Unicode 標準中規定的空白字符,包括不間斷空格和蒙古文元音分隔符。

另外一個問題是,默認狀況下,正則表達式使用代碼單元,而不是實際的字符,正如第 5 章中所討論的那樣。 這意味着由兩個代碼單元組成的字符表現很奇怪。

console.log(/\ud83c\udf4e{3}/.test("\ud83c\udf4e\ud83c\udf4e\ud83c\udf4e"));
// → false
console.log(/<.>/.test("<\ud83c\udf39>"));
// → false
console.log(/<.>/u.test("<\ud83c\udf39>"));
// → true

問題是第一行中的"\ud83c\udf4e"(emoji 蘋果)被視爲兩個代碼單元,而{3}部分僅適用於第二個。 與之相似,點匹配單個代碼單元,而不是組成玫瑰 emoji 符號的兩個代碼單元。

你必須在正則表達式中添加一個u選項(表示 Unicode),才能正確處理這些字符。 不幸的是,錯誤的行爲仍然是默認行爲,由於改變它可能會致使依賴於它的現有代碼出現問題。

儘管這是剛剛標準化的,在撰寫本文時還沒有獲得普遍支持,但能夠在正則表達式中使用\p(必須啓用 Unicode 選項)以匹配 Unicode 標準分配了給定屬性的全部字符。

console.log(/\p{Script=Greek}/u.test("α"));
// → true
console.log(/\p{Script=Arabic}/u.test("α"));
// → false
console.log(/\p{Alphabetic}/u.test("α"));
// → true
console.log(/\p{Alphabetic}/u.test("!"));
// → false

Unicode 定義了許多有用的屬性,儘管找到你須要的屬性可能並不老是沒有意義。 你可使用\p{Property=Value}符號來匹配任何具備該屬性的給定值的字符。 若是屬性名稱保持不變,如\p{Name}中那樣,名稱被假定爲二元屬性,如Alphabetic,或者類別,如Number

本章小結

正則表達式是表示字符串模式的對象,使用本身的語言來表達這些模式:

  • /abc/:字符序列
  • /[abc]/:字符集中的任何字符
  • /[^abc]/:不在字符集中的任何字符
  • /[0-9]/:字符範圍內的任何字符
  • /x+/:出現一次或屢次
  • /x+?/:出現一次或屢次,非貪婪模式
  • /x*/:出現零次或屢次
  • /x??/:出現零次或屢次,非貪婪模式
  • /x{2,4}/:出現兩次到四次
  • /(abc)/:元組
  • /a|b|c/:匹配任意一個模式
  • /\d/:數字字符
  • /\w/:字母和數字字符(單詞字符)
  • /\s/:任意空白字符
  • /./:任意字符(除換行符外)
  • /\b/:單詞邊界
  • /^/:輸入起始位置
  • /$/:輸入結束位置

正則表達式有一個test方法來測試給定的字符串是否匹配它。 它還有一個exec方法,當找到匹配項時,返回一個包含全部匹配組的數組。 這樣的數組有一個index屬性,用於代表匹配開始的位置。

字符串有一個match方法來對正確表達式匹配它們,以及search方法來搜索字符串,只返回匹配的起始位置。 他們的replace方法能夠用替換字符串或函數替換模式匹配。

正則表達式擁有選項,這些選項寫在閉合斜線後面。 i選項使匹配不區分大小寫。 g選項使表達式成爲全聚德,除此以外,它使replace方法替換全部實例,而不是第一個。 y選項使它變爲粘性,這意味着它在搜索匹配時不會向前搜索並跳過部分字符串。 u選項開啓 Unicode 模式,該模式解決了處理佔用兩個代碼單元的字符時的一些問題。

正則表達式是難以駕馭的強力工具。它能夠簡化一些任務,但用到一些複雜問題上時也會難以控制管理。想要學會使用正則表達式的重要一點是:不要將其用到沒法乾淨地表達爲正則表達式的問題。

習題

在作本章習題時,讀者不可避免地會對一些正則表達式的莫名其妙的行爲感到困惑,於是備受挫折。讀者可使用相似於 http://debuggex.com/ 這樣的在線學習工具,將你想編寫的正則表達式可視化,並試驗其對不一樣輸入字符串的響應。

RegexpGolf

Code Golf 是一種遊戲,嘗試儘可能用最少的字符來描述特定程序。相似的,Regexp Golf 這種活動是編寫儘可能短小的正則表達式,來匹配給定模式(並且只能匹配給定模式)。

針對如下幾項,編寫正則表達式,測試給定的子串是否在字符串中出現。正則表達式匹配的字符串,應該只包含如下描述的子串之一。除非明顯提到單詞邊界,不然千萬不要擔憂邊界問題。當你的表達式有效時,請檢查一下可否讓正則表達式更短小。

  1. carcat
  2. popprop
  3. ferretferryferrari
  4. ious結尾的單詞
  5. 句號、冒號、分號以前的空白字符
  6. 多於六個字母的單詞
  7. 不包含e(或者E)的單詞

須要幫助時,請參考本章總結中的表格。使用少許測試字符串來測試每一個解決方案。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["hottentottententen"],
       ["no", "hotten totten tenten"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "learning ape", "BEET"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}

QuotingStyle

想象一下,你編寫了一個故事,自始至終都使用單引號來標記對話。如今你想要將對話的引號替換成雙引號,但不能替換在縮略形式中使用的單引號。

思考一下能夠區分這兩種引號用法的模式,並手動調用replace方法進行正確替換。

let text = "'I'm the cook,' he said, 'it's my job.'";
// Change this call.
console.log(text.replace(/A/g, "B"));
// → "I'm the cook," he said, "it's my job."

NumbersAgain

編寫一個表達式,只匹配 JavaScript 風格的數字。支持數字前可選的正號與負號、十進制小數點、指數計數法(5e-31E10,指數前也須要支持可選的符號)。也請注意小數點前或小數點後的數字也是沒必要要的,但數字不能只有小數點。例如.55.都是合法的 JavaScript 數字,但單個點則不是。

// Fill in this regular expression.
let number = /^...$/;

// Tests:
for (let str of ["1", "-1", "+15", "1.55", ".5", "5.",
                 "1.3e2", "1E-4", "1e+12"]) {
  if (!number.test(str)) {
    console.log(`Failed to match '${str}'`);
  }
}
for (let str of ["1a", "+-1", "1.2.3", "1+1", "1e4.5",
                 ".5.", "1f5", "."]) {
  if (number.test(str)) {
    console.log(`Incorrectly accepted '${str}'`);
  }
}
相關文章
相關標籤/搜索