4

编码

前言

这篇文章是研究上传文件的时候扩展出来的知识点,因为上传文件的时候会涉及到文件的编码等内容,作为前端平时接触这些东西又比较少,我又是个不彻底搞清楚问题就不罢休的人,所以往往会因为一个小问题牵扯出来一堆问题,接着,疑问又带来新的疑问(禁止套娃!)。

写这篇文章花了很长时间,如果你看完之后有所收获,一个 Star ✨就是对我最好的鼓励。

ps: 文章不会纠结一些不太重要的信息,比如某协议是谁提出的,在什么年份提出的,这些基本上看过一次就忘了,也不重要,我会提取重要的信息来分享给大家。

前置知识

在开始之前,先整理一下我们所需要的前置知识,对于这些我们只需要有个简单的印象即可。

计算机常用的单位换算

二进制位、位、比特、bit、b,这些都是代表计算机存储的最小单位「位」,也就是二进制的 01

1B = 8b

字节、Byte、B,这些单位也是表达的同一个意思「字节」,1 个「字节」等于 8「位」。

1kb = 1024B

kb、mb,这些大家应该就要熟悉多了,获取文件大小的时候都可以看到这些单位。

1mb = 1024kb

单位换算

前端中的进制转换

// 十六进制转十进制
parseInt(0x0f, 16);
// 十六进制转二进制
(0x0f).toString(2);

// 十进制转十六进制
(15).toString(16);
// 十进制转二进制
(15).toString(2);

// 二进制转十六进制
parseInt(1111, 2).toString(16);
// 二进制转十进制
parseInt(1111, 2);

计算机之初,杂乱无法统一的编码方案

为了方便理解,文章以 UNICODE 出现作为时间线划分,UNICODE 就是盘古开天辟地的那一斧子。

ASCII 的到来

众所周知,在计算机中,所有的数据存储和运算时都要使用二进制表示,因为计算机用高电平和低电平表示 10。然后就有这么一群人,他们决定用 8 个二进制位组成码位表示所有字符

8 个二进制位,每个位置都可以是 0 或者 1,所以一共有 2^8 = 256 个码位,可以表示 256 个字符,这些字符又分为控制字符、通信专用字符和可显示字符。

控制字符和通信专用字符放在一起说,从 0000 0000 ~ 0001 1111,再加上 0111 1111,一共是 33 个码位,也就是有 33 个字符。比如,0000 0111,表示的意思是响铃,那时的计算机在接收到 0000 0111 的时候就会铃铃作响。

可显示字符,第 0010 0000 ~ 0111 1110,一共是 95 个,比如 0011 0000,表示的意思是 0,再比如 0100 1010,表示的是大写英文字母 J

ASCII

因为计算机刚开始只在美国使用,那大家相安无事,用的挺好,这些字符按照规定的顺序排排坐所产生的表,就是我们在 C 语言里面学到的 ASCII 表

ps: 现在教育太疯狂了,认识的小孩小学就在学编程,他现在就知道 ASCII 码了 0.0

ASCII 的扩展表以及 GBK 编码方案

后来,因为计算机的发展,一些西方国家开始在 ASCII 码表的后面增加自己国家的字符和制表符等字符,这就是 ASCII 扩展表,他占用了 1000 0000 ~ 1111 1111 的位置来表示自己的字符。

ps: ASCII 扩展表在不同系统配置的内码表也不同,这里就不赘述了,想要了解的同学可以参考 这个文档

再后来,计算机传播的越来越远,来到了第三世界。我们发现,泱泱中华数以万计的文字,ASCII 码表是不可能放下我们的字符了,256 个位置全给我们都不够,那该怎么办?

聪明的中国人直接把 127 号码位之后的奇怪符号取消掉(也就是 ASCII 扩展表的内容),规定,一个小于 127 的字符意义与原来相同,但两个大于 127 的字符连在一起时,就表示一个汉字,前面的一个字节(高字节)从 0xa1 ~ 0xf7,后面一个字节(低字节)从 0xa1 ~ 0xfe。(这里开始文章就不用二进制来表示码位了,写起来太长,而且一堆 0 1 看着也不方便,我就把二进制转为其对应的十六进制,0x 打头就代表十六进制)

