最近筆者在專案中遇到了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+0041a
的碼點 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就不給輸入了?
怎麼破?可以用萬能的正則匹配
1 2 3 4 5 6 7 8 9 10 |
var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; // 匹配UTF-16的代理對 function countSymbols(string) { return string // 把代理對改為一個BMP的字元. .replace(regexAstralSymbols, '_') // …這時候取長度就妥妥的啦. .length; } countSymbols('?'); // 1 |
坑2——反轉字串
js裡怎麼反轉(reverse)字串?相信有些同學已經想到了一個極簡的方案
1 2 3 |
function reverse(str) { return str.split('').reverse().join(''); } |
js雖沒有直接的反轉字串的API,但是陣列有啊,轉陣列反轉之後再轉回字串,嘿嘿嘿,是不是很機智?這時候Unicode大爺又出來打臉了:你們吶,sometimes naive!
拿剛才的函式反轉帶有?的字串試試
1 2 |
reverse('這是一坨?') "��坨一是這" |
�的Unicode碼點是+UFFFD,通常用來表示Unicode轉換時無法識別的字元(也就是亂碼)
當?(\uD83D\uDCA9)通過上述方法反轉時,變成\uDCA9\uD83D,不是一個合法的代理對(高低位元組範圍不同),同時,Unicode規定代理對範圍內的碼點不能單獨出現,所以js只能用�表示了。
怎麼破?
- ES6的Array.from支援代理對的解析
123function reverse(string) {return Array.from(string).reverse().join('');}
- 使用 Esrever (reverse反轉之後就是esrever…)
坑3——碼點與字元互轉
String.fromCharCode可以將一個碼點轉換為字元,比如
1 2 |
String.fromCharCode(0x0041) 'A' |
但超過BMP平面的就跪了。
1 2 |
>> String.fromCharCode(0x1F4A9) // U+1F4A9 '' // U+F4A9, not U+1F4A9 |
事實上這個API是支援倆引數的,分別是代理對的高低位元組。所以需要通過公式計算出對應的高低位元組
1 2 3 4 |
>> String.fromCharCode(0xD83D, 0xDCA9) '?' // U+1F4A9 >> '?'.charCodeAt(0) 0xD83D |
一個字,蛋疼!
怎麼破? ES6大法好。
1 2 3 4 |
>> String.fromCodePoint(0x1F4A9) '?' // U+1F4A9 >> '?'.codePointAt(0) 0x1F4A9 |
坑4——正則匹配
正則匹配符.
只能匹配單個“字元”,但js將代理對當成兩個單獨的“字元”處理,所以匹配不到任何輔助平面字元。
1 2 |
>> /foo.bar/.test('foo?bar') false |
思考一下,什麼正規表示式可以表示任何Unicode字元? 顯然.
是不夠的,因為它不能匹配輔助平面字元或者換行符。那麼用\s\S呢?
1 2 |
>> /^[\s\S]$/.test('?') false |
懷疑人生了~~正確的匹配任意Unicode字元的正則如下:
1 2 |
>> /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test('?') // wtf true |
怎麼破? ES6給出一個簡單的方法——增加一個u標誌
1 2 |
>> /foo.bar/u.test('foo?bar') true |
注意:這裡的.
還是不能匹配換行符。
ES6的Unicode支援
從上面的例子中可以看出,ES6已經在很努力地填坑了。對於Unicode字元,ES6支援新的表示方法 \u{1F4A9}
加上花括號後,可以把碼點直接填進去來表示,而不用去計算代理對。再補充2點:
1. 為了向後相容,字串的length屬性還是用雙位元組判斷的,所以要用Array.from(str).length。
2. 遍歷字串的時候,可以用for(let s of str) {}