RegExp v 旗標與字串屬性,使用集合表示法

發布於 · 標籤為 ECMAScript

自 ECMAScript 3 (1999) 以來,JavaScript 便支援正規表示式。十六年後,ES2015 引入了 Unicode 模式(u 旗標)黏著模式(y 旗標),以及 RegExp.prototype.flags 取得器。再過三年,ES2018 引入了 dotAll 模式(s 旗標)回溯斷言命名擷取群組,以及 Unicode 字元屬性跳脫字元。而在 ES2020 中,String.prototype.matchAll 讓使用正規表示式變得更加容易。JavaScript 正規表示式已經有了長足的進步,而且還在持續改進中。

最新的範例是 新的 unicodeSets 模式,使用 v 旗標啟用。這個新模式解鎖了對延伸字元類別的支援,包括下列功能

本文將深入探討這些功能。但首先 — 以下是使用新旗標的方法

const re = //v;

v 旗標可以與現有的正規表示式旗標合併,但有一個值得注意的例外。v 旗標啟用了 u 旗標的所有優點,但有額外的功能和改進 — 其中一些與 u 旗標向後不相容。最重要的是,v 是與 u 完全分開的模式,而不是互補的模式。因此,vu 旗標無法合併 — 在同一個正規表示式上使用這兩個旗標會導致錯誤。唯一有效的選項是:使用 u、使用 v,或不使用 uv。但由於 v 是功能最齊全的選項,因此這個選擇很容易做出…

讓我們深入探討新功能!

字串的 Unicode 屬性 #

Unicode 標準會為每個符號指定各種屬性和屬性值。例如,若要取得希臘腳本中使用的符號集合,請在 Unicode 資料庫中搜尋其 Script_Extensions 屬性值包含 Greek 的符號。

ES2018 Unicode 字元屬性逸出符號讓您可以在 ECMAScript 正規表示式中原生存取這些 Unicode 字元屬性。例如,樣式 \p{Script_Extensions=Greek} 會比對希臘腳本中使用的每個符號

const regexGreekSymbol = /\p{Script_Extensions=Greek}/u;
regexGreekSymbol.test('π');
// → true

根據定義,Unicode 字元屬性會擴充為一組碼點,因此可以轉譯為包含個別比對碼點的字元類別。例如,\p{ASCII_Hex_Digit} 等於 [0-9A-Fa-f]:它一次只會比對單一 Unicode 字元/碼點。在某些情況下,這是不夠的

// Unicode defines a character property named “Emoji”.
const re = /^\p{Emoji}$/u;

// Match an emoji that consists of just 1 code point:
re.test('⚽'); // '\u26BD'
// → true ✅

// Match an emoji that consists of multiple code points:
re.test('👨🏾‍⚕️'); // '\u{1F468}\u{1F3FE}\u200D\u2695\uFE0F'
// → false ❌

在上述範例中,正規表示式不會比對 👨🏾‍⚕️ 表情符號,因為它碰巧由多個碼點組成,而 Emoji 是 Unicode 字元 屬性。

幸運的是,Unicode 標準也定義了幾個 字串屬性。這些屬性會擴充為一組字串,每個字串都包含一個或多個碼點。在正規表示式中,字串屬性會轉譯為一組選項。為了說明這一點,想像一個 Unicode 屬性適用於字串 'a''b''c''W''xy''xyz'。此屬性會轉譯為下列正規表示式樣式(使用交替):xyz|xy|a|b|c|Wxyz|xy|[a-cW]。(最長的字串優先,因此前綴如 'xy' 不會隱藏較長的字串如 'xyz'。)與現有的 Unicode 屬性逸出符號不同,此樣式可以比對多個字元的字串。以下是使用中字串屬性的範例

const re = /^\p{RGI_Emoji}$/v;

// Match an emoji that consists of just 1 code point:
re.test('⚽'); // '\u26BD'
// → true ✅

// Match an emoji that consists of multiple code points:
re.test('👨🏾‍⚕️'); // '\u{1F468}\u{1F3FE}\u200D\u2695\uFE0F'
// → true ✅