这样我们既可以保证在 ASCII 表中的英文字母不会显示乱码,另外还能组合出八千多个位置来放自己的文字、数学符号、日文假名等,而且,我们甚至把原先在 ASCII 码表里就有的标点符号,又全部编了两个字节长的字符,这就是我们常说的全角字符了,而 127 号以下的符号就称为半角字符。(「,」和「,」看出不同了吗?前者是半角字符,后者是全角字符)

GB

后来这个方案用着还不错,我们就给它取了个名字 GB2312,前面的 GB 意思是国标。

但是我们的汉字实在太多了,八千多个位置还是不够,那我们干脆就不要求低字节是 127 号之后的了,规定只要高字节大于 127,那就代表这是 GBK 编码方案中代表的字符。这个编码方案就称为 GBK,GBK 不仅包括了 GB2312 的所有内容,还增加了好多汉字和繁体字。

再后来,少数民族也要用电脑了,要把他们的文字也加进去,于是就再进行扩展,GBK 扩成了 GB18030

有兴趣的同学可以到 这个网站 查询国标对应字符的码位。

听起来是不是挺完美的?GB18030,几乎可以囊括你能见到的所有中文,但是我们这只解决了中文的编码,无法显示其他国家或者地区的文字,比如,那时候还有一个编码方案叫 Big5,普及与台湾、香港、澳门等繁体中文通行区,倚天中文系统、window 繁体中文等系统的字符集都是以 Big5 为基准。

你看,中国的内地和港澳台编码方式都不一样,那其他国家就更不用说了,结果就是,大家都闭门造车,如果需要看其他国家的文档那就得安装切换其他国家的编码方案。

UNICODE 万国码的问世

再这么混乱下去肯定不行,这时候,创建一个囊括全世界的字符的字符集就势在必行,这个时候,有两个组织开始着手统一字符集,国际标准化组织(ISO)开发的 ISO 10646 字符集,统一码联盟开发的 UNICODE 字符集。再后来,他们意识到自己应该做的是统一标准而不是重蹈覆辙,最终才有了我们在用的 UNICODE 字符集,当然 ISO 10646 字符集依然存在,并且和 UNICODE 共存,而且根据规约,他们各自的码位的字符含义都相同。

因为我们比较熟悉的是 UNICODE,所以接下来的内容我都会以 UNICODE 为主。

UNICODE 平面的含义以及码位区段说明

刚开始 UNICODE 的做法很简单,ASCII 的 1 个字节(8 位)不是不够吗,那就 2 个字节(16 位),2^16 = 65536 个码位,这总够了吧?

结果是被啪啪打脸,在被各个国家的字符蹂躏了一遍之后,不行,这得改,于是决定取 UNICODE 中的两段区域 0xd800 ~ 0xdbff(高代理位)和 0xdc00 ~ 0xdfff(低代理位),用他们来组成新的码位。这两段代理位都有 1024 个码位,那就是增加了 1024^2 = 1048576 个码位,再加上原先的 2^16 = 65536 个码位,所以按道理来说,UNICODE 一共有 1048576 + 65536 = 1114112 个码位。

现在我们有正常 2 个字节的码位,还有高代理位和低代理位组成的 4 个字节的码位,我们该怎么区分他们呢?这个时候,我们需要引入平面的概念,把这些平面划分到不同平面,2 个字节的是 BMP 平面,4 个字节的是辅助平面。

第 0 平面称为 BMP 其范围为 0x0000 ~ 0xffff
第 1 辅助平面称为 SMP 又称为多文种补充平面,其范围为 0x10000 ~ 0x1ffff
第 2 辅助平面称为 SIP,又称为表意文字补充平面,其范围为 0x20000 ~ 0x2ffff
第 3 辅助平面称为 TIP,又称为表意文字第三平面,其范围为 0x30000 ~ 0x3ffff
第 4 至 13 辅助平面尚未使用。
第 14 辅助平面称为 SSP,又称为特殊用途补充平面,其范围为 0xe0000 ~ 0xeffff
第 15 辅助平面,其范围为 0xf0000 ~ 0xfffff
第 16 辅助平面,其范围为 0x100000 ~ 0x10ffff

平面其实可以理解成把相同长度的码位区段取了不同名字。我们常用的字符都在 BMP 平面,看下图,从 0x0000 ~ 0xffff 一共有 65536 个码位,其中高代理位和低代理位组合成了辅助平面。

