JavaScript如何正確處理Unicode編碼問(wèn)題
JavaScript 處理 Unicode 的方式至少可以說(shuō)是令人驚訝的。本文解釋了 JavaScript 中的 處理 Unicode 相關(guān)的痛點(diǎn),提供了常見(jiàn)問(wèn)題的解決方案,并解釋了ECMAScript 6 標(biāo)準(zhǔn)如何改進(jìn)這種情況。
Unicode 基礎(chǔ)知識(shí)
在深入研究 JavaScript 之前,先解釋一下 Unicode 一些基礎(chǔ)知識(shí),這樣在 Unicode 方面,我們至少都了解一些。
Unicode是目前絕大多數(shù)程序使用的字符編碼,定義也很簡(jiǎn)單,用一個(gè) 碼位(code point) 映射一個(gè)字符。碼位值的范圍是從 U+0000
到 U+10FFFF
,可以表示超過(guò) 110 萬(wàn)個(gè)字符。下面是一些字符與它們的碼位。
- A 的碼位 U+0041
- a 的碼位 U+0061
- © 的碼位 U+00A9
- ☃ 的碼位 U+2603
- :hankey: 的碼位 U+1F4A9
碼位通常被格式化為十六進(jìn)制數(shù)字,零填充至少四位數(shù),格式為 U +前綴
。
Unicode 最前面的 65536 個(gè)字符位,稱(chēng)為 基本多文種平面(BMP-—Basic Multilingual Plane) ,又簡(jiǎn)稱(chēng)為“ 零號(hào)平面 ”, plane 0),它的 碼位 范圍是從 U+0000
到 U+FFFF
。最常見(jiàn)的字符都放在這個(gè)平面上,這是 Unicode ***定義和公布的一個(gè)平面。
剩下的字符都放在 輔助平面(Supplementary Plane) 或者 星形平面(astral planes) ,碼位范圍從 U+010000
一直到 U+10FFFF
,共 16 個(gè)輔助平面。
輔助平面內(nèi)的碼位很容易識(shí)別:如果需要超過(guò) 4 個(gè)十六進(jìn)制數(shù)字來(lái)表示碼位,那么它就是一個(gè)輔助平面內(nèi)的碼。
現(xiàn)在對(duì) Unicode 有了基本的了解,接下來(lái)看看它如何應(yīng)用于 JavaScript 字符串。
轉(zhuǎn)義序列
在谷歌控制臺(tái)輸入如下:
- >> '\x41\x42\x43'
- 'ABC'
- >> '\x61\x62\x63'
- 'abc'
以下稱(chēng)為十六進(jìn)制轉(zhuǎn)義序列。它們由引用匹配碼位的兩個(gè)十六進(jìn)制數(shù)字組成。例如, \x41
碼位為 U+0041
表示大寫(xiě)字母 A。這些轉(zhuǎn)義序列可用于 U+0000
到 U+00FF
范圍內(nèi)的碼位。
同樣常見(jiàn)的還有以下類(lèi)型的轉(zhuǎn)義:
- >> '\u0041\u0042\u0043'
- 'ABC'
- >> 'I \u2661 JavaScript!'
- 'I ♡ JavaScript!'
這些被稱(chēng)為 Unicode轉(zhuǎn)義序列 。它們由表示碼位的 4 個(gè)十六進(jìn)制數(shù)字組成。例如, \u2661
表示碼位為 \U+2661
表示一個(gè)心。這些轉(zhuǎn)義序列可以用于 U+0000
到 U+FFFF
范圍內(nèi)的碼位,即整個(gè)基本平面。
但是其他的所有輔助平面呢? 我們需要 4 個(gè)以上的十六進(jìn)制數(shù)字來(lái)表示它們的碼位,那么如何轉(zhuǎn)義它們呢?
在 ECMAScript 6中,這很簡(jiǎn)單,因?yàn)樗肓艘环N新的轉(zhuǎn)義序列: Unicode 碼位轉(zhuǎn)義 。例如:
- >> '\u{41}\u{42}\u{43}'
- 'ABC'
- >> '\u{1F4A9}'
- ':hankey:' // U+1F4A9 PILE OF POO
在大括號(hào)之間可以使用最多 6 個(gè)十六進(jìn)制數(shù)字,這足以表示所有 Unicode 碼位。因此,通過(guò)使用這種類(lèi)型的轉(zhuǎn)義序列,可以基于其代碼位輕松轉(zhuǎn)義任何 Unicode 碼位。
為了向后兼容 ECMAScript 5 和更舊的環(huán)境,不幸的解決方案是使用代理對(duì):
- >> '\uD83D\uDCA9'
- ':hankey:' // U+1F4A9 PILE OF POO
在這種情況下,每個(gè)轉(zhuǎn)義表示代理項(xiàng)一半的碼位。兩個(gè)代理項(xiàng)就組成一個(gè)輔助碼位。
注意,代理項(xiàng)對(duì)碼位與原始碼位全不同。 有公式 可以根據(jù)給定的輔助碼位來(lái)計(jì)算代理項(xiàng)對(duì)碼位,反之亦然——根據(jù)代理對(duì)計(jì)算原始輔助代碼位。
輔助平面(Supplementary Planes)中的碼位,在 UTF-16 中被編碼為一對(duì)16 比特長(zhǎng)的碼元(即32bit,4Bytes),稱(chēng)作 代理對(duì)(surrogate pair) ,具體方法是:
- 碼位減去
0x10000
,得到的值的范圍為 20 比特長(zhǎng)的0..0xFFFFF
. - 高位的 10 比特的值(值的范圍為
0..0x3FF
)被加上0xD800
得到***個(gè)碼元或稱(chēng)作 高位代理 。 - 低位的 10 比特的值(值的范圍也是
0..0x3FF
)被加上0xDC00
得到第二個(gè)碼元或稱(chēng)作 低位代理(low surrogate) ,現(xiàn)在值的范圍是0xDC00..0xDFFF
.
使用代理對(duì),所有輔助平面中的碼位(即從 U+010000
到 U+10FFFF
)都可以表示,但是使用一個(gè)轉(zhuǎn)義來(lái)表示基本平面的碼位,以及使用兩個(gè)轉(zhuǎn)義來(lái)表示輔助平面中的碼位,整個(gè)概念是令人困惑的,并且會(huì)產(chǎn)生許多惱人的后果。
使用 JavaScript 字符串方法來(lái)計(jì)算字符長(zhǎng)度
例如,假設(shè)你想要計(jì)算給定字符串中的字符個(gè)數(shù)。你會(huì)怎么做呢?
首先想到可能是使用 length
屬性。
- >> 'A'.length // 碼位: U+0041 表示 A
- 1
- >> 'A' == '\u0041'
- true
- >> 'B'.length // 碼位: U+0042 表示 B
- 1
- >> 'B' == '\u0042'
- true
在這些例子中,字符串的 length
屬性恰好反映了字符的個(gè)數(shù)。這是有道理的:如果我們使用轉(zhuǎn)義序列來(lái)表示字符,很明顯,我們只需要對(duì)每個(gè)字符進(jìn)行一次轉(zhuǎn)義。但情況并非總是如此!這里有一個(gè)稍微不同的例子:
- >> ' '.length // 碼位: U+1D400 表示 Math Bold 字體大寫(xiě) A
- 2
- >> ' ' == '\uD835\uDC00'
- true
- >> ' '.length // 碼位: U+1D401 表示 Math Bold 字體大寫(xiě) B
- 2
- >> ' ' == '\uD835\uDC01'
- true
- >> ':hankey:'.length // U+1F4A9 PILE OF POO
- 2
- >> ':hankey:' == '\uD83D\uDCA9'
- true
在內(nèi)部,JavaScript 將輔助平面內(nèi)的字符表示為代理對(duì),并將單獨(dú)的代理對(duì)部分開(kāi)為單獨(dú)的 “字符”。如果僅使用 ECMAScript 5 兼容轉(zhuǎn)義序列來(lái)表示字符,將看到每個(gè)輔助平面內(nèi)的字符都需要兩個(gè)轉(zhuǎn)義。這是令人困惑的,因?yàn)槿藗兺ǔS?Unicode 字符或圖形來(lái)代替。
計(jì)算輔助平面內(nèi)的字符個(gè)數(shù)
回到這個(gè)問(wèn)題:如何準(zhǔn)確地計(jì)算 JavaScript 字符串中的字符個(gè)數(shù) ? 訣竅就是如何正確地解析代理對(duì),并且只將每對(duì)代理對(duì)作為一個(gè)字符計(jì)數(shù)。你可以這樣使用:
- var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
- function countSymbols(string) {
- return string
- // Replace every surrogate pair with a BMP symbol.
- .replace(regexAstralSymbols, '_')
- // …and *then* get the length.
- .length;
- }
或者,如果你使用 Punycode.js ,利用它的實(shí)用方法在 JavaScript 字符串和 Unicode 碼位之間進(jìn)行轉(zhuǎn)換。 decode
方法接受一個(gè)字符串并返回一個(gè) Unicode 編碼位數(shù)組;每個(gè)字符對(duì)應(yīng)一項(xiàng)。
- function countSymbols(string) {
- return punycode.ucs2.decode(string).length;
- }
在 ES6 中,可以使用 Array.from 來(lái)做類(lèi)似的事情,它使用字符串的迭代器將其拆分為一個(gè)字符串?dāng)?shù)組,每個(gè)字符串?dāng)?shù)組包含一個(gè)字符:
- function countSymbols(string) {
- return Array.from(string).length;
- }
或者,使用解構(gòu)運(yùn)算符 ...
:
- function countSymbols(string) {
- return [...string].length;
- }
使用這些實(shí)現(xiàn),我們現(xiàn)在可以正確地計(jì)算碼位,這將導(dǎo)致更準(zhǔn)確的結(jié)果:
- >> countSymbols('A') // 碼位:U+0041 表示 A
- 1
- >> countSymbols(' ') // 碼位: U+1D400 表示 Math Bold 字體大寫(xiě) A
- 1
- >> countSymbols(':hankey:') // U+1F4A9 PILE OF POO
- 1
找撞臉
考慮一下這個(gè)例子:
- >> 'mañana' == 'mañana'
- false
JavaScript告訴我們,這些字符串是不同的,但視覺(jué)上,沒(méi)有辦法告訴我們!這是怎么回事?
JavaScript轉(zhuǎn)義工具 會(huì)告訴你,原因如下:
- >> 'ma\xF1ana' == 'man\u0303ana'
- false
- >> 'ma\xF1ana'.length
- 6
- >> 'man\u0303ana'.length
- 7
***個(gè)字符串包含碼位 U+00F1
表示字母 n 和 n 頭上波浪號(hào),而第二個(gè)字符串使用兩個(gè)單獨(dú)的碼位( U+006E
表示字母 n 和 U+0303
表示波浪號(hào))來(lái)創(chuàng)建相同的字符。這就解釋了為什么它們的長(zhǎng)度不同。
然而,如果我們想用我們習(xí)慣的方式來(lái)計(jì)算這些字符串中的字符個(gè)數(shù),我們希望這兩個(gè)字符串的長(zhǎng)度都為 6,因?yàn)檫@是每個(gè)字符串中可視可區(qū)分的字符的個(gè)數(shù)。要怎樣才能做到這一點(diǎn)呢?
在ECMAScript 6 中,解決方案相當(dāng)簡(jiǎn)單:
- function countSymbolsPedantically(string) {
- // Unicode Normalization, NFC form, to account for lookalikes:
- var normalized = string.normalize('NFC');
- // Account for astral symbols / surrogates, just like we did before:
- return punycode.ucs2.decode(normalized).length;
- }
String.prototype
上的 normalize
方法執(zhí)行 Unicode規(guī)范 化,這解釋了這些差異。 如果有一個(gè)碼位表示與另一個(gè)碼位后跟組合標(biāo)記相同的字符,則會(huì)將其標(biāo)準(zhǔn)化為單個(gè)碼位形式。
- >> countSymbolsPedantically('mañana') // U+00F1
- 6
- >> countSymbolsPedantically('mañana') // U+006E + U+0303
- 6
為了向后兼容 ECMAScript5 和舊環(huán)境,可以使用 String.prototype.normalize polyfill 。
計(jì)算其他組合標(biāo)記
然而,上述方案仍然不是***的——應(yīng)用多個(gè)組合標(biāo)記的碼位總是導(dǎo)致單個(gè)可視字符,但可能沒(méi)有 normalize 的形式,在這種情況下,normalize 是沒(méi)有幫助。例如:
- >> 'q\u0307\u0323'.normalize('NFC') // `q̣̇`
- 'q\u0307\u0323'
- >> countSymbolsPedantically('q\u0307\u0323')
- 3 // not 1
- >> countSymbolsPedantically('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞')
- 74 // not 6
如果需要更精確的解決方案,可以使用正則表達(dá)式從輸入字符串中刪除任何組合標(biāo)記。
- // 將下面的正則表達(dá)式替換為經(jīng)過(guò)轉(zhuǎn)換的等效表達(dá)式,以使其在舊環(huán)境中工作
- var regexSymbolWithCombiningMarks = /(\P{Mark})(\p{Mark}+)/gu;
- function countSymbolsIgnoringCombiningMarks(string) {
- // 刪除任何組合字符,只留下它們所屬的字符:
- var stripped = string.replace(regexSymbolWithCombiningMarks, function($0, symbol, combiningMarks) {
- return symbol;
- });
- return punycode.ucs2.decode(stripped).length;
- }
此函數(shù)刪除任何組合標(biāo)記,只留下它們所屬的字符。任何不匹配的組合標(biāo)記(在字符串開(kāi)頭)都保持不變。這個(gè)解決方案甚至可以在 ECMAScript3 環(huán)境中工作,并且它提供了迄今為止最準(zhǔn)確的結(jié)果:
- >> countSymbolsIgnoringCombiningMarks('q\u0307\u0323')
- 1
- >> countSymbolsIgnoringCombiningMarks('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞')
- 6
計(jì)算其他類(lèi)型的圖形集群
上面的算法仍然是一個(gè)簡(jiǎn)化—它還是無(wú)法正確計(jì)算像這樣的字符:நி,漢語(yǔ)言由連體的 Jamo 組成,如 깍, 表情字符序列,如 :man::woman::girl::boy: ((:man: U+200D
+ :woman: U+200D
+ :girl: + U+200D
+ :boy:)或其他類(lèi)似字符。
Unicode 文本分段上的 Unicode 標(biāo)準(zhǔn)附件#29 描述了用于確定字形簇邊界的算法。 對(duì)于適用于所有 Unicode腳本的完全準(zhǔn)確的解決方案 ,請(qǐng)?jiān)?JavaScript 中實(shí)現(xiàn)此算法,然后將每個(gè)字形集群計(jì)為單個(gè)字符。 有人建議將Intl.Segmenter(一種文本分段API)添加到ECMAScript中 。
JavaScript 中字符串反轉(zhuǎn)
下面是一個(gè)類(lèi)似問(wèn)題的示例:在JavaScript中反轉(zhuǎn)字符串。這能有多難,對(duì)吧? 解決這個(gè)問(wèn)題的一個(gè)常見(jiàn)的、非常簡(jiǎn)單的方法是:
- function reverse(string) {
- return string.split('').reverse().join('');
- }
它似乎在很多情況下都很有效:
- >> reverse('abc')
- 'cba'
- >> reverse('mañana') // U+00F1
- 'anañam'
然而,它完全打亂了包含組合標(biāo)記或位于輔助平面字符的字符串。
- >> reverse('mañana') // U+006E + U+0303
- 'anãnam' // note: the `~` is now applied to the `a` instead of the `n`
- >> reverse(':hankey:') // U+1F4A9
- '��' // `'\uDCA9\uD83D'`, the surrogate pair for `:hankey:` in the wrong order
要在 ES6 中正確反轉(zhuǎn)位于輔助平面字符,字符串迭代器可以與 Array.from
結(jié)合使用:
- function reverse(string) {
- return Array.from(string).reverse().join('');
- }
但是,這仍然不能解決組合標(biāo)記的問(wèn)題。
幸運(yùn)的是,一位名叫 Missy Elliot 的聰明的計(jì)算機(jī)科學(xué)家提出了一個(gè) 防彈算法 來(lái)解釋這些問(wèn)題。它看上去像這樣:
我把丁字褲放下,翻轉(zhuǎn),然后倒過(guò)來(lái)。我把丁字褲放下,翻轉(zhuǎn),然后倒過(guò)來(lái)。
事實(shí)上:通過(guò)將任何組合標(biāo)記的位置與它們所屬的字符交換,以及在進(jìn)一步處理字符串之前反轉(zhuǎn)任何代理對(duì),可以成功避免問(wèn)題。
- // 使用庫(kù) Esrever (https://mths.be/esrever)
- >> esrever.reverse('mañana') // U+006E + U+0303
- 'anañam'
- >> esrever.reverse(':hankey:') // U+1F4A9
- ':hankey:' // U+1F4A9
字符串方法中的 Unicode 的問(wèn)題
這種行為也會(huì)影響其他字符串方法。
將碼位轉(zhuǎn)轉(zhuǎn)換為字符
String.fromCharCode
可以將一個(gè)碼位轉(zhuǎn)換為字符。 但它只適用于 BMP 范圍內(nèi)的碼位 ( 即從 U+0000
到 U+FFFF
)。如果將它用于轉(zhuǎn)換超過(guò) BMP 平面外的碼位 ,將獲得意想不到的結(jié)果。
- >> String.fromCharCode(0x0041) // U+0041
- 'A' // U+0041
- >> String.fromCharCode(0x1F4A9) // U+1F4A9
- '' // U+F4A9, not U+1F4A9
唯一的解決方法是自己計(jì)算代理項(xiàng)一半的碼位,并將它們作為單獨(dú)的參數(shù)傳遞。
- >> String.fromCharCode(0xD83D, 0xDCA9)
- ':hankey:' // U+1F4A9
如果不想計(jì)算代理項(xiàng)的一半,可以使用 Punycode.js 的實(shí)用方法:
- >> punycode.ucs2.encode([ 0x1F4A9 ])
- ':hankey:' // U+1F4A9
幸運(yùn)的是,ECMAScript 6 引入了 String.fromCodePoint(codePoint)
,它可以位于基本平面外的碼位的字符。它可以用于任何 Unicode 編碼點(diǎn),即從 U+000000
到 U+10FFFF
。
- >> String.fromCodePoint(0x1F4A9)
- ':hankey:' // U+1F4A9
為了向后兼容ECMAScript 5 和更舊的環(huán)境,使用 String.fromCodePoint() polyfill
。
從字符串中獲取字符
如果使用 String.prototype.charAt(position)
來(lái)檢索包含字符串中的***個(gè)字符,則只能獲得***個(gè)代理項(xiàng)而不是整個(gè)字符。
- >> ':hankey:'.charAt(0) // U+1F4A9
- '\uD83D' // U+D83D, i.e. the first surrogate half for U+1F4A9
有人提議在 ECMAScript 7 中引入 String.prototype.at(position)
。它類(lèi)似于 charAt
,只不過(guò)它盡可能地處理完整的字符而不是代理項(xiàng)的一半。
- >> ':hankey:'.at(0) // U+1F4A9
- ':hankey:' // U+1F4A9
為了向后兼容 ECMAScript 5 和更舊的環(huán)境,可以使用 String.prototype.at() polyfill/prollyfill。
從字符串中獲取碼位
類(lèi)似地,如果使用 String.prototype.charCodeAt(position)
檢索字符串中***個(gè)字符的碼位,將獲得***個(gè)代理項(xiàng)的碼位,而不是 poo 字符堆的碼位。
- >> ':hankey:'.charCodeAt(0)
- 0xD83D
幸運(yùn)的是,ECMAScript 6 引入了 String.prototype.codePointAt(position)
,它類(lèi)似于 charCodeAt
,只不過(guò)它盡可能處理完整的字符而不是代理項(xiàng)的一半。
- >> ':hankey:'.codePointAt(0)
- 0x1F4A9
為了向后兼容 ECMAScript 5 和更舊的環(huán)境,使用 String.prototype.codePointAt()_polyfill。
遍歷字符串中的所有字符
假設(shè)想要循環(huán)字符串中的每個(gè)字符,并對(duì)每個(gè)單獨(dú)的字符執(zhí)行一些操作。
在 ECMAScript 5 中,你必須編寫(xiě)大量的樣板代碼來(lái)判斷代理對(duì)。
- function getSymbols(string) {
- var index = 0;
- var length = string.length;
- var output = [];
- for (; index < length - 1; ++index) {
- var charCode = string.charCodeAt(index);
- if (charCode >= 0xD800 && charCode <= 0xDBFF) {
- charCode = string.charCodeAt(index + 1);
- if (charCode >= 0xDC00 && charCode <= 0xDFFF) {
- output.push(string.slice(index, index + 2));
- ++index;
- continue;
- }
- }
- output.push(string.charAt(index));
- }
- output.push(string.charAt(index));
- return output;
- }
- var symbols = getSymbols(':hankey:');
- symbols.forEach(function(symbol) {
- console.log(symbol == ':hankey:');
- });
或者可以使用正則表達(dá)式,如 var regexCodePoint = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
并迭代匹配。
在 ECMAScript 6中,你可以簡(jiǎn)單地使用 for…of
。字符串迭代器處理整個(gè)字符,而不是代理對(duì)。
- for (const symbol of ':hankey:') {
- console.log(symbol == ':hankey:');
- }
不幸的是,沒(méi)有辦法對(duì)它進(jìn)行填充,因?yàn)?nbsp;for…of
是一個(gè)語(yǔ)法級(jí)結(jié)構(gòu)。
其他問(wèn)題
此行為會(huì)影響幾乎所有字符串方法,包括此處未明確提及的方法(如 String.prototype.substring
, String.prototype.slice
等),因此在使用它們時(shí)要小心。
正則表達(dá)式中的 Unicode 問(wèn)題
匹配碼位和 Unicode 標(biāo)量值
正則表達(dá)式中的點(diǎn)運(yùn)算符( .
)只匹配一個(gè)“字符”, 但是由于JavaScript將代理半部分公開(kāi)為單獨(dú)的 “字符”,所以它永遠(yuǎn)不會(huì)匹配位于輔助平面上的字符。
- >> /foo.bar/.test('foo:hankey:bar')
- false
讓我們思考一下,我們可以使用什么正則表達(dá)式來(lái)匹配任何 Unicode字符? 什么好主意嗎? 如下所示的, .
這w個(gè)是不夠的,因?yàn)樗黄ヅ鋼Q行符或整個(gè)位于輔助平面上的字符。
- >> /^.$/.test(':hankey:')
- false
為了正確匹配換行符,我們可以使用 [\s\S]
來(lái)代替,但這仍然不能匹配整個(gè)位于輔助平面上的字符。
- >> /^[\s\S]$/.test(':hankey:')
- false
事實(shí)證明,匹配任何 Unicode 編碼點(diǎn)的正則表達(dá)式一點(diǎn)也不簡(jiǎn)單:
- >> /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test(':hankey:') // wtf
- true
當(dāng)然,你不希望手工編寫(xiě)這些正則表達(dá)式,更不用說(shuō)調(diào)試它們了。為了生成像上面的一個(gè)正則表達(dá)式,可以使用了一個(gè)名為 Regenerate 的庫(kù),它可以根據(jù)碼位或字符列表輕松地創(chuàng)建正則表達(dá)式:
- >> regenerate().addRange(0x0, 0x10FFFF).toString()
- '[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]'
從左到右,這個(gè)正則表達(dá)式匹配BMP字符、代理項(xiàng)對(duì)或單個(gè)代理項(xiàng)。
雖然在 JavaScript 字符串中技術(shù)上允許使用單獨(dú)的代理,但是它們本身并不映射到任何字符,因此應(yīng)該避免使用。術(shù)語(yǔ) Unicode標(biāo)量值 指除代理碼位之外的所有碼位。下面是一個(gè)正則表達(dá)式,它匹配任何 Unicode 標(biāo)量值:
- >> regenerate()
- .addRange(0x0, 0x10FFFF) // all Unicode code points
- .removeRange(0xD800, 0xDBFF) // minus high surrogates
- .removeRange(0xDC00, 0xDFFF) // minus low surrogates
- .toRegExp()
- /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/
Regenerate 作為構(gòu)建腳本的一部分使用的,用于創(chuàng)建復(fù)雜的正則表達(dá)式,同時(shí)仍然保持生成這些表達(dá)式的腳本的可讀性和易于維護(hù)。
ECMAScript 6 為正則表達(dá)式引入一個(gè) u
標(biāo)志,它會(huì)使用 .
操作符匹配整個(gè)碼位,而不是代理項(xiàng)的一半。
- >> /foo.bar/.test('foo:hankey:bar')
- false
- >> /foo.bar/u.test('foo:hankey:bar')
- true
注意 .
操作符仍然不會(huì)匹配換行符,設(shè)置 u
標(biāo)志時(shí), .
操作符等效于以下向后兼容的正則表達(dá)式模式:
- >> regenerate()
- .addRange(0x0, 0x10FFFF) // all Unicode code points
- .remove( // minus `LineTerminator`s (https://ecma-international.org/ecma-262/5.1/#sec-7.3):
- 0x000A, // Line Feed <LF>
- 0x000D, // Carriage Return <CR>
- 0x2028, // Line Separator <LS>
- 0x2029 // Paragraph Separator <PS>
- )
- .toString();
- '[\0-\t\x0B\f\x0E-\u2027\u202A-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]'
- >> /foo(?:[\0-\t\x0B\f\x0E-\u2027\u202A-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])bar/u.test('foo:hankey:bar')
- true
位于輔助平面碼位上的字符
考慮到 /[a-c]/
匹配任何字符從 碼位為 U+0061
的字母 a 到 碼位為 U+0063
的字母 c,似乎/[:hankey:-:dizzy:]/ 會(huì)匹配碼位 U+1F4A9
到碼位 U+1F4AB
,然而事實(shí)并非如此:
- >> /[:hankey:-:dizzy:]/
- SyntaxError: Invalid regular expression: Range out of order in character class
發(fā)生這種情況的原因是,正則表達(dá)式等價(jià)于:
- >> /[\uD83D\uDCA9-\uD83D\uDCAB]/
- SyntaxError: Invalid regular expression: Range out of order in character class
事實(shí)證明,不像我們想的那樣匹配碼位 U+1F4A9
到碼位 U+1F4AB
,而是匹配正則表達(dá)式:
- U+D83D(高代理位)
- 從
U+DCA9
到U+D83D
的范圍(無(wú)效,因?yàn)槠鹗即a位大于標(biāo)記范圍結(jié)束的碼位) - U+DCAB(低代理位)
- >> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCA9') // match U+1F4A9
- true
- >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4A9}') // match U+1F4A9
- true
- >> /[:hankey:-:dizzy:]/u.test(':hankey:') // match U+1F4A9
- true
- >> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAA') // match U+1F4AA
- true
- >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AA}') // match U+1F4AA
- true
- >> /[:hankey:-:dizzy:]/u.test(':muscle:') // match U+1F4AA
- true
- >> /[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAB') // match U+1F4AB
- true
- >> /[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AB}') // match U+1F4AB
- true
- >> /[:hankey:-:dizzy:]/u.test(':dizzy:') // match U+1F4AB
- true
遺憾的是,這個(gè)解決方案不能向后兼容 ECMAScript 5 和更舊的環(huán)境。如果這是一個(gè)問(wèn)題,應(yīng)該使用 Regenerate 生成 es5兼容的正則表達(dá)式,處理輔助平面范圍內(nèi)的字符:
- >> regenerate().addRange(':hankey:', ':dizzy:')
- '\uD83D[\uDCA9-\uDCAB]'
- >> /^\uD83D[\uDCA9-\uDCAB]$/.test(':hankey:') // match U+1F4A9
- true
- >> /^\uD83D[\uDCA9-\uDCAB]$/.test(':muscle:') // match U+1F4AA
- true
- >> /^\uD83D[\uDCA9-\uDCAB]$/.test(':dizzy:') // match U+1F4AB
- true
實(shí)戰(zhàn)中的 bug 以及如何避免它們
這種行為會(huì)導(dǎo)致許多問(wèn)題。例如,Twitter 每條 tweet 允許 140 個(gè)字符,而它們的后端并不介意它是什么類(lèi)型的字符——是否為輔助平面內(nèi)的字符。但由于JavaScript 計(jì)數(shù)在其網(wǎng)站上的某個(gè)時(shí)間點(diǎn)只是讀出字符串的長(zhǎng)度,而不考慮代理項(xiàng)對(duì),因此不可能輸入超過(guò) 70 個(gè)輔助平面內(nèi)的字符。(這個(gè)bug已經(jīng)修復(fù)。)
許多處理字符串的JavaScript庫(kù)不能正確地解析輔助平面內(nèi)的字符。
例如,Countable.js 它沒(méi)有正確計(jì)算輔助平面內(nèi)的字符。
Underscore.string
有一個(gè) reverse 方法,它不處理組合標(biāo)記或輔助平面內(nèi)的字符。(改用 Missy Elliot 的算法)
它還錯(cuò)誤地解碼輔助平面內(nèi)的字符的 HTML 數(shù)字實(shí)體,例如 💩
。 許多其他 HTML 實(shí)體轉(zhuǎn)換庫(kù)也存在類(lèi)似的問(wèn)題。(在修復(fù)這些錯(cuò)誤之前,請(qǐng)考慮使用 he 代替所有 HTML 編碼/解碼需求。)