此程式碼片段指的是字串屬性 RGI_Emoji,Unicode 將其定義為「建議用於一般交換的所有有效表情符號(字元和序列)的子集」。有了這個,我們現在可以比對表情符號,而不管它們在幕後由多少個碼點組成!

v 旗標從一開始就啟用對下列 Unicode 字串屬性的支援

  • Basic_Emoji
  • Emoji_Keycap_Sequence
  • RGI_Emoji_Modifier_Sequence
  • RGI_Emoji_Flag_Sequence
  • RGI_Emoji_Tag_Sequence
  • RGI_Emoji_ZWJ_Sequence
  • RGI_Emoji

隨著 Unicode 標準定義其他字串屬性,此支援屬性清單未來可能會增加。儘管目前所有字串屬性都與表情符號相關,但未來的字串屬性可能會服務於完全不同的使用案例。

注意:儘管字串屬性目前在新的 v 旗標中受到限制,但 我們計畫最終也讓它們在 u 模式中可用

集合符號 + 字串文字語法 #

使用 \p{…} 逸出字元時(不論是字元特性或字串的新特性),執行差異/減法或交集會很有用。使用 v 旗標,字元類別現在可以巢狀,這些集合運算現在可以在其中執行,而不是使用相鄰的向前或向後斷言或表達計算範圍的冗長字元類別。

使用 -- 執行差異/減法 #

語法 A--B 可用於比對字串A 中但不在 B,又稱差異/減法。

例如,如果你想要比對所有希臘符號,但字母 π 除外,使用集合符號,解決這個問題非常簡單

/[\p{Script_Extensions=Greek}--π]/v.test('π'); // → false

使用 -- 執行差異/減法,正規表示式引擎會為你執行困難的工作,同時保持你的程式碼可讀且可維護。

如果我們想要減去字元集 αβγ,而不是單一字元,怎麼辦?沒問題,我們可以使用巢狀字元類別並減去其內容

/[\p{Script_Extensions=Greek}--[αβγ]]/v.test('α'); // → false
/[\p{Script_Extensions=Greek}--[α-γ]]/v.test('β'); // → false

另一個範例是比對非 ASCII 數字,例如稍後將其轉換為 ASCII 數字

/[\p{Decimal_Number}--[0-9]]/v.test('𑜹'); // → true
/[\p{Decimal_Number}--[0-9]]/v.test('4'); // → false

集合符號也可以與字串的新特性一起使用

// Note: 🏴󠁧󠁢󠁳󠁣󠁴󠁿 consists of 7 code points.

/^\p{RGI_Emoji_Tag_Sequence}$/v.test('🏴󠁧󠁢󠁳󠁣󠁴󠁿'); // → true
/^[\p{RGI_Emoji_Tag_Sequence}--\q{🏴󠁧󠁢󠁳󠁣󠁴󠁿}]$/v.test('🏴󠁧󠁢󠁳󠁣󠁴󠁿'); // → false

這個範例比對任何 RGI 表情符號標籤順序,蘇格蘭國旗除外。請注意使用 \q{…},這是字元類別中字串文字的另一種新語法。例如,\q{a|bc|def} 比對字串 abcdef。如果不使用 \q{…},就無法減去硬編碼的多字元字串。

使用 && 執行交集 #

語法 A&&B 比對字串同時在 AB,又稱交集。這讓你能夠執行比對希臘字母等動作

const re = /[\p{Script_Extensions=Greek}&&\p{Letter}]/v;
// U+03C0 GREEK SMALL LETTER PI
re.test('π'); // → true
// U+1018A GREEK ZERO SIGN
re.test('𐆊'); // → false

比對所有 ASCII 空白

const re = /[\p{White_Space}&&\p{ASCII}]/v;
re.test('\n'); // → true
re.test('\u2028'); // → false

或比對所有蒙古數字

const re = /[\p{Script_Extensions=Mongolian}&&\p{Number}]/v;
// U+1817 MONGOLIAN DIGIT SEVEN
re.test('᠗'); // → true
// U+1834 MONGOLIAN LETTER CHA
re.test('ᠴ'); // → false

聯集 #

比對字串在 A 或在 B 中,以前已經可以使用 [\p{Letter}\p{Number}] 等字元類別比對單一字元字串。使用 v 旗標,此功能變得更強大,因為現在也可以與字串或字串文字的特性結合

