logo
2019-04-27
tech

以JS处理emoji表情为例简介UTF-8编码

Emoji 表情符号是直接保存在字符中的标签,不是一张图片,而是可以理解为和一个汉字同类的东西。因此在绝大多数可以打字的地方,就能放 Emoji。但是某些地方会出现表情变成问号或者一个框框的情况,其中一个可能的原因是使用了自定义的,或者过时的 UTF-8 解码形式。

首先简单说明一下文字在计算机中是如何被存储的。毫无疑义的是,文字最终一定是以二进制的形式存储的。其中最简单的是著名的 ASCII 码,他是早期由美国指定的一个编码标准,建立了一个二进制数到字母和符号的映射关系,其中共有 128 个符号,包括了英文大小写字母和标点符号等。二进制范围从0000 00000111 1111,具体范围可以查看维基百科。后来互联网时代这套标准延续了下来,一直用于英语文字的存储,通常直接用 8 位二进制,即一个字节(Byte)存储。

可是问题来了,世界并不只有英文,欧洲有法语、德语等语言不全是有 26 个英文字母组成的,亚洲又有众多的象形文字,比如中文字符的数量就多了去了。所以继续拓展,一开始,欧洲国家使用一个字节中剩下的 128 个空间各自表示自己语言的符号,而中文则是以GB2312为主(如 Windows 系统),用两个字节来存储常用的 6 万多个汉字。由于各种编码之间并不是遵循同样的标准,所以有些时候我们会看到各种乱码,比如著名的手持两把锟斤拷,口中直呼烫烫烫

Unicode 与 UTF-8

Unicode 的出现,正是为了解决文字编码问题,它建立了一张超大的映射关系表,存储各国文字符号,可以看作是 ASCII 的国际版,其数量已经远远超过了 2 个字节的范围。其中 Emoji 表情就在其中占有一段位置。

那么为什么有了 Unicode 还会出现乱码和 Emoji 的不正常显示呢?因为 Unicode 只是规定了映射关系,具体存储并不一定按照这个顺序直接对应到二进制码。除了历史原因外,还有一个问题就是长度过长引起的浪费,假设用 3 个字节作为一个文字符号,那么英语(ASCII)的存储就会出现两个字节的浪费。因此通常采用一种可以兼容 ASCII 的存储方式,使得字节数不需要固定到最大位数。

著名的UTF-8便是 Unicode 的一种实现方式,解决了上面提到的长度浪费问题。除去 ASCII 的范围,一个字节中还存在 1 开头的各种情况,UTF-8 充分利用了这部分空间,规定了一些标识位,实现了变长的编码方式,即一个字符可能由 1-4 个字节组成。这样 ASCII 继续保持精简,而其他丰富的符号则自行拓展更多的字节保存。编码规则十分简单,见下表

Unicode符号(顺序、16进制表示)UTF-8编码(变长、2进制表示)
0000 0000 - 0000 007F0xxxxxxx
0000 0080 - 0000 07FF110xxxxx 10xxxxxx
0000 0800 - 0000 FFFF1110xxxx 10xxxxxx 10xxxxxx
0001 0000 - 0010 FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

解码规则总结:对于每一个字节

  1. 如果第一位为 0,则为单字节码,后面 7 位表示 ASCII
  2. 如果开头为 110,则表示双字节码的第一个字节,其后 5 位及下一个字节的后 6 位共同组成一个 Unicode 符号
  3. 如果开头位 1110,则表示三字节码的第一个字节
  4. 如果开头位 11110,则表示三字节码的第一个字节

Emoji 编解码

Emoji 在 Unicode 的位置比较靠后,通常需要 4 个字节码。因此如果解码方案里面没有考虑 4 个字节的情况(11110 开头),则会出现一些混乱,但是混乱不会影响太多,因为假设只解析了 3 个字节,那么最后一个字节是 10 开头,通常会被忽略。

下面以 JS 为例,进行 Emoji 的编码。

