你還在用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 中留言。