const re = /^[\p{Emoji_Keycap_Sequence}\p{ASCII}\q{🇧🇪|abc}xyz0-9]$/v;

re.test('4️⃣'); // → true
re.test('_'); // → true
re.test('🇧🇪'); // → true
re.test('abc'); // → true
re.test('x'); // → true
re.test('4'); // → true

此模式中的字元類別結合

  • 字串的屬性 (\p{Emoji_Keycap_Sequence})
  • 字元屬性 (\p{ASCII})
  • 多碼點字串 🇧🇪abc 的字串文字語法
  • 單獨字元 xyz 的傳統字元類別語法
  • 09 字元範圍的傳統字元類別語法

另一個範例是比對所有常用的旗幟表情符號,無論它們編碼為雙字母 ISO 碼 (RGI_Emoji_Flag_Sequence) 或特殊標籤序列 (RGI_Emoji_Tag_Sequence)

const reFlag = /[\p{RGI_Emoji_Flag_Sequence}\p{RGI_Emoji_Tag_Sequence}]/v;
// A flag sequence, consisting of 2 code points (flag of Belgium):
reFlag.test('🇧🇪'); // → true
// A tag sequence, consisting of 7 code points (flag of England):
reFlag.test('🏴󠁧󠁢󠁥󠁮󠁧󠁿'); // → true
// A flag sequence, consisting of 2 code points (flag of Switzerland):
reFlag.test('🇨🇭'); // → true
// A tag sequence, consisting of 7 code points (flag of Wales):
reFlag.test('🏴󠁧󠁢󠁷󠁬󠁳󠁿'); // → true

改良不區分大小寫的比對 #

ES2015 的 u 標記有 令人困惑的不區分大小寫比對行為。請考慮以下兩個正規表示式

const re1 = /\p{Lowercase_Letter}/giu;
const re2 = /[^\P{Lowercase_Letter}]/giu;

第一個模式比對所有小寫字母。第二個模式使用 \P 取代 \p 來比對除了小寫字母以外的所有字元,但接著包在一個否定的字元類別中 ([^…])。兩個正規表示式都透過設定 i 標記 (ignoreCase) 來設定為不區分大小寫。

直覺上,你可能會預期兩個正規表示式行為相同。實際上,它們的行為非常不同

const re1 = /\p{Lowercase_Letter}/giu;
const re2 = /[^\P{Lowercase_Letter}]/giu;

const string = 'aAbBcC4#';

string.replaceAll(re1, 'X');
// → 'XXXXXX4#'

string.replaceAll(re2, 'X');
// → 'aAbBcC4#''

新的 v 標記有比較不令人意外的行為。使用 v 標記取代 u 標記後,兩個模式的行為相同

const re1 = /\p{Lowercase_Letter}/giv;
const re2 = /[^\P{Lowercase_Letter}]/giv;

const string = 'aAbBcC4#';

string.replaceAll(re1, 'X');
// → 'XXXXXX4#'

string.replaceAll(re2, 'X');
// → 'XXXXXX4#'

更一般來說,v 標記讓 [^\p{X}][\P{X}]\P{X},以及 [^\P{X}][\p{X}]\p{X},無論是否設定 i 標記。

進一步閱讀 #

提案儲存庫包含更多關於這些功能及其設計決策的詳細資訊和背景資料。

在我們處理這些 JavaScript 功能時,我們超越了「僅」提議對 ECMAScript 的規格變更。我們將「字串屬性」的定義上傳到 Unicode UTS#18,讓其他程式語言能以統一的方式實作類似的功能。我們也 提議變更 HTML 標準,目標是讓這些新功能也能在 pattern 屬性中啟用。

RegExp v 標記支援 #

V8 v11.0 (Chrome 110) 透過 --harmony-regexp-unicode-sets 旗標提供對此新功能的實驗性支援。V8 v12.0 (Chrome 112) 已預設啟用新功能。Babel 也支援轉譯 v 旗標 — 在 Babel REPL 中試用本文範例!下方的支援表格連結至追蹤問題,你可以訂閱以取得更新。