BMP

另外,有兴趣的同学可以到 这个网站 上查看 UNICODE 所有的字符。

等等!高代理位和低代理位的操作是不是很熟悉?看文章仔细的同学应该马上就反应过来了,对,没错,就像当初我们针对 ASCII 而提出的 GBK,我们用 127 号以后的 ASCII 码组合出几万个码位给汉字用,UNICODE 也是一样,取了两段代理位组合成了辅助平面,那为什么 UNICODE 有几百万个码位,而我们提出的 GBK 只有几万个码位,那是因为 ASCII 只有 1 个字节,UNICODE 有 2 个字节,仅此而已。

那我们有了 UNICODE 字符集之后,所有问题就都解决了吗?其实并没有,当时的情况是,由于 UNICODE 一开始就没有打算去兼容之前任何字符集,所以 UNICODE 的推广之路并不平坦。

这时候还有细心的同学可能会问了,你上面的图画的,UNICODE 不是把 ASCII 前一半的字符拿去了吗?而且每个码位对应的字符都一样,为啥就不兼容了?

这个同学就看的更细心了,但是 ASCII 是 1 个字节,而 UNICODE 的 BMP 平面是 2 个字节,同样表达英文字母 A,ASCII 是 01000001,而 UNICODE 是 00000000 01000001,前面多了一堆无用的 0。所以,UNICODE 推广受阻还有一个原因就是,存储相同内容的英文文档,空间占用会翻倍,更别说其他文字了。

UNICODE vs ASCII

解决兼容和空间问题,聪明的 UTF-8

这个时候,为了兼容 ASCII,UTF-8 就出现了,他是 UNICODE 的编码方案。在实现上他不仅可以兼容 ASCII,并且由于它是可变长编码方案,对于纯英文的文档,可以使空间使用减半。

但是对于中文文档,相对于 GBK 编码方案还是导致空间增加了,因为原先我们 GBK 用 2 个字节组合出了几万个码位,但在 UTF-8 中,即使是 BMP 平面的汉字,那也需要 3 个字节。为什么?我们直接看下面的例子,对于”裤裆“的”裤“字,UTF-8 是如何编码的。

首先这里有一个表格,是 UTF-8 的替换模板,模板也是有规律的,如果开头是 0,那就占用一个字节,如果开头是 1,那连续几个 1 就表示有几个字节。

十六进制范围UTF-8 模板
0x0000 ~ 0x007f0xxxxxxx
0x0080 ~ 0x07ff110xxxxx 10xxxxxx
0x0800 ~ 0xffff1110xxxx 10xxxxxx 10xxxxxx
0x10000 ~ 0x10ffff11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

细心的同学可能又要问了(你就不能换个方式引题!?),看最后一个模板,如果 x 位置全是 1 的话,明明就大于 0x10ffff,为什么范围限制在了 0x10ffff?这就是代理位所带来的问题了,因为 UNICODE 一开始设定 2 个字节表示字符失策,后来用了代理位做补救,但这就导致码位的个数被限制在了 0x10ffff,即使 UTF-8 的设计可以表达、容纳更多的字符。

// 获取”裤“的 UNICODE 码位
const code = "裤".charCodeAt(); // 35044
// 获取 35044 的十六进制表示
// 查表可得 0x88e4 位于上表的第 3 行
const hex = code.toString(16); // 0x88e4
// 获取 35044 的二进制表示
const binary = code.toString(2); // 1000 1000 1110 0100

//          10001000 11100100   |   ”裤“的二进制序列
//     1000   100011   100100   |   ”裤“的二进制排序后
// 1110xxxx 10xxxxxx 10xxxxxx   |   模板中的第三行
// --------------------------   |   从低位到高位带入模板
// 11101000 10100011 10100100   |   获得编码后的二进制序列
//       e8       a3       a4   |   二进制序列转为十六进制

// 最终得到 e8 a3 a4 的编码结果

// 通过 node Buffer 来验证
const buffer = new Buffer.from("裤", "utf-8"); // <Buffer e8 a3 a4>

UTF-8

