2024 年 1 月 27 日

擷取群組

模式的一部分可以用括號 (...) 括起來。這稱為「擷取群組」。

這有兩個效果

  1. 它允許將匹配的一部分作為結果陣列中的單獨項目取得。
  2. 如果我們在括號後放置一個量詞,它將套用於整個括號。

範例

讓我們看看括號在範例中如何運作。

範例:gogogo

不帶括號,模式 go+ 表示 g 字元,後接 o 重複出現一次或多次。例如,goooogooooooooo

括號將字元群組在一起,因此 (go)+ 表示 gogogogogogo 等。

alert( 'Gogogo now!'.match(/(go)+/ig) ); // "Gogogo"

範例:網域名稱

讓我們來做點更複雜的事,撰寫一個用於搜尋網站網域的正規表示式。

例如

mail.com
users.mail.com
smith.users.mail.com

正如我們所見,網域名稱由重複的字詞組成,每個字詞後面都有點,最後一個字詞除外。

在正規表示式中,表示為 (\w+\.)+\w+

let regexp = /(\w+\.)+\w+/g;

alert( "site.com my.site.com".match(regexp) ); // site.com,my.site.com

搜尋會運作,但模式無法比對包含連字號的網域名稱,例如 my-site.com,因為連字號不屬於類別 \w

我們可以透過在最後一個字詞以外的每個字詞中,將 \w 替換為 [\w-] 來修正它:([\w-]+\.)+\w+

範例:電子郵件

前一個範例可以擴充。我們可以根據它建立一個用於電子郵件的正規表示式。

電子郵件格式為:name@domain。任何字詞都可以當作名稱,允許使用連字號和點。在正規表示式中,表示為 [-.\w]+

模式

let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;

alert("[email protected] @ [email protected]".match(regexp)); // [email protected], [email protected]

這個正規表示式並不完美,但大多數情況下都能運作,有助於修正意外的錯別字。唯一真正可靠的電子郵件檢查方式,只能透過寄送信件來完成。

比對中的括號內容

括號從左到右編號。搜尋引擎會記住每個括號比對到的內容,並允許在結果中取得它。

如果 regexp 沒有旗標 gstr.match(regexp) 方法會尋找第一個比對,並將它作為陣列傳回

  1. 在索引 0:完整的比對。
  2. 在索引 1:第一個括號的內容。
  3. 在索引 2:第二個括號的內容。
  4. …以此類推…

例如,我們想要尋找 HTML 標籤 <.*?>,並處理它們。將標籤內容(尖括號內的內容)放在一個獨立的變數中會很方便。

讓我們將內部內容包在括號中,如下所示:<(.*?)>

現在我們會在結果陣列中同時取得整個標籤 <h1> 及其內容 h1

let str = '<h1>Hello, world!</h1>';

let tag = str.match(/<(.*?)>/);

alert( tag[0] ); // <h1>
alert( tag[1] ); // h1

巢狀群組

括號可以巢狀。在這種情況下,編號也會從左到右進行。

例如,在 <span class="my"> 中搜尋標籤時,我們可能對以下內容感興趣

  1. 整個標籤內容:span class="my"
  2. 標籤名稱:span
  3. 標籤屬性:class="my"

讓我們為它們加上括號:<(([a-z]+)\s*([^>]*))>

以下是它們的編號方式(從左到右,依開啟括號)

在動作中

let str = '<span class="my">';

let regexp = /<(([a-z]+)\s*([^>]*))>/;

let result = str.match(regexp);
alert(result[0]); // <span class="my">
alert(result[1]); // span class="my"
alert(result[2]); // span
alert(result[3]); // class="my"

result 的零索引始終包含完整匹配。

然後是群組,由開括號從左到右編號。第一個群組回傳為 result[1]。這裡包含整個標籤內容。

然後在 result[2] 中放置來自第二個開括號的群組 ([a-z]+) – 標籤名稱,然後在 result[3] 中放置標籤:([^>]*)

字串中每個群組的內容

選用群組

即使群組是選用的,且不存在於匹配中(例如,具有量詞 (...)?),對應的 result 陣列項目也會存在,並等於 undefined

例如,讓我們考慮正規表示式 a(z)?(c)?。它尋找 "a",後接選用的 "z",後接選用的 "c"

如果我們在只有一個字母 a 的字串上執行它,則結果為

let match = 'a'.match(/a(z)?(c)?/);

