你還在用charCodeAt那你就out了

語言: CN / TW / HK

在 JavaScript 中處理中文和其他 Unicode 字符時,我們會用到處理 Unicode 相關的 API。

在早期,JavaScript 提供的String.prototype.charCodeAtString.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'); // 中文

複製代碼

0x4e2d0x6587分別是 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 官方對 codePointAtfromCodePoint 的説明中,就按照上面的思路提供了對應的 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 編碼方式如下:

字節起始終止byte1byte2byte3byte4byte5byte6
1U+0000U+007F0xxxxxxx
2U+0080U+07FF110xxxxx10xxxxxx
3U+0800U+FFFF1110xxxx10xxxxxx10xxxxxx
4U+10000U+1FFFFF11110xxx10xxxxxx10xxxxxx10xxxxxx
5U+200000U+3FFFFFF111110xx10xxxxxx10xxxxxx10xxxxxx10xxxxxx
6U+4000000U+7FFFFFFF1111110x10xxxxxx10xxxxxx10xxxxxx10xxxxxx10xxxxxx

在瀏覽器的 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 中留言。