JavaScript与文字编码

在学习阮一峰的 字符串的扩展|ECMAScript 6 入门 章节的时候其中有一处“JavaScript 共有 6 种方法可以表示一个字符”。然后想起之前看的一篇关于文字编码的文章,所以现在打算重新复习一下文字编码

文字编码

在物理层面上,我们存入计算机的所有文件都是以二进制形式保存的。但现实生活中,我们会发现计算机中保存的文件形式各式各样,有音频文件、文本文件、视频文件、还有我们程序员熟悉的源代码文件等等。由此可以看出,不同文件的差别只是我们解读二进制的方式不同。然而即使同为一个类别的文件,解读二进制文件又会有不同。其中我们以网页开发中最常接触到的文本文件的编码方式拿来细说。这里所说的文本文件,一般是指基于字符编码的文件。计算机发展到现在为止,出现的字符编码就有 ASCII、GB2312、Unicode等等。

ASCII

ASCII(American Standard Code for Information Interchange),中文名称为美国信息交换标准代码。是基于拉丁字母的一套电脑编码系统。它主要用于显示现代英语和其他西欧语言。它是现今最通用的单字节编码系统。ASCII码又分为标准ASCII码和扩展ASCII码。

使用 8 个二进制位进行编码的方案,最多可以给 256 个字符(包括字母、数字、标点符号、控制字符及其他符号)分配数值。基本的 ASCII 字符集共有 128 (0~127)个字符,其中有 96 个可打印字符,包括常用的字母、数字、标点符号等,另外还有 32 个控制字符。因为这 128 个基本字符就可以用 7 位二进制表示。所以多出来的最高位设为 0 (在数据传输时可用作奇偶校验位)。剩下的 128 个拿来扩充 ASCII 字符集,为了以后方便加入新的字符。这些扩充字符的编码最高为均为 1 的二进制数字(即十进制 128~255),称为扩展 ASCII 码,各国为了可以在计算机使用他们自己的文字,于是就在扩展 ASCII 码上各自做文章。虽然剩下的128个扩展 ASCII 码数量有限,但在实际应用中是可以满足部分国家文字编码的需求。因此针对扩展的 ASCII 码,不同的国家有不同的字符集,但它不是国际标准。比如 Latin1 字符集属于扩展ASCII码的一种,国际标准名为 ISO-8859-1,它把位于128-255之间的字符用于拉丁字母表中特殊语言字符的编码,也因此而得名。不过欧洲语言不是地球上的唯一语言,亚洲和非洲语言并不能被Latine1字符集所支持。这些基于基本 ASCII 字符集进行扩展出来的单字节字符集在不同国家地区有不同具体的方案。

GB2312

等中国人开始使用计算机时发现如果还是使用扩展 ASCII 码来制定中文的字符集是行不通,因为常用汉字就已超过6000个。于是这时候国人就自主研发,把那些127号之后的奇异符号们直接取消掉。规定:一个小于127的字符的意义与原来相同,但两个大于127的字符连在一起时,就表示一个汉字,前面的一个字节(他称之为高字节)从0xA1用到 0xF7,后面一个字节(低字节)从0xA1到0xFE,这样我们就可以组合出大约7000多个简体汉字了。在这些编码里,我们还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在 ASCII 里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的”全角”字符,而原来在127号以下的那些就叫”半角”字符了。中国人民看到这样很不错,于是就把这种汉字方案叫做 “GB2312″。GB2312 是对 ASCII 的中文扩展。

GBK

但是中国的汉字太多了,后来还是不够用,于是干脆不再要求低字节一定是127之后的内码,只要第一个字节是大于127就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。结果扩展之后的编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近20000个新的汉字(包括繁体字)和符号。

GB18030

后来少数民族也要用电脑了,于是我们再扩展,又加了几千个新的少数民族的字,GBK 扩成了 GB18030

ANSI

各国在 ASCII 的基础上制定了自己的字符集,这些从 ANSI 标准派生的字符集被习惯的统称为 ANSI 字符集,它们正式的名称应该是 MBCS(Multi-Byte Chactacter System,即多字节字符系统)。这些派生字符集的特点是以 ASCII 127 bits 为基础,兼容 ASCII 127,他们使用大于128的编码作为一个 Leading Byte,紧跟在 Leading Byte 后的第二(甚至第三)个字节与 Leading Byte 一起作为实际的编码,由此产生了 GB2312、GBK、GB18030、Big5、Shift_JIS 等各自的编码标准。但西欧国家也有根据本国文字的数量仅需要 128 个扩展的 ASCII 码就完成制定了单字节字符集。

Unicode

为了解决不同国家地区间编码方案的不同引起的不便,于是就有了这样一个想法:将全世界所有的字符包含在一个集合里,计算机只要支持这一个字符集,就能显示所有的字符,再也不会乱码了。

