[譯]JavaScript的新功能將改變正規表示式的編寫方式

第一秩序發表於2019-02-19

翻譯:第一秩序

原文:www.smashingmagazine.com/2019/02/reg…

摘要:如果你曾用 JavaScript 做過複雜的文字處理和操作,那麼你將會對 ES2018 中引入的新功能愛不釋手。 在本文中,我們將詳細介紹第 9 版標準如何提高 JavaScript 的文字處理能力。


有一個很好的理由能夠解釋為什麼大多數程式語言都支援正規表示式:它們是用於處理文字的極其強大的工具。 通常一行正規表示式程式碼就能完成需要幾十行程式碼才能搞定的文字處理任務。 雖然大多數語言中的內建函式足以對字串進行一般的搜尋和替換操作,但更加複雜的操作(例如驗證文字輸入)通常需要使用正規表示式。

自從 1999 年推出 ECMAScript 標準第 3 版以來,正規表示式已成為 JavaScript 語言的一部分。ECMAScript 2018(簡稱ES2018)是該標準的第 9 版,通過引入四個新功能進一步提高了JavaScript的文字處理能力:

下面詳細介紹這些新功能。

後行斷言

能夠根據之後或之前的內容匹配一系列字元,使你可以丟棄可能不需要的匹配。 當你需要處理大字串並且意外匹配的可能性很高時,這個功能非常有用。 幸運的是,大多數正規表示式都為此提供了 lookbehind 和 lookahead 斷言。

在 ES2018 之前,JavaScript 中只提供了先行斷言。 lookahead 允許你在一個斷言模式後緊跟另一個模式。

先行斷言有兩種版本:正向和負向。 正向先行斷言的語法是 (?=...)。 例如,正規表示式 /Item(?= 10)/ 僅在後面跟隨有一個空格和數字 10 的時候才與 Item 匹配:

const re = /Item(?= 10)/;

console.log(re.exec('Item'));
// → null

console.log(re.exec('Item5'));
// → null

console.log(re.exec('Item 5'));
// → null

console.log(re.exec('Item 10'));
// → ["Item", index: 0, input: "Item 10", groups: undefined]
複製程式碼

此程式碼使用 exec() 方法在字串中搜尋匹配項。 如果找到匹配項, exec() 將返回一個陣列,其中第一個元素是匹配的字串。 陣列的 index 屬性儲存匹配字串的索引, input 屬性儲存搜尋執行的整個字串。 最後,如果在正規表示式中使用了命名捕獲組,則將它們放在 groups 屬性中。 在程式碼中, groups 的值為 undefined ,因為沒有被命名的捕獲組。

負向先行的構造是 (?!...) 。 負向先行斷言的模式後面沒有特定的模式。 例如, /Red(?!head)/ 僅在其後不跟隨 head 時匹配 Red

const re = /Red(?!head)/;

console.log(re.exec('Redhead'));
// → null

console.log(re.exec('Redberry'));
// → ["Red", index: 0, input: "Redberry", groups: undefined]

console.log(re.exec('Redjay'));
// → ["Red", index: 0, input: "Redjay", groups: undefined]

console.log(re.exec('Red'));
// → ["Red", index: 0, input: "Red", groups: undefined]
複製程式碼

ES2018 為 JavaScript 補充了後行斷言。 用 (?<=...) 表示,後行斷言允許你在一個模式前面存在另一個模式時進行匹配。

假設你需要以歐元檢索產品的價格但是不捕獲歐元符號。 通過後行斷言,會使這項任務變得更加簡單:

const re = /(?<=€)\d+(\.\d*)?/;

console.log(re.exec('199'));
// → null

console.log(re.exec('$199'));
// → null

console.log(re.exec('€199'));
// → ["199", undefined, index: 1, input: "€199", groups: undefined]
複製程式碼

注意先行(Lookahead)和後行(lookbehind)斷言通常被稱為“環視”(lookarounds)

後行斷言的反向版本由 (?<!...) 表示,使你能夠匹配不在lookbehind中指定的模式之前的模式。 例如,正規表示式 /(?<!\d{3}) meters/ 會在 三個數字不在它之前 匹配單詞“meters”如果:

const re = /(?<!\d{3}) meters/;

console.log(re.exec('10 meters'));
// → [" meters", index: 2, input: "10 meters", groups: undefined]

console.log(re.exec('100 meters'));    
// → null
複製程式碼

與前行斷言一樣,你可以連續使用多個後行斷言(負向或正向)來建立更復雜的模式。下面是一個例子:

const re = /(?<=\d{2})(?<!35) meters/;

console.log(re.exec('35 meters'));
// → null

console.log(re.exec('meters'));
// → null

console.log(re.exec('4 meters'));
// → null

console.log(re.exec('14 meters'));
// → ["meters", index: 2, input: "14 meters", groups: undefined]
複製程式碼

此正規表示式僅匹配包含“meters”的字串,如果它前面緊跟 35 之外的任何兩個數字。正向後行確保模式前面有兩個數字,同時負向後行能夠確保該數字不是 35。

命名捕獲組