alert( match.length ); // 3
alert( match[0] ); // a (whole match)
alert( match[1] ); // undefined
alert( match[2] ); // undefined

陣列的長度為 3,但所有群組都為空。

以下是字串 ac 的更複雜匹配

let match = 'ac'.match(/a(z)?(c)?/)

alert( match.length ); // 3
alert( match[0] ); // ac (whole match)
alert( match[1] ); // undefined, because there's nothing for (z)?
alert( match[2] ); // c

陣列長度為永久:3。但對於群組 (z)? 沒有任何內容,因此結果為 ["ac", undefined, "c"]

使用群組搜尋所有匹配:matchAll

matchAll 是一種新的方法,可能需要多重載入

舊瀏覽器不支援 matchAll 方法。

可能需要多重載入,例如 https://github.com/ljharb/String.prototype.matchAll

當我們搜尋所有匹配(旗標 g)時,match 方法不會回傳群組的內容。

例如,讓我們在字串中尋找所有標籤

let str = '<h1> <h2>';

let tags = str.match(/<(.*?)>/g);

alert( tags ); // <h1>,<h2>

結果是一個匹配陣列,但沒有關於每個匹配的詳細資訊。但在實務上,我們通常需要結果中擷取群組的內容。

若要取得它們,我們應使用 str.matchAll(regexp) 方法進行搜尋。

match 之後很長一段時間才將它新增到 JavaScript 語言中,作為它的「新且改良版本」。

就像 match 一樣,它會尋找匹配,但有 3 個不同點

  1. 它回傳的不是陣列,而是可迭代物件。
  2. 當旗標 g 存在時,它會回傳每個相符項目為具有群組的陣列。
  3. 如果沒有相符項目,它不會回傳 null,而會回傳一個空的 iterable 物件。

例如

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

// results - is not an array, but an iterable object
alert(results); // [object RegExp String Iterator]

alert(results[0]); // undefined (*)

results = Array.from(results); // let's turn it into array

alert(results[0]); // <h1>,h1 (1st tag)
alert(results[1]); // <h2>,h2 (2nd tag)

正如我們所見,第一個差異非常重要,如 (*) 行所示。我們無法將相符項目取得為 results[0],因為該物件為偽陣列。我們可以使用 Array.from 將其轉換為真正的 Array。有關偽陣列和 iterable 的更多詳細資訊,請參閱文章 Iterable

如果我們要對結果進行迴圈,則不需要 Array.from

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

for(let result of results) {
  alert(result);
  // first alert: <h1>,h1
  // second: <h2>,h2
}

…或使用解構

let [tag1, tag2] = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

每個由 matchAll 回傳的相符項目,其格式與沒有旗標 gmatch 回傳的格式相同:它是一個包含其他屬性 index(字串中的相符項目索引)和 input(來源字串)的陣列。

let results = '<h1> <h2>'.matchAll(/<(.*?)>/gi);

let [tag1, tag2] = results;

alert( tag1[0] ); // <h1>
alert( tag1[1] ); // h1
alert( tag1.index ); // 0
alert( tag1.input ); // <h1> <h2>
為什麼 matchAll 的結果是 iterable 物件,而不是陣列?

為什麼方法會這樣設計?原因很簡單,就是為了最佳化。

呼叫 matchAll 並不會執行搜尋。相反地,它會回傳一個 iterable 物件,一開始沒有結果。搜尋會在每次我們對其進行迭代時執行,例如在迴圈中。

因此,只會找到所需數量的結果,不會更多。

例如,文字中可能有 100 個相符項目,但在 for..of 迴圈中,我們只找到其中 5 個,然後決定足夠了並執行 break。然後,引擎就不會花時間尋找其他 95 個相符項目。

命名群組

根據數字記住群組很困難。對於簡單的模式,這是可行的,但對於更複雜的模式,計算括號並不方便。我們有一個更好的選擇:為括號命名。

這可透過在開頭括號後立即放置 ?<name> 來完成。

例如,讓我們尋找「年-月-日」格式的日期

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2019-04-30";

let groups = str.match(dateRegexp).groups;

alert(groups.year); // 2019
alert(groups.month); // 04
alert(groups.day); // 30

如您所見,群組位於相符項目的 .groups 屬性中。

要尋找所有日期,我們可以新增旗標 g

我們還需要 matchAll 來取得完整的相符項目,以及群組

let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30 2020-01-01";

let results = str.matchAll(dateRegexp);

