一次性搞清字符编码

正文开始前的絮叨

不看并不影响后面的理解

弄清各种编码的念头在我脑海里面已经浮现过 N 回了,我相信很多人也一样,特别是接触过后端语言的人,会时不时碰到乱码,需要按正确的编码类型解码才能得到正常的文字。一提到编码,我们脑海里可能冒出了 N 多词汇:ASCII 码、Unicode、utf8、gbk、Latin1…他们分别是什么?在什么场合下使用?了解 js/node.js 的小伙伴,对如下两个函数并不陌生:

encodeURI('一'); // "%E4%B8%80"
escape('一'); // "%u4E00"

这两个函数运算的结果分别对应上面提到的哪种“编码”呢?

先说结论

  1. ASCII是给常见符号、字母、数字编码的规则
  2. Unicode是给所有文字编码的规则
  3. utf8是在计算机上对Unicode的实现
  4. gbkLatin1…是各种文字的使用国在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

分别转成十进制就是979899:

parseInt('01100001', 2); // 97
parseInt('01100010', 2); // 98
parseInt('01100011', 2); // 99

查 ASCII 码表我们知道,这个文件的内容就是abc

Latin1,gbk

我们知道,一个字节有 8 个比特位,一个比特位又可以用01表示两种状态,那么,这 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是怎么让计算机识别不同长度的编码呢?

两条规则:

  1. 单字节字符:第一位设置为0后面七位设置为对应的Unicode
  2. n(n>1)字节字符:第一字节的前n位均设置为1n+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 码。

扯远了,我们回到手动编码的话题。

对照上面的编码表,我们知道4E00U+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编码了。

验证方式有二:

  1. node.js 安装iconv-lite库,然后:
const iconv = require('iconv-lite');
iconv.encode('一', 'utf8');
// 得到 <Buffer e4 b8 80>
  1. 通过 JavaScript 内置函数encodeURI:
encodeURI('一');
// 得到 %E4%B8%80

BOM

BOM(Byte Order Mark),字节顺序标记,出现在文本文件头部,Unicode 编码标准中用于标识文件是采用哪种格式的编码。

UTF-8一般用EFBBBF作为文件的开头,声明自己的编码类型,一般软件在读取文件的前三个字节的时候便知道了文件的编码类型。但某些编程语言,比如php,在读取文件的时候,会无视BOM,换句话说,它会将这三个字节当做可显示字符去读取,结果就会出现文件开头乱码的情况。

实际上,由于UTF-8编码的特殊性,多读取几个字节,软件是可以猜出当前文档的编码形式的,所以就出现了很多编辑器保存文件时会有UTF-8UFT-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]

参考文档

  1. 字符编码笔记:ASCII,Unicode 和 UTF-8
  2. 所谓编码–泛谈 ASCII、Unicode、UTF8、UTF16、UCS-2 等编码格式
  3. 刨根究底字符编码
  4. JavaScript character escape sequences