如上我们可以看到,汉字在经过 UTF-8 编码后,变成了 3 个字节,而 GBK 我们使用 2 个字节就可以表示。所以,如果你的网站或者文档只需要在国内传输的,使用 GBK 未尝不可?可以减少不少文档大小。

另外,我们也写一下英文字母经过 UTF-8 编码后会获取到多少字符。

// 获取 A 的 UNICODE 码位
const code = "A".charCodeAt(); // 65
// 获取 65 的十六进制表示,0x41 位于上表的第 1 行
const hex = code.toString(16); // 0x41
// 获取 65 的二进制表示
const binary = code.toString(2); // 1000001

//  1000001   |   A 的二进制序列
// 0xxxxxxx   |   模板中的第一行
// --------   |   从低位到高位带入模板
// 01000001   |   获得二进制序列
//       41   |   二进制序列转为十六进制

// 最终得到 41 的编码结果

// 通过 node Buffer 来验证
const buffer = new Buffer.from("A", "utf-8"); // <Buffer 41>

可见,对于英文字母来说,00000000 01000001 变成了 01000001,所占空间减少了一半。

UTF-16 编码方案

说完了 UTF-8,我们来说说 UTF-16 和 UCS-2,后面还有 UTF-32 和 UCS-4。另外,上面我们不是提到了 ISO 组织么?UCS 就是该组织提出的编码方案,UCS-2 可以说就是 UTF-16 的前身。

虽然看着 UTF-16 和 UTF-8 是 double 的样子,但其实并不。

首先,对于 BMP 面的字符,UTF-16 就直接用 2 个字节来表示,包括英文字母。

另外,我们刚才不是提到代理位和代理位组合而成的辅助平面么?忘了的同学可以翻上去看看,其实代理位就是专门用于 UTF-16 的,对于辅助平面的字符,UTF-16 就用 4 个字节来表示,也就是高代理位和低代理位组合表示。

看了这么久文章,同学们该饿了吧,我们说点好吃的,陕西的特产,biangbiang 面。

biangbiang 面

这个 biang 字,如下图。

biang 字

biang 在 UNICODE 的码位为 0x30ede,你可以打开 这个网站,搜索 0x30ede 来查看 biang 字收录在 UNICODE 字符集的位置。虽然这个字在浏览器中还不能够打出来,但是我们可以用这个字做引子,来说说 UTF-16 的编码该如何实现。

biangCharCodeHex = "0x30ede";
// 从 200414 这个码位就可以看出来,biang 字不在 BMP 平面
// 因为 BMP 平面只有 65536 个码位
parseInt("0x30ede"); // 200414

// 接下来演示 UTF-16 编码过程

// 先获取 200414 的二进制表示
(200414).toString(2); // 11 0000 1110 1101 1110

//            11 0000 1110 1101 1110   |   码位对应的二进制
//             1 0000 0000 0000 0000   |   减去 0x10000
//            10 0000 1110 1101 1110   |   得到的二进制
//          0010 0000 1110 1101 1110   |   把得到的二进制前补 0,补充到 20 位
//       0010000011       1011011110   |   整理一下,10 位一隔,方便阅读
// 1101100000000000 1101110000000000   |   左边是 0xd800,右边是 0xdc00
// ---------------------------------   |   还记得高代理位和低代理位的区间么?
//                                     |   高代理位从 0xd800 ~ 0xdbff,我们取 0xd800
//                                     |   低代理位从 0xdc00 ~ 0xdfff,我们取 0xdc00
// 1101100010000011 1101111011011110   |   直接把 10 位分别取代代理位后面的 0,从后面开始取代
//             d883             dede   |   把上述二进制转为 16 进制,最终获取到 UTF-16 编码

// 通过 node Buffer 来验证
// 因为 node 只支持 UTF-16 小端序
// http://nodejs.cn/api/buffer.html#buffer_buffers_and_character_encodings
// 所以表示为 dede 83d8,注意,这是从右往左读的
// 另外 "\u{30ede}" 这是个 ES6 用来表示辅助平面的字符的方法
const buffer = new Buffer.from("\u{30ede}", "utf16le"); // <Buffer 83 d8 de de>

UTF-16

以上,我们就完成了 UTF-16 编码,另外 UNICODE@3.0 也给出了辅助平面字符的转换公式

