Javascript有個Unicode的天坑

rugw7343 8年前發布 | 14K 次閱讀 Unicode JavaScript開發 JavaScript

最近筆者在項目中遇到了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
  • :hankey: 的碼點 U+1F4A9

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

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

比如:hankey:用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″ === “:hankey:”

>true

>”:hankey:”.length

>2

因為”:hankey:”在JS的編碼是”\uD83D\uDCA9″,而JS認為每16位(2字節)即表示一個字符,所以一坨大便是占2個字符的。我們經常用length來判斷字符串長度,那產品不干了呀,說好可以輸入10個字,為毛輸了5個emoji就不給輸入了?

怎么破?可以用萬能的正則匹配

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; // 匹配UTF-16的代理對
 
function countSymbols(string) {
 return string
 // 把代理對改為一個BMP的字符.
 .replace(regexAstralSymbols, '_')
 // …這時候取長度就妥妥的啦.
 .length;
}
countSymbols(':hankey:'); // 1

坑2——反轉字符串

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

function reverse(str) {
    return str.split('').reverse().join('');
}

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

拿剛才的函數反轉帶有:hankey:的字符串試試

reverse('這是一坨:hankey:')
"??坨一是這"

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

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

怎么破?

  1. ES6的Array.from支持代理對的解析
function reverse(string) {
 return Array.from(string).reverse().join('');
}
  1. 使用 Esrever (reverse反轉之后就是esrever…)

坑3——碼點與字符互轉

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

String.fromCharCode(0x0041)
'A'

但超過BMP平面的就跪了。

>> String.fromCharCode(0x1F4A9) // U+1F4A9
'?' // U+F4A9, not U+1F4A9

事實上這個API是支持倆參數的,分別是代理對的高低字節。所以需要通過公式計算出對應的高低字節

>> String.fromCharCode(0xD83D, 0xDCA9)
':hankey:' // U+1F4A9
>> ':hankey:'.charCodeAt(0)
0xD83D

一個字,蛋疼!

怎么破? ES6大法好。

>> String.fromCodePoint(0x1F4A9)
':hankey:' // U+1F4A9
>> ':hankey:'.codePointAt(0)
0x1F4A9

坑4——正則匹配

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

>> /foo.bar/.test('foo:hankey:bar')
false

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

``
>> /^[\s\S]$/.test(':hankey:')
false
```

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

>> /[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test(':hankey:') // wtf
true

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

>> /foo.bar/u.test('foo:hankey:bar')
true

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

ES6的Unicode支持

從上面的例子中可以看出,ES6已經在很努力地填坑了。對于Unicode字符,ES6支持新的表示方法

\u{1F4A9} 加上花括號后,可以把碼點直接填進去來表示,而不用去計算代理對。再補充2點:

1. 為了向后兼容,字符串的length屬性還是用雙字節判斷的,所以要用Array.from(str).length。

2. 遍歷字符串的時候,可以用 for(let s of str) {}

 

 

來自:http://www.alloyteam.com/2016/12/javascript-has-a-unicode-sinkhole/

 

 本文由用戶 rugw7343 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!