for(let result of results) {
  let {year, month, day} = result.groups;

  alert(`${day}.${month}.${year}`);
  // first alert: 30.10.2019
  // second: 01.01.2020
}

在替換中擷取群組

方法 str.replace(regexp, replacement)regexp 中的所有相符項目替換 str,允許在 replacement 字串中使用括號內容。這是使用 $n 完成的,其中 n 是群組編號。

例如,

let str = "John Bull";
let regexp = /(\w+) (\w+)/;

alert( str.replace(regexp, '$2, $1') ); // Bull, John

對於命名括號,參照將為 $<name>

例如,讓我們將日期從「年-月-日」重新格式化為「日.月.年」

let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;

let str = "2019-10-30, 2020-01-01";

alert( str.replace(regexp, '$<day>.$<month>.$<year>') );
// 30.10.2019, 01.01.2020

使用 ? 的非擷取群組

有時我們需要括號來正確套用量詞,但我們不希望其內容出現在結果中。

可以在開頭新增 ?: 來排除群組。

例如,如果我們想要尋找 (go)+,但不想將括號內容(go)作為一個單獨的陣列項目,我們可以寫:(?:go)+

在下面的範例中,我們只會取得名稱 John 作為配對的單獨成員

let str = "Gogogo John!";

// ?: excludes 'go' from capturing
let regexp = /(?:go)+ (\w+)/i;

let result = str.match(regexp);

alert( result[0] ); // Gogogo John (full match)
alert( result[1] ); // John
alert( result.length ); // 2 (no more items in the array)

摘要

括號將正規表示式的部分組合在一起,以便量詞套用於整個部分。

括號群組從左到右編號,並可以使用 (?<name>...) 選擇命名。

由群組配對的內容可以在結果中取得

  • 方法 str.match 僅在沒有旗標 g 的情況下傳回擷取群組。
  • 方法 str.matchAll 始終傳回擷取群組。

如果括號沒有名稱,則其內容可透過其編號在配對陣列中取得。命名的括號也可以在屬性 groups 中取得。

我們也可以在 str.replace 中的替換字串中使用括號內容:透過編號 $n 或名稱 $<name>

可以在開始處新增 ?: 來將群組排除在編號之外。當我們需要將量詞套用於整個群組,但不想將其作為結果陣列中的單獨項目時,會使用這種方式。我們也不能在替換字串中參照此類括號。

任務

MAC 位址 的網路介面包含 6 個由冒號分隔的兩位元組十六進位數字。

例如:'01:32:54:67:89:AB'

撰寫一個正規表示式,用於檢查字串是否為 MAC 位址。

用法

let regexp = /your regexp/;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (no colons)

alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, must be 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ at the end)

兩位元組十六進位數字為 [0-9a-f]{2}(假設已設定旗標 i)。

我們需要數字 NN,然後重複 5 次 :NN(更多數字);

正規表示式為:[0-9a-f]{2}(:[0-9a-f]{2}){5}

現在讓我們說明配對應擷取所有文字:從開頭開始,在結尾結束。這是透過將樣式包在 ^...$ 中來完成的。

最後

let regexp = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i;

alert( regexp.test('01:32:54:67:89:AB') ); // true

alert( regexp.test('0132546789AB') ); // false (no colons)

alert( regexp.test('01:32:54:67:89') ); // false (5 numbers, need 6)

alert( regexp.test('01:32:54:67:89:ZZ') ) // false (ZZ in the end)

撰寫一個正規表示式,用於配對格式為 #abc#abcdef 的顏色。也就是:# 後面接著 3 或 6 個十六進位數字。

使用範例

let regexp = /your regexp/g;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

P.S. 這應該正好是 3 或 6 個十六進位數字。值為 4 個數字,例如 #abcd,不應符合。

用於搜尋 3 位數顏色 #abc 的正規表示法:/#[a-f0-9]{3}/i

我們可以再新增正好 3 個十六進位數字。我們不需要更多或更少。顏色有 3 或 6 個數字。

讓我們使用量詞 {1,2}:我們將會有 /#([a-f0-9]{3}){1,2}/i

此處的樣式 [a-f0-9]{3} 括在括號中以套用量詞 {1,2}

在動作中

let regexp = /#([a-f0-9]{3}){1,2}/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef #abc

這裡有一個小問題:樣式在 #abcd 中找到 #abc。為了防止這種情況,我們可以在結尾新增 \b

