unicode 字符转 uint8 数组

Table of Contents

关于 unicode 编码和 utf8 的对应关系可以参考知乎的一篇文章:《UTF-8 到底是什么意思?unicode编码简介》

unicode 和 utf8 的关系

根据上面的图,可以编写出 unicode 和 uint8 数组的相互转换函数。

编码

// 编码
function encode(str) {
  const arr = [...str]
  const buffer = new Uint8Array(arr.length * 4)
  let index = 0
  for (let i = 0; i < arr.length; i++) {
    const codePoint = arr[i].codePointAt(0)
    // 四字节字符
    if (codePoint >= 0x10000) {
      buffer[index++] = (codePoint >> 18) & 0x7 | 0xf0
      buffer[index++] = (codePoint >> 12) & 0x3f | 0x80
      buffer[index++] = (codePoint >> 6) & 0x3f | 0x80
      buffer[index++] = codePoint & 0x3f | 0x80
    } else if (codePoint >= 0x800) {
      // 三字节字符
      buffer[index++] = (codePoint >> 12) & 0xf | 0xe0
      buffer[index++] = (codePoint >> 6) & 0x3f | 0x80
      buffer[index++] = codePoint & 0x3f | 0x80
    } else if (codePoint >= 0x80) {
      // 两字节字符
      buffer[index++] = (codePoint >> 6) & 0x1f | 0xc0
      buffer[index++] = codePoint & 0x3f | 0x80
    } else {
      // 单字节字符
      buffer[index++] = codePoint
    }
  }
  return buffer.slice(0, index)
}

解释:

  1. 初始 buffer 长度设为字符串长度的 4 倍。这里假设每一个字符都需要 4 个字节。用展开运算符将字符串转为数组是为了正确处理四字节字符。当然也可以直接根据码点判断要不要 i 额外 +1,这样可以节省解构的开销。
  2. 上图二进制中的 x 是数据位,其余有默认值的是控制位。1 ~ 4 字节字符数据位分别是 7、11、16、21。编码要做的就是把字符码点的二进制位填进 x 占据的位置。
  3. emoji 表情:🤔 是四字节,码点是 129300,二进制表示是 1 1111 1001 0001 0100。填充到四字节模板就是 11110000 10011111 10100100 10010100。(从低位开始,没有就补 0)
  4. (codePoint >> 18) & 0x7 | 0xf0 。这句的作用是填充第一个字节的三个数据位。首先右移 18 位,保留高三位(按位与 0x7,十六进制 7 就是二进制 111)。然后按位或 0xf0(11110000——四字节模板的第一个字节)。
  5. 其余操作同理。

解码

// 解码
function decode(buffer) {
  let str = ''
  for (let i = 0; i < buffer.length; i++) {
    switch (buffer[i] >> 4) {
      case 0: case 1: case 2: case 3: case 4: case 5: case 6: case7:
        str += String.fromCodePoint(buffer[i])
        break
      case 12: case 13:
        str += String.fromCodePoint(
          ((buffer[i] & 0x1f) << 6) +
          (buffer[i + 1] & 0x3f)
        )
        i++
        break
      case 14:
        str += String.fromCodePoint(
          ((buffer[i] & 0xf) << 12) +
          ((buffer[i + 1] & 0x3f) << 6) +
          (buffer[i + 2] & 0x3f)
        )
        i += 2
        break
      case 15:
        str += String.fromCodePoint(
          ((buffer[i] & 0x7) << 18) +
          ((buffer[i + 1] & 0x3f) << 12) +
          ((buffer[i + 2] & 0x3f) << 6) +
          (buffer[i + 3] & 0x3f)
        )
        i += 3
        break
    }
  }
  return str
}

解释:

  1. 解码是编码的逆向操作。移位和按位与就不解释了。这里说明一下 switch case 的判断。
  2. 从上面的图可以看出来,根据第一个字节的前四位可以判断目标字符属于哪个范围(需要几个字节)。
  3. 一个字节的字符,前四位处于 0b0000 ~ 0b0111 之间,即 0 ~ 7。
  4. 两个字节的字符,前四位处于 0b1100 ~ 0b1101 之间,即 12 ~ 13。
  5. 三个字节的字符,前四位是 0b1110,即 14。
  6. 四个字节的字符,前四位是 0b1111,即 15。

其实现在浏览器和 nodejs 都提供了字符串与 uint8array 的转换

const decoder = new TextDecoder()
const encoder = new TextEncoder()
console.log(decoder.decode(encoder.encode('🤔'))) // 🤔

自己写编解码主要是为了了解一下原理,以及某些场景下修改得更高效。比如编解码直接使用调用者传递的 buffer 参数,避免函数内部新建 buffer 的开销,降低内存使用和时间。