iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 10
1
Modern Web

JavaScript 之旅系列 第 10

JavaScript 之旅 (10):RegExp Named Capture Groups

本篇介紹 ES2018 (ES9) 提供的 RegExp Named Capture Groups。

過去的 Numbered Capture Groups

numbered capture groups 可讓你引用 RegExp match 的字串的某個部份
每個 capture group 都分配一個唯一的編號,並可使用該編號來引用,但缺點是會讓 RegExp 更不易讀,且不易重構。

例如:從 /(\d{4})-(\d{2})-(\d{2})/ 你的出來是 match 哪種資料的 RegExp pattern?無法直接看出來,因為你知道格式必須是數字,並且有些數字之間要用 - 分隔。

其實剛剛的 pattern 是拿來 match YYYY-MM-DD 的日期格式,例如:2020-09-25

let dateFormat = /(\d{4})-(\d{2})-(\d{2})/;
let result = dateFormat.exec('2020-09-25');

console.log(result);
// ["2020-09-25", "2020", "09", "25", index: 0, input: "2020-09-25", groups: undefined]

console.log(result[1]);
// "2020"
console.log(result[2]);
// "09"
console.log(result[3]);
// "25"

但還有另一個問題,後面的兩個 capture group 都是連續接著兩個數字,你要怎麼確定哪一個是月份,哪一個是日期?

let dateFormat = /(\d{4})-(\d{2})-(\d{2})/;
let result = dateFormat.exec('2020-25-09');

console.log(result);
// ["2020-25-09", "2020", "25", "09", index: 0, input: "2020-25-09", groups: undefined]

console.log(result[1]);
// "2020"
console.log(result[2]);
// "25"
console.log(result[3]);
// "09"

而本篇介紹的 named capture groups 提案就是一個很好的解決方案。

現今的 Named Capture Groups

在 ECMAScript 的 capture group 可用 {?<name>...} 語法為 capture group 命名,該名稱在 spec 內稱為 RegExpIdentifierName,每個名稱都是唯一的。

以剛剛的日期格式為例,可以改為這樣:

let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = dateFormat.exec('2020-09-25');

console.log(result);
// ["2020-09-25", "2020", "09", "25", index: 0, input: "2020-09-25", groups: {…}]

原本是透過 non-named capture group 取得每個 group:

console.log(result[1]);
// "2020"
console.log(result[2]);
// "09"
console.log(result[3]);
// "25"

而 named capture group 可以從 RegExp 的結果中的 groups property 取得 named group:

console.log(result.groups);
// {year: "2020", month: "09", day: "25"}

也可搭配解構來使用:

let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let { groups: {year, month, day} } = dateFormat.exec('2020-09-25');

console.log(year);
// "2020"
console.log(month);
// "09"
console.log(day);
// "25"

Backreferences (反向引用)

例如:此 pattern 只能 match 第一個字元和最後一個字元是 ' (單引號) 或 " (雙引號),而引號的中間可以是任何字元。但再加上一個規則,只要一邊是單引號,另一邊就一定是單引號,雙引號也是,必須成雙成對。

也許你會這樣寫:

