从Unicode转换UTF-8的过程了解奇妙的按位操作符

1,539 阅读10分钟

本文会以图文的形式进行说明,并先补充几个知识点,方便之后的理解。

必备小知识

1. 计算机内部以 二进制bit 进行存储,由 01 组成。

2. 高位和低位,二进制位从左到右。 (高位)111001101(低位)

3. 8个二进制位bit等于1个字节byte

4. String.prototype.charCodeAt

charCodeAt返回了字符的code point,是一个0到65535之间的整数

codePointAtcharCodeAt一样,但可以返回超出65535之后的整数

后面的例子不会超出65535所以会用charCodeAt

5. 按位操作符:按位与&

对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0

按位与&
按位与&

6. 按位操作符:按位或|

对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0

按位或|
按位或|

7. 按位操作符:右移>>

将第一个操作数向右移动指定的位数,向右被移出的位被丢弃,拷贝最左侧的位以填充左侧(如果最左侧是0则填充0,是1则填充1)

右移>>
右移>>

8. ASCII、Unicode、UTF-8三者的关系

ASCII是美国制定的一套对英语字符的编码规范,共规定了128个字符编码

Unicode是统一各个国家符号的一套的字符集,兼容了ASCII,因为Unicode只规定了二进制代码,没有规定如何存储

UTF-8则是因为互联网的普及而诞生的一种统一的编码方式,它只是Unicode的实现方式之一

ASCII编码的字符一定是UTF-8编码的字符,反过来就不成立

9. Unicode符号范围与UTF-8编码的关系

UTF-8编码用1~4个字节表示一个符号

--------------------------------------------------------------------
Unicode符号范围      |      UTF-8
十六进制             |      二进制
--------------------------------------------------------------------
00000000 0000007F   |  0xxxxxxx (此为兼容ASCII,以0开头)
00000080 000007FF   |  110xxxxx 10xxxxxx (2个字节) (开头的1数量表示了字节数)
00000800 0000FFFF   |  1110xxxx 10xxxxxx 10xxxxxx (3个字节)
00010000 0010FFFF   |  11110xxx 10xxxxxx 10xxxxxx  10xxxxxx (4个字节)
--------------------------------------------------------------------

将上面的Unicode符号范围梳理下:

1个字节: 0x00 ~ 0x7F 0 ~ 127
2个字节: 0x80 ~ 0x7FF 128 ~ 2047
3个字节: 0x800 ~ 0xFFFF 2048 ~ 65535
4个字节: 0x10000 ~ 0x10FFFF 65536 ~ 1114111

好了,以上的知识就是后面的必备知识点了,了解之后就开始今天的旅程吧~

Unicode转换

接下来会使用中文 来作为例子的输入

第一种方法: 常规的填补方式

第一步自然是获取 Unicode

const unicode = '一'.charCodeAt() // -> 19968

知道了 Unicode值,根据上面的必备知识第九点就能得出,它在2048~65538的区间,即 UTF-8中占了3个字节

第二步将Unicode值转换成二进制

const binary = unicode.toString(2) // -> 100111000000000

第三步根据第九点,我们知道了「1110xxxx 10xxxxxx 10xxxxxx」,需要把 x 用二进制数 100111000000000填补掉, 根据x的数量(10xxxxxx 6个)从二进制数右侧取出对应的位数,然后依次从低位高位替换掉,不足的位数要用 0 替换。

     100   111000   000000  [一的二进制数]
1110xxxx 10xxxxxx 10xxxxxx  [UTF-8 3字节]
          ||
          ||
          \/
11100100 10111000 10000000
    ☝️这里补了一个0
// 3个字节: [0x800 ~ 0xFFFF]
if (unicode >= 0x800 && unicode <= 0xFFFF) {
  // x有16个,从头部开始补位
  const _binary = binary.padStart(16, 0)
  // 再利用substr截取对应位即可
  const utf8Binary = `1110${_binary.substr(0, 4)} 10${_binary.substr(4, 6)} 10${_binary.substr(10)}`
}

最后我们得到了 11100100 10111000 10000000 这个就是 UTF-8中的二进制存储

上面的方法比较简单和理解,那有没有更高级的方法呢,当然有,那就是第二种方法啦~

那好,第二种方法呢其实会用到必备知识的第5、6、7点对第一种方法做简化,这种方法利用了按位操作符的特性,巧妙的进行了替换

第二种方法: 按位操作

先要知道一个点: 边界值最小值和最大值,分别是x0x1的情况。把01分别带入进去,得到了

1110*0000* 10*000000* 10*000000*

1110*1111* 10*111111* 10*111111*

*括起来的数就是每个字节所能替换的最小值最大值

了解这个就开始了

我们先给 「1110xxxx 10xxxxxx 10xxxxxx」从左往右编1、2、3号

上面已经计算出了的二进制数100111000000000

要做的事情无非就是要把二进制数填补到对应的位置上,从低位高位填补

1号4x2、3号分别有6x

第一步

先把每个数的位置跟x对其,不够补0,并分别给它们编号a b c

