UTF 编码与 JavaScript

1,622 阅读6分钟

基础

Unicode 与 UTF-8 的区别:(参考知乎回答) Unicode 是一个字符集,几乎为全世界已知的所有字符分配了一个编码,编码与字符一一对应;而 UTF-8 定义了如何存储编码的一种方式,类似的还有 UTF-16/UTF-32/GB2312/GBK 编码

UTF-8: 是针对 Unicode 的一种可变长度字符编码(1字节 ~ 4字节),其编码的第一字节与 ASCII 兼容。

UTF-16: 与 UTF-8 类似,也是一种可变长度字符编码,其长度可能是 2字节 或者 4字节

UTF-32: 使用 4字节(32位)长度表示一个 Unicode 字符,不用做任何转换,缺点是比较占空间。

UCS-2: UTF-16 编码是 UCS-2 编码的超集,在没有辅助平面(UTF-16 的后2个字节)前,UTF-16 与 UCS-2 是指同一个东西,一个字符占用 2 个字节空间。而 JavaScript 就使用 UCS-2 编码,所以当字符码点大于 U+FFFF 时,需特别注意处理。

UTF-8 编码 (rfc3629)

UTF-8 是可变长度编码,一个字符最小占用 1 字节,最多占用 4 字节空间。编码规则 (rfc3629#section-3) 如下:

单字节: 占用 1 字节(8bit)空间,首位以 0 开始,后续 7 位则为 Unicode 码点值,最大可到 0b01111111

多字节:占用 2~4 字节(16bit ~ 32bit)空间。编码规则:

  • 首字节的 8 位,最前面连续有多少个 1 就表示该字符占用多少个字节空间,应该是有 2 ~ 4 个 1,然后紧跟着 1 个 0 , 后续的位用于表示码点的位
  • 后续字节(第 2 ~ 4 字节):由首字节最前面的多少个 1 决定后续字节为 2/3/4 个。后续字节的前2位为 10 ,其后的位用于表示码点的位。完整的码点表示为:首字节的有效位+后续字节的有效位

示例 (有效的码点位用加粗标识):

字符Unicode 码字节数UTF-8 编码
'a'97101100001
'Ɛ'400211000110 10010000
'一'19968311100100 10111000 10000000
'𠮷'134071411110000 10100000 10101110 10110111

使用 C++ 获取 UTF-8 编码字符的码点值

/**
 * 获取 UTF-8 编码字符的码点值
 * @param str - 字符串
 * @param index - 字符串索引
 */
unsigned int getCodePointAt(string &str, int index) {
  int code = (unsigned char) str[index];
  unsigned int baseBits = 0b10000000;
  unsigned int lastBits;

  // 计算第一个字节前 n 位为 1 的个数
  int bytes = 0;
  if ((code & baseBits) == baseBits) { // 0b1xxxxxxx
    do {
      lastBits = baseBits;
      baseBits = baseBits | (baseBits >> 1);
      bytes++;
    } while ((code & baseBits) == baseBits);
  } else {
    return code;
  }

  // 3字节示例:1110xxxx 10xxxxxx 10xxxxxx
  // 第一字节有效位数: 8 - 字节数 - 1,  后续每个字节有效位数: 6
  // 由于后续字节有效位占6位,所以需要把高位左移 6 * (第n字节 - 1), 为低位留空间
  int i = bytes - 1;
  unsigned int result = (code ^ lastBits) << (i * 6);
  while (i >= 1) {
    int c = (unsigned char) str[index + i];
    result += (c & 0b00111111) << ((bytes - i - 1) * 6);
    i--;
  }

  return result;
}

UTF-16 规范详解 (rfc2781)

由 双字节 或 四字节编码,接下来的编码或解码规则从规范中翻译而来:

编码规则(rfc2781#section-2.1):

把字符码用 U 表示,该数字应该不大于 0x10FFFF

  1. 如果 U < 0x10000, 直接使用 U 作为 16位无符号整数编码就可以了,结束;
  2. 否则,令 U' = U - 0x10000, 因为 U 小于等于 0x10FFFF,所以 U' 一定小于等于 0xFFFFF,那么 U' 可以用 20个 bit 表示出来(说明0xFFFFF == 0b11111111111111111111,对应二进制20个1
  3. 初始化 2 个 16 位无符号整数,分别记为 W1 和 W2,W1 初始值为 0xD800, W2 初始值为 0xDC00,这2个整数分别有 10 个 bit 的剩余空间对字符进行编码,加起来总共 20 位(说明:0xD800 与 0xDC00 的二进制后10位均为 0 , 这里就是利用其空间)
  4. 将 20 个 bit 的 U' 中的 10 个高位 (U' 前 10 个 bit) 分配给 W1, 10 个低位 (U' 的后 10 个 bit) 分配给 W2,结束。

第 2 步到第 4 步看起来像下面这样:

U' = yyyyyyyyyyxxxxxxxxxx

W1 = 110110yyyyyyyyyy

W2 = 110111xxxxxxxxxx

解码规则 (rfc2781#section-2.2)

设 W1 为表示文本的整数序列 的一个16位整数(通俗来讲,就是用 16 位整数的数组来表示字符串),设 W2 为紧跟着 W1 后面的一个 16位整数(也是用于表示当前字符的最后一个整数)

  1. 如果 W1 < 0xD800 或 W1 > 0xDFFF,W1 的值就是字符的码点值,结束;
  2. 确定 W1 在 0xD800 ~ 0xDBFF 之间。如果不是,则该字符不是一个有效字符,结束;
  3. 如果没有 W2 (W1 后面的一个数字) 或者 W2 不在 0xDC00 ~ 0xDFFF 之间,则字符序列有误,结束;
  4. 构造一个 20 位无符号整数 U', 取 W1 的 10 个低位(后 10 位)填入 U' 的高位,并取 W2 的 10 个低位(后 10 位)填入 U' 的低位;
  5. U' + 0x10000 = U,U 就是字符的码点值,结束;

总结

UTF-16 编码分为双字节 和 四字节的情况,

  • 双字节范围:0x0000 ~ 0xD799 及 0xDE00 ~ 0xFFFF
  • 四字节范围:前 2 个字节 (0xD800 ~ 0xDBFF), 后 2 个字节 (0xDC00 ~ 0xDFFF)

示例

字符Unicode 码字节数UTF-16 编码
'a'97200000000 01100001
'Ɛ'400200000001 10010000
'一'19968201001110 00000000
'𠮷'134071411011000 01000010 11011111 10110111

下面用 '𠮷' 字符(UTF-16 编码: 11011000 01000010 11011111 10110111 )来验证一下解码规则:

  1. 读取第 1 个双字节值为 55362,在 0xD800 ~ 0xDBFF 之间,所以该字符用 4 字节表示,即需要读取下一个双字节
  2. 读取下一个双字节值为 57271,验证在 0xDC00 ~ 0xDFFF 之间,所以字符有效
  3. 分别取第一个双字节的 10 个低位 (0001000010),和后一个双字节的 10 个低位 (1110110111) ,组成一个 20 位整数,即:00010000101110110111,转为十进制位 68535,最后在加上 0x10000,得到码点值:68535 + 0x10000 = 134071

JavaScript 与字符编码

由于 JavaScript 使用 UCS-2 编码,即与 UTF-16 编码的双字节规则相同。所以对于码点大于等于 0x10000 时,会看到如下现象:

'𠮷'.length // 2

可以通过以下一些方式规避这个问题:

Array.from('𠮷').length // 1

// 将所有码点值大于等于 0x10000 的字符(JS默认作为 2 个字符表示)都替换成单字符
'𠮷'.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length // 1

获取字符的码点:

// 规格 UCS-2 规则,这里只能读取了第一个双字节的值,读取了一个错误的码点值
'𠮷'.charCodeAt(0) // 55362

// 使用 ECMAScript 2015 规范中的字符串 API 获取
'𠮷'.codePointAt(0) // 134071

参考链接