正則表達式在 ES2018 中的新寫法 [每日前端夜話0x25]
京程一燈 前端先鋒
每日前端夜話0x25
每日前端夜話,陪你聊前端。
每天晚上18:00準時推送。
正文共:8459 字
預計閱讀時間: 19 分鐘
翻譯:瘋狂的技術宅
原文:https://www.smashingmagazine.com/2019/02/regexp-features-regular-expressions/
摘要:如果你曾用 JavaScript 做過複雜的文本處理和操作,那麼你將會對 ES2018 中引入的新功能愛不釋手。 在本文中,我們將詳細介紹第 9 版標準如何提高 JavaScript 的文本處理能力。
有一個很好的理由能夠解釋爲什麼大多數編程語言都支持正則表達式:它們是用於處理文本的極其強大的工具。 通常一行正則表達式代碼就能完成需要幾十行代碼才能搞定的文本處理任務。 雖然大多數語言中的內置函數足以對字符串進行一般的搜索和替換操作,但更加複雜的操作(例如驗證文本輸入)通常需要使用正則表達式。
自從 1999 年推出 ECMAScript 標準第 3 版以來,正則表達式已成爲 JavaScript 語言的一部分。ECMAScript 2018(簡稱ES2018)是該標準的第 9 版,通過引入四個新功能進一步提高了JavaScript的文本處理能力:
- 後行斷言
- 命名捕獲組
- s (dotAll) flag
- Unicode屬性轉義
下面詳細介紹這些新功能。
後行斷言
能夠根據之後或之前的內容匹配一系列字符,使你可以丟棄可能不需要的匹配。 當你需要處理大字符串並且意外匹配的可能性很高時,這個功能非常有用。 幸運的是,大多數正則表達式都爲此提供了 lookbehind 和 lookahead 斷言。
在 ES2018 之前,JavaScript 中只提供了先行斷言。 lookahead 允許你在一個斷言模式後緊跟另一個模式。
先行斷言有兩種版本:正向和負向。 正向先行斷言的語法是 (?=...)。 例如,正則表達式 /Item(?= 10)/ 僅在後面跟隨有一個空格和數字 10 的時候才與 Item 匹配:
1const re = /Item(?= 10)/;
2
3console.log(re.exec('Item'));
4// → null
5
6console.log(re.exec('Item5'));
7// → null
8
9console.log(re.exec('Item 5'));
10// → null
11
12console.log(re.exec('Item 10'));
13// → ["Item", index: 0, input: "Item 10", groups: undefined]
此代碼使用 exec() 方法在字符串中搜索匹配項。 如果找到匹配項, exec() 將返回一個數組,其中第一個元素是匹配的字符串。 數組的 index 屬性保存匹配字符串的索引, input 屬性保存搜索執行的整個字符串。 最後,如果在正則表達式中使用了命名捕獲組,則將它們放在 groups 屬性中。 在代碼中, groups 的值爲 undefined ,因爲沒有被命名的捕獲組。
負向先行的構造是 (?!...) 。 負向先行斷言的模式後面沒有特定的模式。 例如, /Red(?!head)/ 僅在其後不跟隨 head 時匹配 Red :
1const re = /Red(?!head)/;
2
3console.log(re.exec('Redhead'));
4// → null
5
6console.log(re.exec('Redberry'));
7// → ["Red", index: 0, input: "Redberry", groups: undefined]
8
9console.log(re.exec('Redjay'));
10// → ["Red", index: 0, input: "Redjay", groups: undefined]
11
12console.log(re.exec('Red'));
13// → ["Red", index: 0, input: "Red", groups: undefined]
ES2018 爲 JavaScript 補充了後行斷言。 用 (?<=...) 表示,後行斷言允許你在一個模式前面存在另一個模式時進行匹配。
假設你需要以歐元檢索產品的價格但是不捕獲歐元符號。 通過後行斷言,會使這項任務變得更加簡單:
1const re = /(?<=€)\d+(\.\d*)?/;
2
3console.log(re.exec('199'));
4// → null
5
6console.log(re.exec('$199'));
7// → null
8
9console.log(re.exec('€199'));
10// → ["199", undefined, index: 1, input: "€199", groups: undefined]
注意:先行(Lookahead)和後行(lookbehind)斷言通常被稱爲“環視”(lookarounds)。
後行斷言的反向版本由 (?<!...) 表示,使你能夠匹配不在lookbehind中指定的模式之前的模式。 例如,正則表達式 /(?<!\d{3}) meters/ 會在 三個數字不在它之前 匹配單詞“meters”如果:
1const re = /(?<!\d{3}) meters/;
2
3console.log(re.exec('10 meters'));
4// → [" meters", index: 2, input: "10 meters", groups: undefined]
5
6console.log(re.exec('100 meters'));
7// → null
與前行斷言一樣,你可以連續使用多個後行斷言(負向或正向)來創建更復雜的模式。下面是一個例子:
1const re = /(?<=\d{2})(?<!35) meters/;
2
3console.log(re.exec('35 meters'));
4// → null
5
6console.log(re.exec('meters'));
7// → null
8
9console.log(re.exec('4 meters'));
10// → null
11
12console.log(re.exec('14 meters'));
13// → ["meters", index: 2, input: "14 meters", groups: undefined]
此正則表達式僅匹配包含“meters”的字符串,如果它前面緊跟 35 之外的任何兩個數字。正向後行確保模式前面有兩個數字,同時負向後行能夠確保該數字不是 35。
命名捕獲組
你可以通過將字符封裝在括號中的方式對正則表達式的一部分進行分組。 這可以允許你將規則限制爲模式的一部分或在整個組中應用量詞。 此外你可以通過括號來提取匹配值並進行進一步處理。
下列代碼給出瞭如何在字符串中查找帶有 .jpg 並提取文件名的示例:
1const re = /(\w+)\.jpg/;
2const str = 'File name: cat.jpg';
3const match = re.exec(str);
4const fileName = match[1];
5
6// The second element in the resulting array holds the portion of the string that parentheses matched
7console.log(match);
8// → ["cat.jpg", "cat", index: 11, input: "File name: cat.jpg", groups: undefined]
9
10console.log(fileName);
11// → cat
在更復雜的模式中,使用數字引用組只會使本身就已經很神祕的正則表達式的語法更加混亂。 例如,假設你要匹配日期。 由於在某些國家和地區會交換日期和月份的位置,因此會弄不清楚究竟哪個組指的是月份,哪個組指的是日期:
1const re = /(\d{4})-(\d{2})-(\d{2})/;
2const match = re.exec('2020-03-04');
3
4console.log(match[0]); // → 2020-03-04
5console.log(match[1]); // → 2020
6console.log(match[2]); // → 03
7console.log(match[3]); // → 04
ES2018針對此問題的解決方案名爲捕獲組,它使用更具表現力的 (?<name>...) 形式的語法:
1const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
2const match = re.exec('2020-03-04');
3
4console.log(match.groups); // → {year: "2020", month: "03", day: "04"}
5console.log(match.groups.year); // → 2020
6console.log(match.groups.month); // → 03
7console.log(match.groups.day); // → 04
因爲生成的對象可能會包含與命名組同名的屬性,所以所有命名組都在名爲 groups 的單獨對象下定義。
許多新的和傳統的編程語言中都存在類似的結構。 例如Python對命名組使用 (?P<name>) 語法。 Perl支持與 JavaScript 相同語法的命名組( JavaScript 已經模仿了 Perl 的正則表達式語法)。 Java也使用與Perl相同的語法。
除了能夠通過 groups 對象訪問命名組之外,你還可以用編號引用訪問組—— 類似於常規捕獲組:
1const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
2const match = re.exec('2020-03-04');
3
4console.log(match[0]); // → 2020-03-04
5console.log(match[1]); // → 2020
6console.log(match[2]); // → 03
7console.log(match[3]); // → 04
新語法也適用於解構賦值:
1const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
2const [match, year, month, day] = re.exec('2020-03-04');
3
4console.log(match); // → 2020-03-04
5console.log(year); // → 2020
6console.log(month); // → 03
7console.log(day); // → 04
即使正則表達式中不存在命名組,也始終創建 groups 對象:
1const re = /\d+/;
2const match = re.exec('123');
3
4console.log('groups' in match); // → true
如果可選的命名組不參與匹配,則 groups 對象仍將具有命名組的屬性,但該屬性的值爲 undefined:
1const re = /\d+(?<ordinal>st|nd|rd|th)?/;
2
3let match = re.exec('2nd');
4
5console.log('ordinal' in match.groups); // → true
6console.log(match.groups.ordinal); // → nd
7
8match = re.exec('2');
9
10console.log('ordinal' in match.groups); // → true
11console.log(match.groups.ordinal); // → undefined
你可以稍後在模式中引用常規捕獲的組,並使用 \1 的形式進行反向引用。 例如以下代碼使用在行中匹配兩個字母的捕獲組,然後在模式中調用它:
1console.log(/(\w\w)\1/.test('abab')); // → true
2
3// if the last two letters are not the same
4// as the first two, the match will fail
5console.log(/(\w\w)\1/.test('abcd')); // → false
要在模式中稍後調用命名捕獲組,可以使用 /\k<name>/ 語法。 下面是一個例子:
1const re = /\b(?<dup>\w+)\s+\k<dup>\b/;
2
3const match = re.exec("I'm not lazy, I'm on on energy saving mode");
4
5console.log(match.index); // → 18
6console.log(match[0]); // → on on
此正則表達式在句子中查找連續的重複單詞。 如果你願意,還可以用帶編號的後引用來調用命名的捕獲組:
1const re = /\b(?<dup>\w+)\s+\1\b/;
2
3const match = re.exec("I'm not lazy, I'm on on energy saving mode");
4
5console.log(match.index); // → 18
6console.log(match[0]); // → on on
也可以同時使用帶編號的後引用和命名後向引用:
1const re = /(?<digit>\d):\1:\k<digit>/;
2
3const match = re.exec('5:5:5');
4
5console.log(match[0]); // → 5:5:5
與編號的捕獲組類似,可以將命名的捕獲組插入到 replace() 方法的替換值中。 爲此,你需要用到 $<name> 構造。 例如:
onst str = 'War & Peace';
2
onsole.log(str.replace(/(War) & (Peace)/, '$2 & $1'));
/ → Peace & War
5
onsole.log(str.replace(/(?<War>War) & (?<Peace>Peace)/, '$<Peace> & $<War>'));
/ → Peace & War
如果要使用函數執行替換,則可以引用命名組,方法與引用編號組的方式相同。 第一個捕獲組的值將作爲函數的第二個參數提供,第二個捕獲組的值將作爲第三個參數提供:
1const str = 'War & Peace';
2
3const result = str.replace(/(?<War>War) & (?<Peace>Peace)/, function(match, group1, group2, offset, string) {
4 return group2 + ' & ' + group1;
5});
6
7console.log(result); // → Peace & War
s (dotAll) Flag
默認情況下,正則表達式模式中的點 (.) 元字符匹配除換行符 (\n) 和回車符 (\r)之外的所有字符:
1console.log(/./.test('\n')); // → false
2console.log(/./.test('\r')); // → false
儘管有這個缺點,JavaScript 開發者仍然可以通過使用兩個相反的速記字符類來匹配所有字符,例如[\ w \ W],它告訴正則表達式引擎匹配一個字符(\w)或非單詞字符(\W):
1console.log(/[\w\W]/.test('\n')); // → true
2console.log(/[\w\W]/.test('\r')); // → true
ES2018旨在通過引入 s (dotAll) 標誌來解決這個問題。 設置此標誌後,它會更改點 (.)元字符的行爲以匹配換行符:
1console.log(/./s.test('\n')); // → true
2console.log(/./s.test('\r')); // → true
s 標誌可以在每個正則表達式的基礎上使用,因此不會破壞依賴於點元字符的舊行爲的現有模式。 除了 JavaScript 之外, s 標誌還可用於許多其他語言,如 Perl 和 PHP。
Unicode 屬性轉義
ES2015中引入的新功能包括Unicode感知。 但是即使設置了 u 標誌,速記字符類仍然無法匹配Unicode字符。
請考慮以下案例:
1const str = '