在 JavaScript 中處理中文和其他 Unicode 字符時,我們會用到處理 Unicode 相關的 API。
在早期,JavaScript 提供的String.prototype.charCodeAt
和String.fromCharCode
就是能夠將字符串轉換為 Unicode 的 UTF-16 編碼以及從 UTF-16 編碼轉換為字符串的函數。
比如:
const str = '中文';
console.log([...str].map(char => char.charCodeAt(0)));
// [20013, 25991]
複製代碼
這裏我們將字符串展開成單個字符,再通過 charCodeAt 方法將字符串轉換為對應的 Unicode 編碼,這裏的 20013 和 25991 就是 “中文” 兩個字對應的 Unicode 編碼。
同樣,我們可以使用 fromCharCode 將 Unicode 編碼轉換為字符串:
const charCodes = [20013, 25991];
console.log(String.fromCharCode(...charCodes)); // 中文
複製代碼
這兩個方法相信大部分同學都不陌生,這是從 ES3 就開始支持的方法。但是,這個方法在今天我們處理 Unicode 字符時不夠用了。
為什麼呢?我們來看一下例子:
const str = '🀄';
console.log(str.charCodeAt(0)); // 55356
複製代碼
這個字符是我們熟悉的麻將中的紅中,現在很多輸入法都能直接打出來,看上去似乎也正常,沒什麼問題啊?
可你再試試:
console.log(String.fromCharCode(55356)); // �
複製代碼
實際上 Unicode 字符🀄的 UTF-16 編碼並不是 55356,這時候如果你使用 charCodeAt 來得到字符🀄的 UTF-16 編碼,應該要到兩個值:
const str = '🀄';
console.log(str.charCodeAt(0), str.charCodeAt(1)); // 55356 56324
複製代碼
對應的String.fromCharCode(55356, 56324)
才能還原🀄字符。
除此以外,還有其他一些不一樣的地方,比如:
console.log('🀄'.length); // 字符串長度為2
'🀄'.split(''); // ["�", "�"] split 出來兩個字符
/^.$/.test('🀄'); // false
複製代碼
👉🏻知識點:Unicode 標準中,將字符編碼的碼位以2**16
個為一組,組成為一個平面(Plane),按照字符的碼位值,分為 17 個平面,所有碼位從 0x000000 到 0x10FFFF,總共使用 3 個字節。
其中最前面的 1 個字節是平面編號,從 0x0 到 0x10,一共 17 個平面。
第 0 號平面被稱為基本多文種平面(BMP,Basic Multilingual Plane),這個平面的所有字符碼位只需要 16 位編碼單元即可表示,所以它們可以繼續使用 UTF-16 編碼。
其他的平面被稱為輔助平面(supplementary plane),這些平面的字符被稱為增補字符,它們的碼位均超過 16 位範圍。
ES5 及之前的 JavaScript 的 Unicode 相關 API,只能以 UTF-16 來處理 BMP 的字符,所有字符串的操作都是基於 16 位編碼單元。
因此,當🀄這樣的增補字符出現時,得到的結果就會與預期不符。
在 ES2015 之後,JavaScript 提供了新的 API 來支持 Unicode 碼位,所以我們可以這麼使用:
const str = '🀄';
console.log(str.codePointAt(0)); // 126980
複製代碼
👉🏻 知識點:String.prototype.codePointAt(index)
方法返回字符串指定 index 位置的字符的 Unicode 碼位,與舊的 charCodeAt 方法相比,它能夠很好地支持增補字符。
對應地,我們有String.fromCodePoint
方法將 CodePoint 轉為對應的字符:
console.log(String.fromCodePoint(126980)); // 🀄
複製代碼
Unicode 轉義
JavaScript 字符串支持 Unicode 轉義,所以我們可以用碼位的十六進制字符串加上前綴\u
來表示一個字符,例如:
console.log('\u4e2d\u6587'); // 中文
複製代碼
0x4e2d
和0x6587
分別是 20013 和 25991 的十六進制表示。
注意,Unicode 轉義不僅僅可以用於字符串,實際上 \ uxxxx 也是可以用在標識符,並相互轉換的。例如我們可以這麼寫:
const \u4e2d\u6587 = '測試';
console.log(中文); // 測試
複製代碼
上面的代碼我們定義了一箇中文變量,聲明的時候我們用 Unicode 轉義,console.log 的時候用它的變量名字符,這樣也是沒有問題的。
\u 和十六進制字符的這種表示法同樣只適用於 BMP 的字符,所以如果我們試圖使用它轉義增補字符,直接這樣是不行的:
console.log('\u1f004'); // 4
複製代碼
這樣,引擎會把\u1f004
解析成字符\u1f00
和阿拉伯數字 4 組成的字符串。我們需要使用{}
將編碼包含起來,這樣就可以了:
console.log('\u{1f004}'); // 🀄
複製代碼
代理對(surrogate pair)
為區別 BMP 來表示輔助平面,Unicode 引入代理對 (surrogate pair),規定用 2 個 16 位編碼單元來表示一個碼位,具體規則是將一個字符按如下表示:
- 在 BMP 內的字符,仍然按照 UTF-16 的編碼規則,使用兩個字節來表示。
- 增補字符使用兩組 16 位編碼來表示一個字符規則為:
- 首先將它的編碼減去 0x10000
- 然後寫成 yyyy yyyy yyxx xxxx xxxx 的 20 位二進制形式
- 然後編碼為 110110yy yyyyyyyy 110111xx xxxxxxxx 一共 4 個字節。
其中 110110yyyyyyyyyy 和 110111xxxxxxxxxx 就是兩個代理字符,形成一組代理對,其中第一個代理字符的範圍從 U+D800 到 U+DBFF,第二個代理字符的範圍從 U+DC00 到 U+DFFF。
實現 getCodePoint
理解了代理對,我們就可以通過 charCodeAt 實現 getCodePoint 了:
function getCodePoint(str, idx = 0) {
const code = str.charCodeAt(idx);
if(code >= 0xD800 && code <= 0xDBFF) {
const high = code;
const low = str.charCodeAt(idx + 1);
return ((high - 0xD800) * 0x400) +
(low - 0xDC00) + 0x10000;
}
return code;
}
console.log(getCodePoint('中')); // 20013
console.log(getCodePoint('🀄')); // 126980
複製代碼
同樣地,我們也可以通過 fromCharCode 實現 fromCodePoint:
function fromCodePoint(...codePoints) {
let str = '';
for(let i = 0; i < codePoints.length; i++) {
let codePoint = codePoints[i];
if(codePoint <= 0xFFFF) {
str += String.fromCharCode(codePoint);
} else {
codePoint -= 0x10000;
const high = (codePoint >> 10) + 0xD800;
const low = (codePoint % 0x400) + 0xDC00;
str += String.fromCharCode(high) + String.fromCharCode(low);
}
}
return str;
}
console.log(fromCodePoint(126980, 20013)); // 🀄中
複製代碼
所以我們就可以用上面這樣的思路來實現早期瀏覽器下的 polyfill。實際上 MDN 官方對 codePointAt 和 fromCodePoint 的説明中,就按照上面的思路提供了對應的 polyfill 方法。
getCodePointCount
JavaScript 字符串的 length 只能獲得 UTF-16 字符的個數,所以前面看到的:
console.log('🀄'.length); // 字符串長度為2
複製代碼
要獲得 Unicode 字符數,有幾個辦法,比如使用 spread 操作是可以支持 Unicode 字符串轉數組的,所以:
function getCodePointCount(str) {
return [...str].length;
}
console.log(getCodePointCount('👫中'));
複製代碼
或者使用帶有 u 描述符的正則表達式:
function getCodePointCount(str) {
let result = str.match(/./gu);
return result ? result.length : 0;
}
console.log(getCodePointCount('👫中'));
複製代碼
擴展
Unicode 碼位使用固定的 4 個字節來編碼增補字符,而早期,UTF-8 編碼則採用可變的 1~6 個字節來編碼 Unicode 字符。
UTF-8 編碼方式如下:
字節 | 起始 | 終止 | byte1 | byte2 | byte3 | byte4 | byte5 | byte6 |
---|---|---|---|---|---|---|---|---|
1 | U+0000 | U+007F | 0xxxxxxx | |||||
2 | U+0080 | U+07FF | 110xxxxx | 10xxxxxx | ||||
3 | U+0800 | U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |||
4 | U+10000 | U+1FFFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | ||
5 | U+200000 | U+3FFFFFF | 111110xx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | |
6 | U+4000000 | U+7FFFFFFF | 1111110x | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
在瀏覽器的 encodeURIComponent 和 Node 的 Buffer 默認採用 UTF-8 編碼:
console.log(encodeURIComponent('中')); // %E4%B8%AD
複製代碼
const buffer = new Buffer('中');
console.log(buffer); // <Buffer e4 b8 ad>
複製代碼
這裏的 E4、B8、AD 就是三個字節的十六進編碼,我們試着轉一下:
const byte1 = parseInt('E4', 16); // 228
const byte2 = parseInt('B8', 16); // 184
const byte3 = parseInt('AD', 16); // 173
const codePoint = (byte1 & 0xf) << 12 | (byte2 & 0x3f) << 6 | (byte3 & 0x3f);
console.log(codePoint); // 20013
複製代碼
我們將三個字節的控制碼 1110、10、10 分別去掉,然後將它們按照從高位到低位的順序拼接起來,正好就得到'中'的碼位 20013。
所以我們也可以利用 UTF-8 編碼規則,寫另一個版本的通用方法來實現 getCodePoint:
function getCodePoint(char) {
const code = char.charCodeAt(0);
if(code <= 0x7f) return code;
const bytes = encodeURIComponent(char)
.slice(1)
.split('%')
.map(c => parseInt(c, 16));
let ret = 0;
const len = bytes.length;
for(let i = 0; i < len; i++) {
if(i === 0) {
ret |= (bytes[i] & 0xf) << 6 * (len - i - 1);
} else {
ret |= (bytes[i] & 0x3f) << 6 * (len - i - 1);
}
}
return ret;
}
console.log(getCodePoint('中')); // 20013
console.log(getCodePoint('🀄')); // 126980
複製代碼
那麼同樣,我們可以實現 fromCodePoint:
function fromCodePoint(point) {
if(point <= 0xffff) return String.fromCharCode(point);
const bytes = [];
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
if(point < 0x1FFFFF) {
bytes.unshift(point & 0x7 | 0xf0);
} else if(point < 0x3FFFFFF) {
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
bytes.unshift(point & 0x3 | 0xf8);
} else {
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
bytes.unshift(point & 0x3f | 0x80);
point >>>= 6;
bytes.unshift(point & 0x1 | 0xfc);
}
const code = '%' + bytes.map(b => b.toString(16)).join('%');
return decodeURIComponent(code);
}
console.log(fromCodePoint(126980)); // 🀄
複製代碼
關於 Unicode,你還有什麼想討論的,歡迎在 issue 中留言。