前言
这篇文章是研究上传文件的时候扩展出来的知识点,因为上传文件的时候会涉及到文件的编码等内容,作为前端平时接触这些东西又比较少,我又是个不彻底搞清楚问题就不罢休的人,所以往往会因为一个小问题牵扯出来一堆问题,接着,疑问又带来新的疑问(禁止套娃!)。
写这篇文章花了很长时间,如果你看完之后有所收获,一个 Star ✨就是对我最好的鼓励。
ps: 文章不会纠结一些不太重要的信息,比如某协议是谁提出的,在什么年份提出的,这些基本上看过一次就忘了,也不重要,我会提取重要的信息来分享给大家。
前置知识
在开始之前,先整理一下我们所需要的前置知识,对于这些我们只需要有个简单的印象即可。
计算机常用的单位换算
二进制位、位、比特、bit、b,这些都是代表计算机存储的最小单位「位」,也就是二进制的 0
或 1
。
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 的到来
众所周知,在计算机中,所有的数据存储和运算时都要使用二进制表示,因为计算机用高电平和低电平表示 1
和 0
。然后就有这么一群人,他们决定用 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
。
因为计算机刚开始只在美国使用,那大家相安无事,用的挺好,这些字符按照规定的顺序排排坐所产生的表,就是我们在 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 号以下的符号就称为半角字符。(「,」和「,」看出不同了吗?前者是半角字符,后者是全角字符)
后来这个方案用着还不错,我们就给它取了个名字 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 个码位,其中高代理位和低代理位组合成了辅助平面。
另外,有兴趣的同学可以到 这个网站 上查看 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 推广受阻还有一个原因就是,存储相同内容的英文文档,空间占用会翻倍,更别说其他文字了。
解决兼容和空间问题,聪明的 UTF-8
这个时候,为了兼容 ASCII,UTF-8 就出现了,他是 UNICODE 的编码方案。在实现上他不仅可以兼容 ASCII,并且由于它是可变长编码方案,对于纯英文的文档,可以使空间使用减半。
但是对于中文文档,相对于 GBK 编码方案还是导致空间增加了,因为原先我们 GBK 用 2 个字节组合出了几万个码位,但在 UTF-8 中,即使是 BMP 平面的汉字,那也需要 3 个字节。为什么?我们直接看下面的例子,对于”裤裆“的”裤“字,UTF-8 是如何编码的。
首先这里有一个表格,是 UTF-8 的替换模板,模板也是有规律的,如果开头是 0,那就占用一个字节,如果开头是 1,那连续几个 1 就表示有几个字节。
十六进制范围 | UTF-8 模板 |
---|---|
0x0000 ~ 0x007f | 0xxxxxxx |
0x0080 ~ 0x07ff | 110xxxxx 10xxxxxx |
0x0800 ~ 0xffff | 1110xxxx 10xxxxxx 10xxxxxx |
0x10000 ~ 0x10ffff | 11110xxx 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 编码后,变成了 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 面。
这个 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 编码,另外 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 ~ 0xdbff
和 0xdc00 ~ 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 被称为万国码,但如果以后如果有了宇宙码呢,那是不是要重启一套编码,或者被迫使用脑电波传输?(狗头
字符集和编码方案的阶段总结
至此,字符集和编码方案就先告一段落,我们来做一个总结
- 刚开始由美国提出了 ASCII 码表,并且这也是早期计算机的编码方案,规定 8 个位为 1 个字节,一共有
2^8 = 256
个码位。 - 后来计算机传到了其他国家,所以又出了 ASCII 扩展表,把 127 位之后的码位占满了。
- 再后来,计算机传到了中国,我们通过特定的规则,将两个 ASCII 码组合,得到了中国汉字编码,当时流行的编码方案有 GB2312、GBK、GB18030、Big5 等。
- 最后,国际标准化组织终止了各国创造各自编码方案的行为,打造了 UNICODE,试图将全世界的字符都收纳其中。
- UNICODE 在提出之初直接规定使用 2 个字节表达所有字符,并没有考虑兼容任何编码方案,包括 ASCII,所以推广十分艰难,直到 UTF-8 编码方案的问世。
- UNICODE 的码位一共有
0x10ffff
个,这些码位被分为 17 个平面,我们常用的是 BMP 平面。 - UNICODE 的 BMP 平面前 127 个码位内容直接照搬 ASCII,并将
0xd800 ~ 0xdbff
和0xdc00 ~ 0xdfff
规定为高低代理位,他俩组合生成辅助平面所需码位。 - UTF-8 是可变长的编码方案,编码结果是 1 ~ 4 个字节,既保证了英文字符的编码结果和 ASCII 初版字符集保持一致,又可以通过特定规则,编码所有的 UNICODE 码位。
- UTF-16 也是可变长编码方案,编码结果是 2 或者 4 个字节,BMP 平面的字符用 2 个字节表示,辅助平面用高低代理位组合计算后的 4 个字节表示。并且由于没有 UTF-8 的特殊规则,所以存在大小端字节序的问题。
- UCS-2 是 UTF-16 的前身,编码结果是 2 个字节,不支持辅助平面的字符表示。编码空间为
0x0000 ~ 0xffff
。 - UTF-32 是定长编码方案,编码结果是固定的 4 个字节,和 UTF-16 一样,也存在字节序的问题。
- UCS-4 是 UTF-32 的前身,编码结果是固定的 4 个字节。编码空间为
0x00000000 ~ 0x7fffffff
。 - UTF 系列编码方案的编码空间都为
0x000000 ~ 0x10ffff
。
验证阶段,实际看到才会记得牢
前面说了这么多东西,可能有的同学转眼就忘了,那我们来实际演示一下,不同编码对于文件大小的影响。
首先是英文文档,创建 3 个文件,内容都是 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