Step1
Step1

从图上可以很清晰看出a要在1号这个位置上,那就必须将整体往右边移动12位,即0b100111000000000 >> 12(0b为二进制数字面量),b c会被丢弃

代码如下:

const binary = 0b100111000000000
const a = binary >> 12 // -> 0b100

我们现在只关注a

第二步

回到刚才说的边界值最大值不能超过xxxx=>1111,再结合按位操作符,哪种符号可以将其限制1111内(包含了1111)

诶,那就是按位与&,可以回到必备知识第五点看下定义。

x1替代与a -> 0100按位与&操作,即0b1111 & 0b100,最后结果还是a,这里可能还看不出来按位与&"限制"作用,不着急,讲b就会体现出来

Step2
Step2

代码如下:

// a = 0b0100
a & 0b1111 // -> 0b0100

第三步

要得出1号最终的二进制,这步很关键,最后再来看下1号 1110xxxxa 0100,因为1110为控制位,只是表达一些信息,不用改变,要替换的也只是x而已,那第二步已经得到了a的值,也不用改变,那这步就是要让这两个位值不能发生改变,并要产生一个新的二进制,那这时候需要上面说的哪几点呢?

聪明的小伙伴应该已经想到了,就要利用最小值以及按位或|,这样就能完整的保留11100100,看图

Step3
Step3

代码如下:

// a = 0b00000100 b-1之间的0其实可以省略
a | 0b11100000 // -> 0b11100100

这样就得到了1号的最终二进制11100100

按位或|在这里充分体现了一个特性任一数值x与0进行按位或操作,得到的就是x,利用这个特性就能理解上面的保留操作

第四步

接下来关注b,从前面的图中可以看出b要在2号这个位置上,那就必须将整体往右边移动6位,即0b100111000000000 >> 6,这里得到的二进制会包含a的部分,但其实并没有什么影响,为什么呢,就要看按位与&大显神威了,来看下面图片

Step4
Step4

讲两点:

  1. 两操作数都为 1,结果才是 1
  2. 任一数值0按位与操作,其结果还是 0

根据这两点就能发现多出来的a,并没有什么影响,也体现了按位与&的作用,不管另一个操作数多大,都会被限制最大值 111111 的范围内

代码如下:

const binary = 0b100111000000000
const b = binary >> 6 // -> 0b100111000
b & 0b111111 // -> 0b0111000

第五步

就是重复第三步操作,只不过二进制数字换掉了

Step5
Step5

代码如下:

// b = 0b111000
b | 0b10000000 // -> 0b10111000

第六步

关注c3号因为已经在最后面了,所以不需要向右移动,省略第一步操作,后面的第二步、第三步跟b的步骤一样,这里就不画图了,直接上代码

代码如下:

const binary = 0b100111000000000
const c = binary // -> c不需要移动就等于binary
// 0b100111000000000 & 0b111111
c & 0b111111 // -> 0b000000,就是0
0 | 0b10000000 // -> 0b10000000

完整代码:

const str = '一'
const unicode = str.charCodeAt()
// 这个转换成二进制部分可以省略掉,在进行下面的按位操作时js运行时会把十进制数自动换算成二进制数 
// const binary = '0b' + unicode.toString(2)
const result = []
if (unicode >= 0x800 && unicode <= 0xFFFF) {
  result.push(unicode >> 12 & 0b1111 | 0b11100000) // 1号 - a
  result.push(unicode >> 6 & 0b111111 | 0b10000000) // 2号 - b 
  result.push(unicode & 0b111111 | 0b10000000) // 3号 - c
}

// result -> [228, 184, 128]
// 转换成十六进制 -> [e4, b8, 80]
// 利用encodeURI来验证下
encodeURI('一') // -> %E4%B8%80
// 发现e4==E4 b8==B8 80==80
// 其实encodeURI将字符编码成UTF-8的,但仅限于特定字符,像汉字

上面的二进制数不好书写,也不利于阅读,我们将其换算成十进制,就是其他文章里的代码了,你一开始看到这个是不是很懵逼,经过上面的过程会不会清晰很多~

const str = '一'
const unicode = str.charCodeAt()
const result = []
if (unicode >= 0x800 && unicode <= 0xFFFF) {
  // unicode >> 12 & 0x0F | 0xE0 [十六进制也可以]
  result.push(unicode >> 12 & 15 | 224)
  result.push(unicode >> 6 & 63 | 128)
  result.push(unicode & 63 | 128)
}

上面的Unicode转换带你了解了按位操作符的奇妙之处,但也只是其中的冰山一角。如同我们自己身处在冰山一角内,只有破冰而出,才能看到更广阔的世界,共勉💪!!!

其实上面的转换只讲了3字节,其他的2字节4字节都是差不多的,感兴趣的同学可以自己上手操作一下~

PS. 作为掘金小透明第一次分享文章,真的很激动,如果文章部分有错误的地方欢迎各位大佬指出,如果你有点收获,非常高兴^ ^~

本文使用 mdnice 排版