你可以通過將字元封裝在括號中的方式對正規表示式的一部分進行分組。 這可以允許你將規則限制為模式的一部分或在整個組中應用量詞。 此外你可以通過括號來提取匹配值並進行進一步處理。

下列程式碼給出瞭如何在字串中查詢帶有 .jpg 並提取檔名的示例:

const re = /(\w+)\.jpg/;
const str = 'File name: cat.jpg';
const match = re.exec(str);
const fileName = match[1];

// The second element in the resulting array holds the portion of the string that parentheses matched
console.log(match);
// → ["cat.jpg", "cat", index: 11, input: "File name: cat.jpg", groups: undefined]

console.log(fileName);
// → cat
複製程式碼

在更復雜的模式中,使用數字引用組只會使本身就已經很神祕的正規表示式的語法更加混亂。 例如,假設你要匹配日期。 由於在某些國家和地區會交換日期和月份的位置,因此會弄不清楚究竟哪個組指的是月份,哪個組指的是日期:

const re = /(\d{4})-(\d{2})-(\d{2})/;
const match = re.exec('2020-03-04');

console.log(match[0]);    // → 2020-03-04
console.log(match[1]);    // → 2020
console.log(match[2]);    // → 03
console.log(match[3]);    // → 04
複製程式碼

ES2018針對此問題的解決方案名為捕獲組,它使用更具表現力的 (?<name>...) 形式的語法:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = re.exec('2020-03-04');

console.log(match.groups);          // → {year: "2020", month: "03", day: "04"}
console.log(match.groups.year);     // → 2020
console.log(match.groups.month);    // → 03
console.log(match.groups.day);      // → 04
複製程式碼

因為生成的物件可能會包含與命名組同名的屬性,所以所有命名組都在名為 groups 的單獨物件下定義。

許多新的和傳統的程式語言中都存在類似的結構。 例如Python對命名組使用 (?P<name>) 語法。 Perl支援與 JavaScript 相同語法的命名組( JavaScript 已經模仿了 Perl 的正規表示式語法)。 Java也使用與Perl相同的語法。

除了能夠通過 groups 物件訪問命名組之外,你還可以用編號引用訪問組—— 類似於常規捕獲組:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = re.exec('2020-03-04');

console.log(match[0]);    // → 2020-03-04
console.log(match[1]);    // → 2020
console.log(match[2]);    // → 03
console.log(match[3]);    // → 04
複製程式碼

新語法也適用於解構賦值:

const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const [match, year, month, day] = re.exec('2020-03-04');

console.log(match);    // → 2020-03-04
console.log(year);     // → 2020
console.log(month);    // → 03
console.log(day);      // → 04
複製程式碼

即使正規表示式中不存在命名組,也始終建立 groups 物件:

const re = /\d+/;
const match = re.exec('123');

console.log('groups' in match);    // → true
複製程式碼

如果可選的命名組不參與匹配,則 groups 物件仍將具有命名組的屬性,但該屬性的值為 undefined

const re = /\d+(?<ordinal>st|nd|rd|th)?/;

let match = re.exec('2nd');

console.log('ordinal' in match.groups);    // → true
console.log(match.groups.ordinal);         // → nd

match = re.exec('2');

console.log('ordinal' in match.groups);    // → true
console.log(match.groups.ordinal);         // → undefined
複製程式碼

你可以稍後在模式中引用常規捕獲的組,並使用 \1 的形式進行反向引用。 例如以下程式碼使用在行中匹配兩個字母的捕獲組,然後在模式中呼叫它:

console.log(/(\w\w)\1/.test('abab'));    // → true

// if the last two letters are not the same 
// as the first two, the match will fail
console.log(/(\w\w)\1/.test('abcd'));    // → false
複製程式碼

要在模式中稍後呼叫命名捕獲組,可以使用 /\k<name>/ 語法。 下面是一個例子:

const re = /\b(?<dup>\w+)\s+\k<dup>\b/;

const match = re.exec("I'm not lazy, I'm on on energy saving mode");        

console.log(match.index);    // → 18
console.log(match[0]);       // → on on
複製程式碼

此正規表示式在句子中查詢連續的重複單詞。 如果你願意,還可以用帶編號的後引用來呼叫命名的捕獲組:

const re = /\b(?<dup>\w+)\s+\1\b/;

const match = re.exec("I'm not lazy, I'm on on energy saving mode");        

console.log(match.index);    // → 18
console.log(match[0]);       // → on on 
複製程式碼

也可以同時使用帶編號的後引用和命名後向引用:

const re = /(?<digit>\d):\1:\k<digit>/;

const match = re.exec('5:5:5');        

console.log(match[0]);    // → 5:5:5
複製程式碼

與編號的捕獲組類似,可以將命名的捕獲組插入到 replace() 方法的替換值中。 為此,你需要用到 $<name> 構造。 例如:

const str = 'War & Peace';

console.log(str.replace(/(War) & (Peace)/, '$2 & $1'));    
// → Peace & War

console.log(str.replace(/(?<War>War) & (?<Peace>Peace)/, '$<Peace> & $<War>'));    
// → Peace & War
複製程式碼