High = Math.floor((charCode - 0x10000) / 0x400) + 0xd800;
Low = ((charCode - 0x10000) % 0x400) + 0xdc00;

// 我们把刚才 biang 的码位代入试试
High = (Math.floor((0x30ede - 0x10000) / 0x400) + 0xd800).toString(16); // d883
Low = (((0x30ede - 0x10000) % 0x400) + 0xdc00).toString(16); // dede

在上面的例子里我们也看到了,UTF-16 还存在大端序和小端序,也就是字节序(BOM)的问题,其实就是我们得告诉程序,这段编码该从左开始读还是从右开始读。

举个例子,“奠”的编码结果是 5960,“恙”的编码结果是 6059,如果没有表明读取的方向,显示的结果就会有问题。

所以,UTF-16 还有两个分支,UTF-16BE(大端序)和 UTF-16LE(小端序)。并且对于用 UTF-16 编码的文件,会在文件头部增加一个代表字节序的标识,UTF-16BE 放的是 0xfeff,UTF-16LE 放的是 0xfffe,所以你会发现用 UTF-16 保存的文件,所占用空间会多 2 个字节。

我们知道了 UTF-16 可以用高低代理位组合成新的码位,UCS-2 和 UTF-16 的区别就在此,UCS-2 也是用 2 个字节表示 BMP 平面的字符,但是它不能表示辅助平面,并且,由于 ISO 要保证和 UNICODE 保持一致,所以 UCS-2 的 0xd800 ~ 0xdbff0xdc00 ~ 0xdfff 码位的字符是空的,你可以把 UCS-2 理解成是 UTF-16 的子集。

UTF-32 编码方案

接下来我们更快的来过一下 UTF-32 和 UCS-4,他俩都是直接对每个字符都使用 4 个字节,并且刚开始 UCS-4 提出来时,4 个字节,一共 32 个位,但是在计算机中我们一般会把最高位当做符号位(这里是否如此,存疑),那 UCS-2 就有 2^31 = 2147483648 个码位,但是因为 UCS-4 要符合 UNICODE 的标准,码位只能用到 0x10ffff,所以 UTF-32 就被提出来了,他只用来表示 0x000000 ~ 0x10ffff 的码位。