Unicode 就是在这种背景下诞生了。Unicode 规定了每个字符的编号,这个编号也叫做“码点”(code point)。它从 0 开始,目前 Unicode 的最新版本一共收入了 109449 个符号,其中的中日韩文字为 74500 个。这么多符号,Unicode 不是一次性定义的,而是分区定义。每个区可以放 65536 ($2^{16}$)个字符,称为一个平面(plane)。目前,一共有 17 ($2^{5}$)个平面,也就是说,整个 Unicode 字符集的大小现在是 $2^{21}$ 个。其中最前面的 65536 个字符位,称为基本平面(缩写BMP),它的码点范围是从 0 ~ $2^{16}-1$,写成十六进制就是从 U+0000 ~ U+FFFF 。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。剩下的字符都放在辅助平面(缩写SMP),码点范围从 U+010000 ~ U+10FFFF。

Unicode 只规定了每个字符的码点,到底用什么样的字节序表示这个码点,就涉及到编码方法。

UTF-32

最直观的编码方法就是,每个码点使用 4 个字节表示,字节内容一一对应码点。这种编码方法就叫做 UTF-32。例如码点 597D

U+597D = 0x0000 597D

UTF-32

UTF-32 的优点在于,转换规则简单直观,查找效率高。缺点在于浪费空间,同样内容的英语文本,它会比 ASCII编码大四倍。这个缺点很致命,导致实际上没有人使用这种编码方法,HTML 5 标准就明文规定,网页不得编码成UTF-32。

UTF-8

UTF-32 太过于浪费空间,所以急需一种节省空间的表示方案,这导致了 UTF-8 的诞生。UTF-8 是一种变长的编码方法,字符长度从 1 个字节到 4 个字节不等。越是常用的字符,字节越短,最前面的 128 个字符,只使用 1 个字节表示,与 ASCII 码完全相同。由于 UTF-8 这种节省空间的特性,导致它成为互联网上最常见的网页编码。

字节 编号范围
0x0000 - 0x007F 1
0x0080 - 0x07FF 2
0x0800 - 0xFFFF 3
0x010000 - 0x10FFFF 4

我们来看看 UTF-8 具体是如何存储字符的,如下面的字符

we 发 财 🤑

上面的字符对应的十进制编码如下

其中 20 是空格的编码,可以看到一个英文还是 1 个字节,一个中文用了 3 个字节,而一个 Emoj 用了 4 个字节。它怎么知道每次应该读取多少个字节呢?如下图所示:

如果一个字节是 0 开头,表示这个字节就表示一个字符,如果是 3 个 1 开头表示这个字符要占 3 个字节,有多少个 1 就表示当前字符占用了多少个字节。这个就是 UTF-8 的存储特点,UTF 规定了每个字符的编号,而 UTF-8 定义了字符应该怎么存储。从 Unicode官网 可以查到,“我”的UTF 十六进制编码是 \u6211,如下图所示:

6211 怎么变成 UTF-8 编码呢?因为 6211 落在下面这个范围:

U+ 0800 ~ U+ FFFF: 1110XXXX 10XXXXXX 10XXXXXX

所以是这么转的:

“我”的 UTF-8 就是 E6 88 91

UTF-16

UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000到U+FFFF),要么是 4 个字节(U+010000到U+10FFFF)。

但是现在面临一个问题就是:当我们遇到两个字节,怎么看出它本身是一个字符,还是需要跟其他两个字节放在一起解读?然而在基本平面内,从 U+D800 ~ U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段被拿来映射辅助平面的字符。当我们遇到两个字节,发现它的码点在 U+D800 ~ U+DBFF 之间,就可以断定,紧跟在后面的两个字节的码点,应该在U+DC00 ~ U+DFFF之间,这四个字节必须放在一起解读。

到此,我们可以看出,UTF-8 的优点在于 1 个英文只要 1 个字节,但是 1 个中文却是 3 个字节,UTF-16 的优点在于编码长度固定,1 个中文只要 2 个字节,但是 1 个英文也要 2 个字节。所以对于英文网页 UTF-8 编码更加有利,而对于中文网页使用 UTF-16 应该更加有利。因为绝大部分的中文都是落在 U+0000 ~ U+FFFF。

“联通”两字与Windows记事本

在简体中文 Windows 操作系统中,ANSI 编码代表 GBK 编码;在繁体中文 Windows 操作系统中,ANSI 编码代表 Big5;在日文 Windows 操作系统中,ANSI 编码代表 Shift_JIS 编码。而记事本程序保存非英文字符时的默认编码方案就是 ANSI,即根据 Windows 语言版本选择编码方式,就如上述中文 Windows 操作系统使用 GBK 字符集。保存后再使用记事本程序打开会发现原来保存的“联通”两字现在变成了不知什么鬼的字符,就是乱码了。因为当一个软件打开一个文本时,它要做的第一件事是决定这个文本究竟是使用哪种字符集的哪种编码保存的。软件有三种途径来决定文本的字符集和编码:

  1. 文本文件开头的字符集标记
  2. 询问用户采用哪个字符集
  3. 软件自己猜测