如果要使用函式執行替換,則可以引用命名組,方法與引用編號組的方式相同。 第一個捕獲組的值將作為函式的第二個引數提供,第二個捕獲組的值將作為第三個引數提供:

const str = 'War & Peace';

const result = str.replace(/(?<War>War) & (?<Peace>Peace)/, function(match, group1, group2, offset, string) {
    return group2 + ' & ' + group1;
});

console.log(result);    // → Peace & War

複製程式碼

s (dotAll) Flag

預設情況下,正規表示式模式中的點 (.) 元字元匹配除換行符 (\n) 和回車符 (\r)之外的所有字元:

console.log(/./.test('\n'));    // → false
console.log(/./.test('\r'));    // → false
複製程式碼

儘管有這個缺點,JavaScript 開發者仍然可以通過使用兩個相反的速記字元類來匹配所有字元,例如[\ w \ W],它告訴正規表示式引擎匹配一個字元(\w)或非單詞字元(\W):

console.log(/[\w\W]/.test('\n'));    // → true
console.log(/[\w\W]/.test('\r'));    // → true
複製程式碼

ES2018旨在通過引入 s (dotAll) 標誌來解決這個問題。 設定此標誌後,它會更改點 (.)元字元的行為以匹配換行符:

console.log(/./s.test('\n'));    // → true
console.log(/./s.test('\r'));    // → true
複製程式碼

s 標誌可以在每個正規表示式的基礎上使用,因此不會破壞依賴於點元字元的舊行為的現有模式。 除了 JavaScript 之外, s 標誌還可用於許多其他語言,如 Perl 和 PHP。

Unicode 屬性轉義

ES2015中引入的新功能包括Unicode感知。 但是即使設定了 u 標誌,速記字元類仍然無法匹配Unicode字元。

請考慮以下案例:

const str = '?';

console.log(/\d/.test(str));     // → false
console.log(/\d/u.test(str));    // → false
複製程式碼

?被認為是一個數字,但 \d 只能匹配ASCII [0-9],因此 test() 方法返回 false。 因為改變速記字元類的行為會破壞現有的正規表示式模式,所以決定引入一種新型別的轉義序列。

在ES2018中,當設定 u 標誌時,Unicode屬性轉義(由 \p{...} 表示)在正規表示式中可用。 現在要匹配任何Unicode 數字,你只需使用 \p{Number},如下所示:

const str = '?';
console.log(/\p{Number}/u.test(str));     // → true
複製程式碼

要匹配 Unicode 字元,你可以使用\p{Alphabetic}

const str = '漢';

console.log(/\p{Alphabetic}/u.test(str));     // → true

// the \w shorthand cannot match 漢
console.log(/\w/u.test(str));    // → false
複製程式碼

\P{...}\p{...} 的否定版本,並匹配 \p{...} 沒有的所有字元:

console.log(/\P{Number}/u.test('?'));    // → false
console.log(/\P{Number}/u.test('漢'));    // → true

console.log(/\P{Alphabetic}/u.test('?'));    // → true
console.log(/\P{Alphabetic}/u.test('漢'));    // → false
複製程式碼

當前規範提案中提供了受支援屬性的完整列表。

請注意,使用不受支援的屬性會導致 SyntaxError

console.log(/\p{undefined}/u.test('漢'));    // → SyntaxError
複製程式碼

相容性列表

桌面瀏覽器

Chrome Firefox Safari Edge
後行斷言 62 X X X
命名捕獲組 64 X 11.1 X
s (dotAll) Flag 62 X 11.1 X
Unicode 屬性轉義 64 X 11.1 X

移動瀏覽器

Chrome For Android Firefox For Android iOS Safari Edge Mobile Samsung Internet Android Webview
後行斷言 62 X X X 8.2 62
命名捕獲組 64 X 11.3 X X 64
s (dotAll) Flag 62 X 11.3 X 8.2 62
Unicode 屬性轉義 64 X 11.3 X X 64

NODE.JS

  • 8.3.0 (需要 --harmony 執行時標誌)
  • 8.10.0 (支援 s (dotAll) flag 和後行斷言)
  • 10.0.0 (完全支援)

總結

通過使正規表示式得到增強,ES2018 繼續了以前版本ECMAScript的工作。新功能包括後行斷言,命名捕獲組, s (dotAll) flag 和 Unicode屬性轉義。 後行斷言允許你在一個模式前面存在另一個模式進行匹配。與常規捕獲組相比,命名捕獲組使用了更具表現力的語法。 s (dotAll) flag 通過更改點(.)元字元的行為來匹配換行符。最後,Unicode 屬性轉義在正規表示式中提供了一種新型別的轉義序列。

在構建複雜的模式時,使用正規表示式測試程式通常很有幫助。一個好的測試器會提供一個介面來對字串的正規表示式進行測試,並顯示引擎所做的每一步,這在你理解其他人編寫的表示式時非常有幫助。它還可以檢測正規表示式中可能出現的語法錯誤。 Regex101 和 RegexBuddy 是兩個值得一試的正規表示式測試程式。

除此之外你能推薦其他的工具嗎?歡迎在評論中分享!

相關文章