到这里,有没有发现代理位的坑?明明 UTF-8 和 UTF-32 可以表示更多的字符,但是因为用了代理位,UNICODE 的字符个数上限就被制裁到了 0x10ffff,虽然我们现在还有大量的码位没有被使用,UNICODE 被称为万国码,但如果以后如果有了宇宙码呢,那是不是要重启一套编码,或者被迫使用脑电波传输?(狗头

字符集和编码方案的阶段总结

至此,字符集和编码方案就先告一段落,我们来做一个总结

  1. 刚开始由美国提出了 ASCII 码表,并且这也是早期计算机的编码方案,规定 8 个位为 1 个字节,一共有 2^8 = 256 个码位。
  2. 后来计算机传到了其他国家,所以又出了 ASCII 扩展表,把 127 位之后的码位占满了。
  3. 再后来,计算机传到了中国,我们通过特定的规则,将两个 ASCII 码组合,得到了中国汉字编码,当时流行的编码方案有 GB2312、GBK、GB18030、Big5 等。
  4. 最后,国际标准化组织终止了各国创造各自编码方案的行为,打造了 UNICODE,试图将全世界的字符都收纳其中。
  5. UNICODE 在提出之初直接规定使用 2 个字节表达所有字符,并没有考虑兼容任何编码方案,包括 ASCII,所以推广十分艰难,直到 UTF-8 编码方案的问世。
  6. UNICODE 的码位一共有 0x10ffff 个,这些码位被分为 17 个平面,我们常用的是 BMP 平面。
  7. UNICODE 的 BMP 平面前 127 个码位内容直接照搬 ASCII,并将 0xd800 ~ 0xdbff0xdc00 ~ 0xdfff 规定为高低代理位,他俩组合生成辅助平面所需码位。
  8. UTF-8 是可变长的编码方案,编码结果是 1 ~ 4 个字节,既保证了英文字符的编码结果和 ASCII 初版字符集保持一致,又可以通过特定规则,编码所有的 UNICODE 码位。
  9. UTF-16 也是可变长编码方案,编码结果是 2 或者 4 个字节,BMP 平面的字符用 2 个字节表示,辅助平面用高低代理位组合计算后的 4 个字节表示。并且由于没有 UTF-8 的特殊规则,所以存在大小端字节序的问题。
  10. UCS-2 是 UTF-16 的前身,编码结果是 2 个字节,不支持辅助平面的字符表示。编码空间为 0x0000 ~ 0xffff
  11. UTF-32 是定长编码方案,编码结果是固定的 4 个字节,和 UTF-16 一样,也存在字节序的问题。
  12. UCS-4 是 UTF-32 的前身,编码结果是固定的 4 个字节。编码空间为 0x00000000 ~ 0x7fffffff
  13. UTF 系列编码方案的编码空间都为 0x000000 ~ 0x10ffff

验证阶段,实际看到才会记得牢

前面说了这么多东西,可能有的同学转眼就忘了,那我们来实际演示一下,不同编码对于文件大小的影响。

首先是英文文档,创建 3 个文件,内容都是 abc,然后对比三个文件的大小。

abc

从上图我们可以看到:

GBK 和 UTF-8 对于三个英文字母,都是 3 个字节的空间占用,因为 GBK 对于 127 号以前的英文字母编码保持 1 个字节,UTF-8 对于英文字母的编码规则也是得到 1 个字节。

还有 UTF-16,占用了 8 个字节,我们上面提到 UTF-16 为了处理字节序问题,会在文件首部增加 2 个字节表示读取顺序,我们去掉这 2 个字节,得出在 UTF-16 中,每个英文字母占位 2 个字节。

再是中文文档,也是 3 个文件,内容是 我爱你,对比三个文件的大小。

我爱你

从上图我们可以看到:

GBK 占用了 6 个字节,因为 GBK 编码方案把 2 个 ASCII 组合成了一个新的码位给中文使用,每个汉字就占 2 个字节。

UTF-16 占用了 8 个字节,先去掉文件首部表示字节序的 2 个字节,再因为 UTF-16 对于 BMP 平面的字符,统一用 2 个字节显示,汉字在 UTF-16 中就是占用 2 个字节。

UTF-8 占用了 9 个字节,我们上面提到的,对于 BMP 平面的汉字,UTF-8 经过编码后,每个汉字占用 3 个字节。

参考 & 建议

作为开发,平时开发的任务繁忙就没啥时间课外阅读,但是偶尔也要把自己从代码中拔出来。虽然写代码完成功能很有成就感,但是如果有机会踏入自己不曾涉足的世界,也是人生一大乐事。

参考文章

网页编码就是那点事
ASCII 码表
Code Charts
Unicode 和 UTF-8 有什么区别?
字符编码笔记:ASCII,Unicode 和 UTF-8
细说:Unicode, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4

建议阅读

ANSI 是什么编码?
汉字编码:GB2312, GBK, GB18030, Big5
细说:Unicode, UTF-8, UTF-16, UTF-32, UCS-2, UCS-4
UTF-8, UTF-16, UTF-32 & BOM
JavaScript’s internal character encoding: UCS-2 or UTF-16?

系列文章内容预告

当然 UNICODE 并不是只有这么简单,一个码位一个字符,比如印度语 नमस्ते,这是 hello 的意思,这是怎么实现的也大有文章。

另外 kùdāng 这个字符串在浏览器环境,输出的 length 为什么是 8 而不是 6,这就涉及到 javascript 内部的编码格式问题。

这个钻研下去内容实在太多了,我得好好整理一下,最后再结合自己的理解写成文章。

页脚

代码即人生,我甘之如饴。

技术不断在变
头脑一直在线
前端路漫漫
我们下期见

by --- 裤裆三重奏

我在这里 gayhub@jsjzh 欢迎大家来找我玩儿。如果你看完文章之后有所收获,想要夸夸我,一个 Star ✨就是对我最好的鼓励。

欢迎小伙伴们直接加我 vx,拉你进群一起搞事情,记得备注一下你是从哪里看到文章的。

ps: 如果图片失效,可以加我 wechat: kimimi_king

<div align="center">
<image src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/53fb3e16b1f64ebbb8aee73734371257~tplv-k3u1fbpfcp-watermark.image" />
</div>


裤裆三重奏
788 声望936 粉丝

认真的灵魂会发光