结果因为“联通”两字的 GBK 编码看起来更像UTF-8编码,记事本程序就根据 UTF-8 编码规则解码这两个字,从而导致最后的乱码。

Base64

早期电子邮件的 SMTP(Simple Mail Transfer Protocol) 协议仅限于传送 7 位的 ASCII 码。许多其他非英语国家的文字以及多媒体资源就无法传送,所以说早期的电子邮件就只能传送英文文字。或许是早期电子邮件的诞生的时候,美国人民以为只有他们自己会传送文字以取消传统的电报业务。

于是后来就提出了通用互联网邮件扩充(MIME)。MIME 并没有改动或取代 SMTP。MIME 的意图是继续使用原来的邮件格式,但增加了邮件主体的结构,并定义了传送非 ASCII 码的编码规则。因此在这种情况下,Base64 就诞生了。

Base64 可以把原本 ASCII 码的控制字符甚至 ASCII 码之外的字符都转成可打印的 6 bit 字符,也就是说用 6 bit 字符表达了原本 8 bit 字符。我们看看下面的图片:

像表格中那样,8 Bit$\times$3 的字符串可以每 6 个 bit 分成一组,每一组 bit 对应一个十进制的 index,每一个 index 值又对应了 Base64 的字符。由于 6 bit 的二进制代码共有 64 种不同的值,从 0 到 63。用 A 表示 0 ,用 B 表示 1 ,等等。26 个大写字母排列完毕后,接下去再排 26 个小写字母,再后面是 10 个数字,最后用“+”表示 62,而用“/”表示 63。再用两个连在一起的等号 “==” 和一个等号 “=” 分别表示最后一组的代码只有 8 bit 或 16 bit。

JavaScript 与编码

JavaScript使用哪种编码

JavaScript 语言采用 Unicode 字符集,但是它编码使用的既不是 UTF-16,也不是UTF-8,更不是 UTF-32,而是 UCS-2。他们之间是什么关系呢?我们讲讲一些历史。

互联网还没出现的年代,曾经有两个团队,不约而同想搞统一字符集。一个是 1988 年成立的Unicode 团队,另一个是 1989 年成立的 UCS 团队。等到他们发现了对方的存在,很快就达成一致:世界上不需要两套统一字符集。1991 年 10 月,两个团队决定合并字符集。也就是说,从今以后只发布一套字符集,就是 Unicode,并且修订此前发布的字符集,UCS 的码点将与 Unicode 完全一致。

UCS与Unicode

UCS 的开发进度快于 Unicode,1990 年就公布了第一套编码方法 UCS-2,使用 2 个字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以 2 个字节就够用了。)UTF-16 编码迟至 1996 年 7 月才公布,明确宣布是 UCS-2 的超集,即基本平面字符沿用 UCS-2 编码,辅助平面字符定义了 4 个字节的表示方法。

两者的关系简单说,就是 UTF-16 取代了 UCS-2 ,或者说 UCS-2 整合进了 UTF-16。所以,现在只有 UTF-16,没有 UCS-2。

JavaScript为什么不使用 UTF-16

那么,为什么 JavaScript 不选择更高级的 UTF-16,而用了已经被淘汰的 UCS-2 呢?答案很简单:非不想也,是不能也。因为在 JavaScript 语言出现的时候,还没有 UTF-16 编码。

JavaScript字符编码历史

JavaScript 字符函数的局限

由于 JavaScript 只能处理 UCS-2 编码,造成所有字符在这门语言中都是 2 个字节,如果是 4 个字节的字符,会当作两个双字节的字符处理。JavaScript 的字符函数都受到这一点的影响,无法返回正确结果。所以当遍历字符串的时,必须对码点做一个判断,然后手动调整。

  • String.prototype.replace()
  • String.prototype.substring()
  • String.prototype.slice()

上面的字符函数都只对 2 字节的码点有效。要正确处理 4 字节的码点要手动处理。

ES6

JavaScript 的下一个版本 ECMAScript 6(ES6),大幅增强了 Unicode 支持,基本上解决了上面的问题。新增了以下特点:

  • ES6能够自动识别 4 字节的码点
  • 允许直接用码点表示 Unicode 字符
  • 新增了几个专门处理 4 字节码点的函数
  • 对正则表达式添加了 4 字节码点的支持
  • Unicode 正规化

参考

Donate comment here