let regexp = /#([a-f0-9]{3}){1,2}\b/gi;

let str = "color: #3f3; background-color: #AA00ef; and: #abcd";

alert( str.match(regexp) ); // #3f3 #AA00ef

撰寫一個正規表示法,用於尋找所有十進位數字,包括整數、浮點數和負數。

使用範例

let regexp = /your regexp/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) ); // -1.5, 0, 2, -123.4

正數(小數部分為選用):\d+(\.\d+)?

讓我們在開頭新增選用的 -

let regexp = /-?\d+(\.\d+)?/g;

let str = "-1.5 0 2 -123.4.";

alert( str.match(regexp) );   // -1.5, 0, 2, -123.4

算術表達式包含 2 個數字和它們之間的一個運算子,例如

  • 1 + 2
  • 1.2 * 3.4
  • -3 / -6
  • -2 - 2

運算子為:"+""-""*""/"

開頭、結尾或部分之間可能有額外的空格。

建立一個函式 parse(expr),它會接收一個表達式並傳回一個包含 3 個項目陣列

  1. 第一個數字。
  2. 運算子。
  3. 第二個數字。

例如

let [a, op, b] = parse("1.2 * 3.4");

alert(a); // 1.2
alert(op); // *
alert(b); // 3.4

數字的正規表示法為:-?\d+(\.\d+)?。我們在先前的任務中建立了它。

運算子為 [-+*/]。連字號 - 在方括弧中排在第一個,因為在中間它會表示字元範圍,而我們只想要字元 -

斜線 / 應在 JavaScript 正規表示法 /.../ 中進行跳脫,我們稍後會執行此操作。

我們需要一個數字、一個運算子,然後再一個數字。它們之間有選用的空格。

完整的正規表示法:-?\d+(\.\d+)?\s*[-+*/]\s*-?\d+(\.\d+)?

它有 3 個部分,它們之間有 \s*

  1. -?\d+(\.\d+)? – 第一個數字,
  2. [-+*/] – 運算子,
  3. -?\d+(\.\d+)? – 第二個數字。

為了讓這些部分中的每個部分成為結果陣列的個別元素,讓我們將它們括在括號中:(-?\d+(\.\d+)?)\s*([-+*/])\s*(-?\d+(\.\d+)?)

在動作中

let regexp = /(-?\d+(\.\d+)?)\s*([-+*\/])\s*(-?\d+(\.\d+)?)/;

alert( "1.2 + 12".match(regexp) );

結果包含

  • result[0] == "1.2 + 12"(完整配對)
  • result[1] == "1.2"(第一個群組 (-?\d+(\.\d+)?) – 第一個數字,包含小數部分)
  • result[2] == ".2"(第二個群組(\.\d+)? – 第一個小數部分)
  • result[3] == "+"(第三個群組 ([-+*\/]) – 運算子)
  • result[4] == "12"(第四個群組 (-?\d+(\.\d+)?) – 第二個數字)
  • result[5] == undefined(第五個群組 (\.\d+)? – 沒有最後一個小數部分,因此是未定義)

我們只想要數字和運算子,不包含完整配對或小數部分,因此讓我們稍微「清理」一下結果。

完整配對(陣列的第一個項目)可以透過轉移陣列 result.shift() 來移除。

包含小數部分的群組(數字 2 和 4) (.\d+) 可以透過在開頭加上 ?: 來排除:(?:\.\d+)?

最終解決方案

function parse(expr) {
  let regexp = /(-?\d+(?:\.\d+)?)\s*([-+*\/])\s*(-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  if (!result) return [];
  result.shift();

  return result;
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45

除了使用非擷取 ?: 之外,我們可以命名群組,如下所示

function parse(expr) {
  let regexp = /(?<a>-?\d+(?:\.\d+)?)\s*(?<operator>[-+*\/])\s*(?<b>-?\d+(?:\.\d+)?)/;

  let result = expr.match(regexp);

  return [result.groups.a, result.groups.operator, result.groups.b];
}

alert( parse("-1.23 * 3.45") );  // -1.23, *, 3.45;
教學課程地圖

留言

留言前請先閱讀...
  • 如果您有改善建議,請 提交 GitHub 議題 或提出 pull 要求,而不是留言。
  • 如果您看不懂文章中的某個部分,請說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,要插入多行,請用 <pre> 標籤包覆,要插入超過 10 行,請使用沙盒 (plnkrjsbincodepen…)