正文开始前的絮叨
不看并不影响后面的理解
弄清各种编码的念头在我脑海里面已经浮现过 N 回了,我相信很多人也一样,特别是接触过后端语言的人,会时不时碰到乱码,需要按正确的编码类型解码才能得到正常的文字。一提到编码,我们脑海里可能冒出了 N 多词汇:ASCII 码、Unicode、utf8、gbk、Latin1…他们分别是什么?在什么场合下使用?了解 js/node.js 的小伙伴,对如下两个函数并不陌生:
encodeURI('一'); // "%E4%B8%80"
escape('一'); // "%u4E00"
这两个函数运算的结果分别对应上面提到的哪种“编码”呢?
先说结论
ASCII
是给常见符号、字母、数字编码的规则Unicode
是给所有文字编码的规则utf8
是在计算机上对Unicode
的实现gbk
、Latin1
…是各种文字的使用国在Unicode
成为世界通用标准之前,自己出的编码规则,一般兼容ASCII
不得不说的编码历史
我们知道,计算机最底层是用二进制表示的,那如何让这些二进制的组合有意义呢?比如我如何表示字母a
,如何表示数字1
呢?
ASCII 的诞生
计算机的先贤们给常用的书写字符、控制字符都分配了唯一的编号,从 0 到 127,总共 128 个字符,这个编号后面被美国国家标准学会制定成了国家标准,叫ASCII(American Standard Code for Information Interchange),中文翻译叫:美国标准信息交换码(谁叫人家走在计算机发展的前列呢)。所以,我们常见的英文字母也好、数字也好都可以在 ASCII 码表中找到它对应的编号,比如:a
的编号十进制是97
,数字1
的编号十进制是49
,标点符号!
的编码十进制是33
…
这样,英语国家就可以愉快的使用计算机处理各种文本了,比如,读取一个文件得到的二进制是:011000010110001001100011
,我们知道 8 位为一个字节,我们这里可以分成三个字节:
01100001
01100010
01100011
分别转成十进制就是97
、98
、99
:
parseInt('01100001', 2); // 97
parseInt('01100010', 2); // 98
parseInt('01100011', 2); // 99
查 ASCII 码表我们知道,这个文件的内容就是abc
。
Latin1,gbk
我们知道,一个字节有 8 个比特位,一个比特位又可以用0
和1
表示两种状态,那么,这 8 个比特位都用上的话,理论上来说可以表示 2^8 =256 个字符,而现在 ASCII 码才占用了 128 个,多浪费啊!!后来,欧洲一些使用拉丁字母的国家率先发现了这个“惊天秘密”,在 ASCII 码表的后面继续编码,从 161 开始 255 结束,加入 96 个字母及符号,还搞了个国际化标准名,叫ISO 8859-1,又叫 Latin-1,从此这些国家也可以愉快的使用计算机了。
中国人也不甘示弱,但这剩下的 128 个都不够我写首长诗的,中文汉字实在太多了(汉字总数接近十万,常用汉字也有好几千),一个字节是不够表示的了,至少得用两个字节(2^16 =65536)表示,制定了 GB2312 编码表,及后续的升级版GBK,当然还是得兼容 ASCII 码。(至于在两字节的 gbk 中,如何区分某一字节该识别为 ASCII 码还是和前、后字节组合识别成单独的一个字符,这超出本文的主题了,感兴趣可以去了解下。
比如 gbk 编码得到的十六进制:61d2bb
,你怎么解码回去?)
Unicode
部分内容来自刨根究底字符编码之八——Unicode 编码方案概述
在那个各种语言编码独自发育的时代,终于有组织站了出来,着手统一全世界所有文字、符号的编码,这个编码就是Unicode
,俗称万国码
。Unicode
为每个字符分配唯一的字符编号(即码点编号、码点值、CodePoint)
Unicode
是国际组织制定的可以容纳世界上所有文字和符号的字符编码方案。目前的Unicode
字符分为 17 组编排,每组称为平面(Plane),而每平面拥有 65536 个码位,共 17*65536=1114112 个。然而目前只用了少数平面。
其中第 0 个平面 BMP(Basic Multilingual Plane 基本多语言平面、基本多文种平面、基本平面、平面 0),基本涵盖了当今世界上正在使用中的常用字符。我们平常用到的 Unicode 字符,一般都是位于 BMP 平面上的。BMP 平面以外其他的增补平面(也称为辅助平面)要么用来表示一些非常特殊的字符(比如不常用的象形文字、远古时期的文字等),且多半只有专家在历史和科学领域里才会用到它们;要么被留作扩展之用。目前 Unicode 字符集中尚有大量编号空间未被使用。
另外,BMP 平面有一个专用区(Private Use Zone):0xE000~0xF8FF(十进制 57344~63743),共 6400 个码点,被保留为专用(私用),因而永远不会被分配给任何字符;还有一个被称为代理区(Surrogate Zone)的特殊区域:0xD800-0xDFFF(十进制 55296~57343),共 2048 个码点,目的是用基本平面 BMP 中的两个码点“代理”表示 BMP 以外的其他增补平面的字符(UTF-16 会用到)。
现在倒是齐活了,100w 个字符,理论上是用不完了。然而,需要面临一个新问题了(这个问题 gbk 给绕过去了,还是上面那节最后的问题),0x0000-0x10FFFF
这么多字符,必须得用至少4
个字节来表示单个字符,比如字符a
,若采用Unicode
,则需要表示成00000000000000000000000001100001
(知道为什么需要这样表示么?其实和 ASCII 码表为什么约定一个字节是 8 位是一样的道理,不约定某种编码需要按多少位作为一个单位解析的话,就会存在解析的多义性,比如你拿到a
的二进制01100001
,一个个比特位读是一回事,两个比特位读又是另一个意思…只有 8 个比特位一起读才能按 ASCII 码规则读成字符a
)。而用 ASCII 码则是:01100001
。
原来 1 个字节表示的a
,现在需要 4 个字节。意味着我原来存储的 ASCII 编码文档,换成 Unicode 编码存储,占用的空间需要翻 4 倍,这哪能忍。
UTF-8 编码
这时候UTF-8
跳了出来:我觉得我该出现了。
UTF-8
是一种针对Unicode
的可变长度字符编码,用 1 到 6 个字节编码Unicode
字符。
上面不是说变长编码有多义性么,UTF-8
是怎么让计算机识别不同长度的编码呢?
两条规则:
- 单字节字符:
第一位
设置为0
,后面七位
设置为对应的Unicode
码 - n(n>1)字节字符:
第一字节
的前n
位均设置为1
,n+1
位设置为0
,后面的字节前两位
均设置为10
,剩下的二进制位从右至左用这个字符的Unicode
码填充,不足的位置填充 0
那么,一个字符该编码为几个字节怎么定呢?
对照如下表:
Unicode 范围 | 编码格式 |
---|---|
U+0000 – U+007F | 0xxxxxxx |
U+0080 – U+07FF | 110xxxxx 10xxxxxx |
U+0800 – U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 – U+1FFFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
U+200000 – U+3FFFFFF | 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
U+4000000 – U+7FFFFFFF | 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
我们拿个字符练练手,汉字:一
。
我们查Unicode 表,知道它的Unicode
编码是4E00
,当然,如果你熟悉 JavaScript,也可以这样得到:'一'.charCodeAt(0).toString(16)
或escape('一')
,但由于历史原因,这两种方法并不总是可用,比如说这个汉字:\uD842\uDFB7
。感兴趣自己试下,然后查找相关资料解惑。
ps: ES6 下提供了"\uD842\uDFB7".codePointAt().toString(16)
方法获得准确的 Unicode 码。
扯远了,我们回到手动编码的话题。
对照上面的编码表,我们知道4E00
在U+0800 - U+FFFF
区间,所以其编码格式为1110xxxx 10xxxxxx 10xxxxxx
,也就是三个字节,我们将4E00
转成二进制,0x4E00.toString(2)
,得到结果:100111000000000
,我们将其与编码格式的xxx
位置从右至左对其:
1110xxxx 10xxxxxx 10xxxxxx
100 111000 000000
填充所有的x位置得到结果:
111001001011100010000000
将其转为 16 进制parseInt('111001001011100010000000',2).toString(16)
,得到:e4b880
即为汉字一
的UTF-8
编码了。
验证方式有二:
- node.js 安装
iconv-lite
库,然后:
const iconv = require('iconv-lite');
iconv.encode('一', 'utf8');
// 得到 <Buffer e4 b8 80>
- 通过 JavaScript 内置函数
encodeURI
:
encodeURI('一');
// 得到 %E4%B8%80
BOM
BOM(Byte Order Mark),字节顺序标记,出现在文本文件头部,Unicode 编码标准中用于标识文件是采用哪种格式的编码。
UTF-8
一般用EFBBBF
作为文件的开头,声明自己的编码类型,一般软件在读取文件的前三个字节的时候便知道了文件的编码类型。但某些编程语言,比如php
,在读取文件的时候,会无视BOM
,换句话说,它会将这三个字节当做可显示字符去读取,结果就会出现文件开头乱码的情况。
实际上,由于UTF-8
编码的特殊性,多读取几个字节,软件是可以猜出当前文档的编码形式的,所以就出现了很多编辑器保存文件时会有UTF-8
与UFT-8-BOM
两种形式的编码方式可选。
与前端相关的编码知识
\数值 deprecated
'\141' === 'a'; // true \x x为Unicode码中00~FF范围内字符的8进制编码
在css中,则对应的是16进制的Unicode码,以下显示字符“一”
body:after{
content: '\4e00' ;
}
\u
'\u4E00' === '一'; // true \u + Unicode码16进制
'\u{20BB7}' === '\uD842\uDFB7'; // true 超过两个字节的用花括号包围(ES6支持)
\x
'\x61' === 'a'; // true \x + Unicode码中00~FF范围内字符的16进制编码
\c
仅限于正则表达式中,26 个字母对于开始的 26 个 Unicode 字符
/^\ca$/.test('\u0001'); // true
/^\cb$/.test('\u0002'); // true
// ...省略中间23个字母组合
/^\cz$/.test('\u001A'); // true
&#x;html 实体
规则&#
+ 字符 Unicode 码十进制 + ‘;’
字符一
的 Unicode 十进制为:19968
,所以有下面:
<span>&\#19968;</span>
<!-- 二者等同 -->
<span>一</span>
当然,也支持如下的16进制形式:
<span>&\#x4e00;</span>
<!-- 二者等同 -->
<span>一</span>
encodeURI/encodeURIComponent
转换U+0000 - U+007F
以外的字符为 utf-8 形式:
encodeURIComponent('一'); // "%E4%B8%80"
escape
除[a-zA-Z0-9]
及@*_+-./
以外字符的UTF-16码
:
escape('一'); // %u4E00 => \u4E00
escape('!'); // %21 => \u0021
escape('\uD842\uDFB7'); // %uD842%uDFB7 => \uD842\uDFB7
unescape
解码形如%60
、%u4E00
编码,由于以符号%
为单位解析,所以,我们可以通过该函数很容易得到utf8字符串的单字节形式:
var encode = encodeURI('一');
unescape(encode) // "ä¸"
TextDecoder
解码常见编码
const gbkDecoder = new TextDecoder('gbk')
console.log(gbkDecoder.decode(new Uint8Array([0xd2,0xbb]))) // 输出“一”
TextEncoder
转换字符为utf-8字节码
encoder = new TextEncoder()
console.log(encoder.encode('一')) // 输出 Uint8Array(3) [228, 184, 128]