function stringEncode(str) {
  const bytes = new Uint8Array(str.length * 4); // 先都假设最长
  let offset = 0;
  for (let i = 0; i < str.length; i++) {
    const charCode = str.codePointAt(i);
    let codes;
    if (charCode <= 0x7f) {
      // 0111 1111 单字节
      codes = [charCode];
    } else if (charCode <= 0x7ff) {
      // 双字节 11000000 -> 0xc0, 00111111 -> 0x3f, 0x80 -> 10000000
      codes = [0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f)];
    } else if (charCode <= 0xffff) {
      // 三字节 1111 1100 0000 -> 0xfc0
      codes = [
        0xe0 | (charCode >> 12),
        0x80 | ((charCode & 0xfc0) >> 6),
        0x80 | (charCode & 0x3f),
      ];
    } else {
      // 四字节 11 1111 0000 0000 0000 -> 3f000
      codes = [
        0xf0 | (charCode >> 18),
        0x80 | ((charCode & 0x3f000) >> 12),
        0x80 | ((charCode & 0xfc0) >> 6),
        0x80 | (charCode & 0x3f),
      ];
    }
    for (let j = 0; j < codes.length; j++) {
      bytes[offset] = codes[j];
      ++offset;
    }
  }
  // 根据实际位数调整
  const finalBytes = new Uint8Array(offset);
  for (let i = 0; i < offset; i++) {
    finalBytes[i] = bytes[i];
  }
  return finalBytes;
}

function stringDecode(bytes) {
  const unicodeArray = []; // 存储解码后的实际unicode
  let string = "";
  let offset = 0;
  while (offset < bytes.length) {
    const byte = bytes[offset];
    if (byte < 0x80) {
      // 单字节 0000 0000
      unicodeArray.push(byte);
      offset += 1;
    } else if (byte < 0xe0) {
      // 双字节 1100 0000
      unicodeArray.push(((byte & 0x1f) << 6) + (bytes[offset + 1] & 0x3f));
      offset += 2;
    } else if (byte < 0xf0) {
      // 三字节 1110 0000
      unicodeArray.push(
        ((byte & 0x0f) << 12) +
          ((bytes[offset + 1] & 0x3f) << 6) +
          (bytes[offset + 2] & 0x3f)
      );
      offset += 3;
    } else {
      // 四字节 1111 0000
      unicodeArray.push(
        ((byte & 0x07) << 12) +
          ((bytes[offset + 1] & 0x3f) << 12) +
          ((bytes[offset + 2] & 0x3f) << 6) +
          (bytes[offset + 3] & 0x3f)
      );
      offset += 4;
    }
  }
  for (i = 0; i < unicodeArray.length; i++) {
    string += String.fromCodePoint(unicodeArray[i]);
  }
  return string;
}

const str = "😊大家好👏";
const bytes = stringEncode(str);
console.log(stringDecode(bytes));

锟斤拷问题

锟斤拷总的来说,是因为 utf-8 的兼容处理产生的临时编码,被当作真实编码,并到了 GBK 解码方式,导致出现了锟(0xefbf)斤(0xbdef)拷(0xbfbd)。具体过程如下:

1. 新的未识别的 unicode 编码,导致出现问号

😊 的 utf8 编码为:11110000, 10011111, 10011000,10001001,解析为 unicode 编码为 128522,这是一个比较大的数字,某些早期环境可能还不支持,比如较早版本的 win10 或者安卓,那么无法从 unicode 映射表找到字符,会使用一个特殊的符号代替,符号的 unicode 代码为 0xfffd,显示为一个问号。(可以试一下用手机发一个 Emoji 给较老的电脑)。

2. 特殊问号的 utf8 编码

这个特殊问号,也需要临时存储为 utf8,编码后为 11101111 , 10111111, 10111101,16 进制 0xef 0xbf 0xbd

3. 解析为双字节编码

如果把这个特殊问好,直接复制或者输出到一个使用 GBK 等双字节编码的环境,会被直接解析(这类编码是定长的,没有像 utf 一样的标识位)。假设连续发了两个 😊,那么得到的是 0xefbfbdefbfbd,解析为 3 个字符,0xefbf, 0xbdef, 0xbfbd,正好是锟(0xefbf)斤(0xbdef)拷(0xbfbd)。