/^(["'])(.*)(["'])$/.exec(`'Titan'`);
// ["'Titan'", "'", "Titan", "'", index: 0, input: "'Titan'", groups: undefined]

/^(["'])(.*)(["'])$/.exec(`"Titan"`);
// [""Titan"", """, "Titan", """, index: 0, input: ""Titan"", groups: undefined]

但有一個問題,第一個和第三個 capture group 只要是 ' (單引號) 或 " (雙引號) 都會 match,這樣就會發生一邊是單引號,一邊是雙引號:

/^(["'])(.*)(["'])$/.exec(`'Titan"`);
// ["'Titan"", "'", "Titan", """, index: 0, input: "'Titan"", groups: undefined]

/^(["'])(.*)(["'])$/.exec(`"Titan'`);
// [""Titan'", """, "Titan", "'", index: 0, input: ""Titan'", groups: undefined]

此時就能用 backreference 來解決,先來看程式碼:

/^(["'])(.*)\1$/.exec(`"Titan"`);
// [""Titan"", """, "Titan", index: 0, input: ""Titan"", groups: undefined]

/^(["'])(.*)\1$/.exec(`'Titan'`);
// ["'Titan'", "'", "Titan", index: 0, input: "'Titan'", groups: undefined]

\1 (語法為 \nn 代表第幾個 numbered capture group) 是 backreference (也可稱為 numbered reference),代表第一個 numbered capture group 所 match 的結果,所以當第一個字元是單引號時,最後一個字元就必須是單引號,不能一邊是單引號,一般是雙引號。

所以這樣就能解決兩邊一定要相同引號的問題,否則就不會 match:

/^(["'])(.*)\1$/.exec(`'Titan"`);
// null

/^(["'])(.*)\1$/.exec(`"Titan'`);
// null

\n 這種 backreference 的缺點是不易讀,若你的 RegExp 比較複雜,同時用了不同編號的 numbered reference 就會開始混亂了!

所以本篇介紹的 named capture group 就能派上用場了!

\k<name> 這種語法 (也可稱為 named reference) 就是為了解決這個問題!它代表與該 named capture group name match 的結果:

let stringFormat = /^(?<quote>["'])(?<string>.*)\k<quote>$/u;
let result = stringFormat.exec(`'Titan'`);

console.log(result);
// ["'Titan'", "'", "Titan", index: 0, input: "'Titan'", groups: {…}]

console.log(result.groups);
// {quote: "'", string: "Titan"}

當然 numbered reference 和 named reference 是可以混用的 (雖然我不太建議這樣)。用另一個範例為例:

let someNumberFormat = /^(?<part>\d)-\k<part>-\1$/u;

let result1 = someNumberFormat.exec('0-0-0');
console.log(result1);
// ["0-0-0", "0", index: 0, input: "0-0-0", groups: {…}]
console.log(result1.groups);
// {part: "0"}

let result2 = someNumberFormat.exec('0-0-1');
console.log(result1);
// null

取代目標

named capture group 也可在 String.prototype.replace() 作為取代值使用,使用 $name 語法就能存取 named capture group。例如:

let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = '2020-09-25'.replace(dateFormat, '$<day>/$<month>/$<year>');

console.log(result);
// 25/09/2020

不過要請注意,傳給 String.prototype.replace() 的值是 ordinary string literal (普通的字串字面值),而不是 template literal (模板字面值,即 ${name} ),不要搞混了。因為語法 $<name> 會解析出其中的 name,而不是作為變數。

提供一個好記的方法,只要跟 named capture group 相關的都會有 <name> 這樣的與法,而此提案就是為了能跟 template literal 做區別才這樣設計的。

String.prototype.replace() 的第二個 argument 是 callback 函數,可透過名為 groups 的新參數來存取 named capture group。

callback 完整的參數如下:

String.prototype.replace(
  regexp,
  function (matched, capture1, ..., captureN, position, string, groups) {
    // ...
  }
);

例如:

let dateFormat = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = '2020-09-25'.replace(dateFormat, (...args) => {
  let {day, month, year} = args[args.length - 1];
  return `${day}/${month}/${year}`;
});

console.log(result);
// 25/09/2020

其他

tc39/proposal-regexp-named-groups 這份提案中有一些討論蠻有趣的:

為何會在 RegExp 結果物件上多加一個 groups property?

  • 若自訂的 named capture group 命名剛好與原有結果物件的 lengthindexinput property 重複時,那就慘了!
  • 未避免這的問題,才單獨將 named capture group 放在獨立的 groups property 內,且值為一個物件:
    • key 為你自訂的命名
    • value 為該 named capture group match 的值
  • 另外此提案在還沒進入 stage 4 之前,還考慮到若在 RegExp.prototype.exec() 的 RegExp 結果物件上多加一個 groups property,也不會造成任何 Web 相容性的問題

只有使用 named capture group 才會在 RegExp 結果物件中建立 groups property,否則為 undefined

groups property 內不包含 numbered group property,只包含 named group。

資料來源


上一篇
JavaScript 之旅 (9):RegExp 的 s (dotAll) flag
下一篇
JavaScript 之旅 (11):RegExp Unicode property escapes
系列文
JavaScript 之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言