JavaScript 有個 Unicode 的天坑

發表於2016-12-05

最近筆者在專案中遇到了emoji表情的處理,期間發現js處理多位元組字元時會有較多坑,記錄一下與各位分享。

本文涉及知識點:

Unicode (BMP/SP)
UTF-8 UTF-16 UTF-32 UCS-2
javascript字元處理

Unicode

Unicode是目前絕大多數程式使用的字元編碼,定義也很簡單,用一個碼點(code point)對映一個字元。碼點值的範圍是從U+0000到U+10FFFF,可以表示超過110萬個符號。下面是一些符號與它們的碼點

  • A的碼點 U+0041
  • a的碼點 U+0061
  • ©的碼點 U+00A9
  • 的碼點 U+2603
  • ?的碼點 U+1F4A9

對於每個碼點,Unicode還會配上一小段文字說明,可以在codepoints.net查到,比如 ?的碼點說明

Unicode最前面的65536個字元位,稱為基本平面(BMP-—Basic Multilingual Plane),它的碼點範圍是從U+0000到U+FFFF。最常見的字元都放在這個平面,這是Unicode最先定義和公佈的一個平面。
剩下的字元都放在補充平面(Supplementary Plane),碼點範圍從U+010000一直到U+10FFFF,共16個。

UTF與UCS

UTF(Unicode transformation format)Unicode轉換格式,是服務於Unicode的,用於將一個Unicode碼點轉換為特定的位元組序列。常見的UTF有

UTF-8 可變位元組序列,用1到4個位元組表示一個碼點
UTF-16 可變位元組序列,用2或4個位元組表示一個碼點
UTF-32 固定位元組序列,用4個位元組表示一個碼點

UTF-8對ASCⅡ編碼是相容的,都是一個位元組,超過U+07FF的部分則用了複雜的轉換方式來對映Unicode,具體不再詳述。

UTF-16對於BMP的碼點,採用2個位元組進行編碼,而BMP之外的碼點,用4個位元組組成代理對(surrogate pair)來表示。其中前兩個位元組範圍是U+D800到U+DBFF,後兩個位元組範圍是U+DC00到U+DFFF,通過以下公式完成對映(H:高位元組 L:低位元組 c:碼點)
H = Math.floor((c-0x10000) / 0x400)+0xD800
L = (c – 0x10000) % 0x400 + 0xDC00

比如 ? 用UTF-16表示就是”\uD83D\uDCA9″

UCS(Universal Character Set)通用字符集,是一個ISO標準,目前與Unicode可以說是等價的。
相對於UTF,UCS也有自己的轉換方法(編碼)。如

UCS-2 用2個位元組表示BMP的碼點
UCS-4 用4個位元組表示碼點

UCS-2是一個過時的編碼方式,因為它只能編碼基本平面(BMP)的碼點,在BMP的編碼上,與UTF-16是一致的,所以可以認為是UTF-16的一個子集。
UCS-4則與UTF-32等價,都是用4個位元組來編碼Unicode。

javascript字元處理

辣莫,js到底是用的啥編碼呢?答案是UCS-2。咦,剛剛不是說UCS-2過時了嗎?首先看下年表

1990 UCS-2 誕生
1995.5 JavaScript 誕生
1996.7 UTF-16 誕生

也就是說,Brendan Eich在寫JS的時候,UTF-16還沒問世,所以只能用UCS-2的方式來處理字元,也因此留下了隱患。

坑1——length屬性

先看一個簡單的例子:

>”\uD83D\uDCA9″ === “?”
>true
>”?”.length
>2

因為”?”在JS的編碼是”\uD83D\uDCA9″,而JS認為每16位(2位元組)即表示一個字元,所以一坨大便是佔2個字元的。我們經常用length來判斷字串長度,那產品不幹了呀,說好可以輸入10個字,為毛輸了5個emoji就不給輸入了?
怎麼破?可以用萬能的正則匹配

坑2——反轉字串

js裡怎麼反轉(reverse)字串?相信有些同學已經想到了一個極簡的方案

js雖沒有直接的反轉字串的API,但是陣列有啊,轉陣列反轉之後再轉回字串,嘿嘿嘿,是不是很機智?這時候Unicode大爺又出來打臉了:你們吶,sometimes naive!

拿剛才的函式反轉帶有?的字串試試

�的Unicode碼點是+UFFFD,通常用來表示Unicode轉換時無法識別的字元(也就是亂碼)

當?(\uD83D\uDCA9)通過上述方法反轉時,變成\uDCA9\uD83D,不是一個合法的代理對(高低位元組範圍不同),同時,Unicode規定代理對範圍內的碼點不能單獨出現,所以js只能用�表示了。
怎麼破?

  1. ES6的Array.from支援代理對的解析
  1. 使用 Esrever (reverse反轉之後就是esrever…)

坑3——碼點與字元互轉

String.fromCharCode可以將一個碼點轉換為字元,比如

但超過BMP平面的就跪了。

事實上這個API是支援倆引數的,分別是代理對的高低位元組。所以需要通過公式計算出對應的高低位元組

一個字,蛋疼!
怎麼破? ES6大法好。

坑4——正則匹配

正則匹配符.只能匹配單個“字元”,但js將代理對當成兩個單獨的“字元”處理,所以匹配不到任何輔助平面字元。

思考一下,什麼正規表示式可以表示任何Unicode字元? 顯然.是不夠的,因為它不能匹配輔助平面字元或者換行符。那麼用\s\S呢?

懷疑人生了~~正確的匹配任意Unicode字元的正則如下:

怎麼破? ES6給出一個簡單的方法——增加一個u標誌

注意:這裡的.還是不能匹配換行符。

ES6的Unicode支援

從上面的例子中可以看出,ES6已經在很努力地填坑了。對於Unicode字元,ES6支援新的表示方法 \u{1F4A9} 加上花括號後,可以把碼點直接填進去來表示,而不用去計算代理對。再補充2點:
1. 為了向後相容,字串的length屬性還是用雙位元組判斷的,所以要用Array.from(str).length。
2. 遍歷字串的時候,可以用for(let s of str) {}

參考資料:
Unicode與JavaScript詳解
JavaScript has a Unicode problem

相關文章