JavaScript 笔记(一):字符编码

作为一个比较懒的人,我没有记笔记的习惯,结果大量不算常用的的内容就都只是记了个大概,每次用到时都只能现场谷歌。为了改掉这个习惯,今后遇到有趣的事情都会记录这样一篇笔记。

一方面,这些笔记可以让我在习惯性的遗忘一些知识时能够更快的回想起了,另一方面,也许也可以给同样正在学习 JavaScript 的同志们一些经验。

——王兆基 二〇一八年十二月八日

0x00:URI 编码

不管知不知道 JavaScript,几乎所有人都在浏览器的地址栏里见过 URI 编码,也就是类似于这样的东西:

图 1 浏览器地址栏的汉字 URI 编码

图中被红框框住的「%E7%8E%8B%E4%BC%AF%E6%96%87」,实际上就是汉字「王伯文」在 URI(Uniform Resource Identifier,即统一资源标识符,也就是我们俗称的网址)中的「转义序列(escape sequences)」。这些转义序列按照标准,是用 UTF-8 的编码方式编码每个字符的。

当然,如果只是使用的话,其实只需要知道,JavaScript 里可以用 encodeURI 与 encodeURIComponent 方法将 URI 中的字符进行处理,使用一到四个转义序列来表示字符串中的每个字符的 UTF-8 编码。但是我们总是会有许许多多的疑问,比如:encodeURI 方法和 encodeURIComponent 方法有什么区别?为什么是一到四个转义序列?为什么用的是 UTF-8 编码? UTF-8 编码又是什么?这个转换过程是怎样的?这就需要去了解更多的知识了,我将记录在下面。

0x01:escape()、encodeURI() 与 encodeURIComponent()

在 JavaScript 中,一共有三个方法可以对字符串进行 URI 编码,分别为 escape()、encodeURI() 与 encodeURIComponent(),它们的区别如下。

escape() 方法

escape() 比较惨,它已经被从 Web 标准中删除了,是一个已废弃的方法,它的作用就是生成新的由十六进制转义序列替换的字符串,特色字符如: @*_+-./ 被排除在外。这个方法已经很有年头了,如果只处理 ASCII 码或者扩展 ASCII 码的话,这个方法的执行结果并没有什么问题。因为它会先判断字符的 16 进制格式值,当该值小于等于 0xFF 时,用一个 2 位转义序列: %xx 表示,大于的话则使用 4 位序列:%uxxxx 表示。

ASCII 码只有 7 位,共 128 个字符,EASCII 码也只有 8 位 256 个字符,符合 0-0xFF 的范围,但是,面对超出这个范围的字符,escape() 方法就显得比较坑爹了,转换成%uxxxx 的 Unicode 内码有什么用?网络上现在都在用 UTF-8 的方式来传输数据了。

图 2 MDN 上对 escape() 方法的提示

于是,这个与标准八字不合的历史遗留方法就走远了,人们提出了新的方法来替代它。

encodeURI() 方法

encodeURI() 函数通过将特定字符的每个实例替换为一个、两个、三或四转义序列来对统一资源标识符 (URI) 进行编码。为什么是 1-4 个转义序列?这实际上与 UTF-8 编码有关,下一部分将会说明,这里权且先跳过不展开。encodeURI() 函数的特点在于,假定一个 URI 是完整的 URI,那么无需对那些保留的并且在 URI 中有特殊意思的字符进行编码,也就是说,它不会将下列的字符替换为转义序列,因为这些字符本来就应该是一个合法 URI 的一部分:

类型 包含
保留字符 ; , / ? : @ & = + $
非转义的字符 字母 数字 - _ . ! ~ * ' ( )
数字符号 #

所以,这个方法实际上是对一个 URI 编码时使用的。比如我有这样一个 URI:

https://www.google.com/search?q=王伯文&ie=utf-8&oe=utf-8&client=firefox-b

那么,在用 encodeURI 编码之后,得到的结果就是:

https://www.google.com/search?q=%E7%8E%8B%E4%BC%AF%E6%96%87&ie=utf-8&oe=utf-8&client=firefox-b

因此,encodeURI 自身无法产生能适用于 HTTP GET 或 POST 请求的 URI,例如对于 XMLHTTPRequests, 因为 "&", "+", 和 "=" 不会被编码,然而在 GET 和 POST 请求中它们是特殊字符。

不过,encodeURIComponent 这个方法会对这些字符编码,这就解决了这个问题。

encodeURIComponent()

encodeURIComponent() 是对统一资源标识符(URI)的组成部分进行编码的方法。它使用一到四个转义序列来表示字符串中的每个字符的 UTF-8 编码。正因为这个方法是对 URI 的组成部分编码的,所以其中不应该含有任何 URI 特殊字符,它会转义除了字母、数字、().!~*'-_之外的所有字符,原因如 MDN 上所写:

为了避免服务器收到不可预知的请求,对任何用户输入的作为 URI 部分的内容你都需要用 encodeURIComponent 进行转义。比如,一个用户可能会输入"Thyme &time=again"作为 comment 变量的一部分。如果不使用 encodeURIComponent 对此内容进行转义,服务器得到的将是 comment=Thyme%20&time=again。请注意,"&"符号和"="符号产生了一个新的键值对,所以服务器得到两个键值对(一个键值对是 comment=Thyme,另一个则是 time=again),而不是一个键值对。

—— encodeURIComponent() | MDN

比如说,还是刚才那个 URI,这次它需要作为一个新的 URI 中的某个参数提交:

https://www.google.com/search?q=王伯文&ie=utf-8&oe=utf-8&client=firefox-b

在用 encodeURIComponent() 编码后将变为:

https%3A%2F%2Fwww.google.com%2Fsearch%3Fq%3D%E7%8E%8B%E4%BC%AF%E6%96%87%26ie%3Dutf-8%26oe%3Dutf-8%26client%3Dfirefox-b

和之前 encodeURI 的结果对比,发现那些在 URI 中有特殊含义的字符("/",":","?","&","="),也均被转码了。

0x01:ASCII、扩展 ASCII 与国标中文编码

ASCII 码

如何在计算机中表示文字?这要从计算机如何存储并交换信息说起,在计算机中,所有信息都是二进制数据的整合,每一个二进制位有 0 和 1 两种状态,n 位组合起来就可以表示 2 的 n 次方个状态。举个例子,在日常使用中,我们把 8 个二进制位称之为一个字节,而从 00000000 到 11111111,一个字节最多可以表示 256 种不同的状态。

看起来,计算机里只能保存数字,和文字不沾边。但是我们可以变通一下,借用数学里映射的思想,把每一个自然数和一个字符用某种映射关系对应起来,那么看到这个数字,计算机就知道它代表的是什么字符了,我们把这种数字-字符的对应关系称之为编码系统,或者字符集。接上文的例子,一个字节有 256 种状态,可以表示 0-255 的 256 个自然数,如果我们把每一种状态对应一个字符,那么就可以在计算机中存储并传递文字信息了。

因此,在上世纪六十年代的时候,美国人为了能够在电脑间便利的交换信息,制定了一套电脑编码系统,称之为 ASCIIAmerican Standard Code for Information Interchange,美国信息交换标准代码)。它从 0 开始,为每个符号指定一个编号,这叫做"码点"(code point)。

比如,码点 0 的符号就是 null(表示所有二进制位都是 0)。

当然,这个编码系统主要是针对拉丁字母的,尤其是英语显示的,现代英语字母即使算上大小写,再加上标点符号,一共也没有多少数量,所以这个字符编码实际上并没有用到 256 个字符那么多,只定义了 7 位 128 个字符,其中有 33 个控制字元与 95 个可显示字元,对应表如下图所示。

图 3 1968 年版 ASCII 编码速见表

ASCII 的局限在于只能显示 26 个基本拉丁字母、阿拉伯数目字和英式标点符号,因此只能用于显示现代美国英语(而且在处理英语当中,即使会违反拼写规则,外来词如 naïve、café、élite 等等时,所有重音符号都必须去掉)。

面对这样的问题,欧洲人提出了所谓的扩展 ASCII 码表。

扩展 ASCII 码

EASCIIExtended ASCII,延伸美国标准信息交换码)是将 ASCII 码由 7 位扩充为 8 位而成,完满的使用了一个字节的大小,没有一点浪费。EASCII 的内码是由 0 到 255 共有 256 个字符组成。EASCII 码比 ASCII 码扩充出来的符号包括表格符号、计算符号、希腊字母和特殊的拉丁符号。

在所有扩展 ASCII 码中,使用最为广泛的一种是著名的的 ISO/IEC 8859-1 字符集,也就是所谓的 Latin-1 字符集。它是国际标准化组织ISO/IEC 8859 的第一个 8 位字符集。它以 ASCII 为基础,在空置的 0xA0-0xFF 的范围内,加入 96 个字母及符号,藉以供使用附加符号的拉丁字母语言使用,对应码表如下图所示:

图 4 ISO/IEC 8859-1:1998 字符集码表

国标中文编码

1981 年,中国国家标准总局公布了简体中文的官方字符集,大名鼎鼎的 GB2312,因为这个名字,我小时候一直以为 GB2312 只收录了 2132 个字符(笑)。GB 2312 标准共收录 6763 个汉字,其中一级汉字 3755 个,二级汉字 3008 个;同时收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的 682 个字符。它基本满足了汉字的计算机处理需要,所收录的汉字已经覆盖中国大陆 99.75% 的使用频率,但不能处理人名、古汉语等方面出现的罕用字和繁体字。

在大学里有一门著名的课程叫做「大学计算机基础」,不同的大学可能起了不同的名字,里面一个逃不开的内容就是所谓的汉字编码,什么区位码、交换码、机内码,让无数非计算机专业的同学深恶痛绝。GB 2312 中对所收汉字进行了 “分区” 处理,每区含有 94 个汉字/符号,共计 94 个区。用所在的区和位来表示字符,因此称为区位码

但是,这个区位码如果在计算机内表示,会一些特殊字符冲突。为了避开 ASCII 字符中的 CR0 不可显示字符(十六进制为 0 ~ 1F,十进制为 0 ~ 31)及空格字符 0010 0000(十六进制为 20,十进制为 32),国标码(又称为交换码)规定表示汉字双字节编码范围为十六进制为(21,21)~(7E,7E),十进制为(33,33)~(126,126)。因此,须将 “区码” 和 “位码” 分别加上 32(十六进制为 20H),作为国标码。以避免与 ASCII 字符中 0~32 的不可显示字符和空格字符相冲突。

例如: “万” 字的国标码十进制为:(45+32,82+32)=(77,114),十六进制为:(4D,72H)。

但是,这个国标码(交换码),是我们自己定的,它又和国际上通用的 ASCII 码冲突了,这肯定不行。为了解决这个冲突问题,需要把国标码中的每个字节的最高位都从 0 换成 1,即相当于每个字节都再加上 128(十六进制为 80,即 80H;二进制为 1000 0000),从而得到国标码的 “机内码” 表示,简称 “内码”。

好了,现在既然已经有了内码,就需要将其存储在计算机中了。之前说过,一个字节最多只能存放 256 个字符,GB2312 有六千多个字符,远远超出了一个字节能存放的范围。因此,不同于扩展 ASCII 码只需要一个字节就能存储下所有西洋人需要的字符,我们需要用两个字节来存储一个汉字,这也正是众多科普读物上所谓「汉字需要的存储空间比英文大一倍」的来源。

在使用 GB 2312 的程序通常采用 EUC 储存方法,以便兼容于 ASCII。这种格式称为 EUC-CN。浏览器编码表上的 “GB2312” 就是指这种表示法。

每个汉字及符号以两个字节来表示。第一个字节称为 “高位字节”,第二个字节称为 “低位字节”。

这里又进行了一次转换,“高位字节” 使用了 0xA1–0xF7(把 01–87 区的区号加上 0xA0),“低位字节” 使用了 0xA1–0xFE(把 01–94 加上 0xA0)。由于一级汉字从 16 区起始,汉字区的 “高位字节” 的范围是 0xB0–0xF7,“低位字节” 的范围是 0xA1–0xFE,占用的码位是 72*94=6768。其中有 5 个空位是 D7FA–D7FE。

例如:“啊” 字在大多数程序中,会以两个字节,0xB0(第一个字节)0xA1(第二个字节)储存。(与区位码对比:0xB0=0xA0+16,0xA1=0xA0+1)。

GB2312 收录的字符只能满足我们的基本需要,时代一直在进步,后来国家标准总局又陆续更新了 GB12345GB13000GBK 等与时俱进的中文字符编码集,现在最新的是 GB18030。它是我国现时最新的变长度多字节字符集,对 GB 2312-1980 完全向后兼容,与 GBK 基本向后兼容,支持 GB 13000Unicode)的所有码位,共收录汉字 70,244 个,在 2006 年刚刚开始实施。

这里出现了一个新的名词,Unicode,这将在下一部分继续解释。

0x02:Unicode

Unicode中文:万国码、国际码、统一码、单一码)是电脑科学领域里的一项业界标准。它对世界上大部分的文字系统进行了整理、编码,使得电脑可以用更为简单的方式来呈现和处理文字。

Unicode 发展由非营利机构统一码联盟负责,该机构致力于让 Unicode 方案取代既有的字符编码方案。因为既有的方案往往空间非常有限,亦不适用于多语环境。

—— Unicode - 维基百科,自由的百科全书

我觉得 Unicode 的这个理想非常厉害,有一种浪漫主义的精神蕴含其中,又有着实用主义的特质。

前文说到,我国在上世纪七八十年代为汉字制定了 GB2312 编码表,其他国家当然也在制定自己的编码表,这些编码表除了都保证兼容 ASCII 码这样的老前辈外,并没有什么相同之处。同样的一个数字,在一种编码表里代表甲字符,可能在另一种编码方式里就代表乙字符,而且含义大相径庭。也就是说,如果我们在读取一段文字之前并不知道这段文字用的是什么编码方式,是读取不出来这段文字的内容的。如果用了错误的编码方式读取,比如用甲编码方式读取乙编码方式编码的文字,那数字和字符的对应关系就完全是错误的,读取出来的文字就会变成乱码。

于是,Unicode 在各个国家、地区都为自己的文字制定编码表,深受交换信息的乱码之苦时横空出世,致力于将世界上全部的字符都包含在一个字符集里,计算机只要支持这个万能的字符集,就可以显示任何一个国家,任何一个地区、任何一个组织的所有字符。这样,计算机直接交换信息时就再也不会出现乱码问题了。

目前 Unicode 已经更新了 11 个大版本,包含了超过 13 万个字符,而且这个数字还在不断增加。Unicode 和之前提到的 GB2312 类似,也是分区收录各个字符的。在 Unicode 中,一个分区称之为一个「平面」,占用 2 个字节的空间,可以存放 2^16=65536 个字符,这样的平面在 Unicode 标准里共有 17 个!

17 正好比 2 的 4 次方略大,所以现在整个 Unicode 字符集的容量是 2^(5+16)=2^21=2097152!这个容量足以容纳世界上的一切字符了。

最前面的 65536 个字符位,称为基本多文种平面(缩写 BMP),它的码点范围是从 0 一直到 216-1,写成 16 进制就是从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。剩下的字符都放在辅助平面(缩写 SMP),码点范围从 U+010000 一直到 U+10FFFF。存储这些字符需要约 3 字节的空间,但事实上辅助平面字符仍然占用 4 字节编码空间,理论上最多能表示 231 个字符。

基本多文种平面的字符的编码为 U+hhhh,其中每个 h 代表一个十六进制数字,这就是我们日常最常使用的字符们,按照数学来计算,这需要占用 2 个字节储存。但刚才我们也提到,在 Unicode 的规划中,最终要采用 4 个字节存储一个字符,那基本平面上的文字表示时就会变成 U+0000hhhh,在十六进制下看起来不多,但实际上,在二进制下前面的零会有十六个!这是十分浪费的。而且,对西洋人而言,他们日常生活中其实用一个字节的 ASCII 码就足够了,连用两个字节都是巨大的浪费,如何解决这些问题呢?

Unicode 字符集和其他字符集一样,是一种自然数到字符的映射关系,但其实在计算机的实现上,我们还需要定义如何在字节流的读取过程中断字。也就是说,计算机读取一篇文章时必须把文章里文字断成一个一个独立的字符解析,但如何断字呢?这一点 Unicode 编码是没有说明的。

如果我们让计算机在读取字符时,每隔 4 个字节断字一次,那就悲剧了。大写字母 A 在码表里用自然数 65 对应,写成十六进制是 0x41,用 4 个字节表示就是 U+00000041,整整浪费了 3 个字节,3 倍的空间。但如果我们让它每隔 2 个字节断字一次,就只会浪费一倍的空间。如果我们让计算机能动态的断字,有的一个字节断一次,有的两个字节断一次,有的三个字节才断一次……那毫无疑问可以节约很大的空间,提高传输信息的效率。

这就引出了 Unicode 的「实现方式」。Unicode 的实现方式不同于编码方式。一个字符的 Unicode 编码是确定的。但是在实际传输过程中,由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对 Unicode 编码的实现方式有所不同。Unicode 的实现方式称为 Unicode 转换格式(Unicode Transformation Format,简称为 UTF),以下是一个维基百科上的例子:

例如,如果一个仅包含基本 7 位 ASCII 字符的 Unicode 文件,如果每个字符都使用 2 字节的原 Unicode 编码传输,其第一字节的 8 位始终为 0。这就造成了比较大的浪费。对于这种情况,可以使用 UTF-8 编码,这是一种变长编码,它将基本 7 位 ASCII 字符仍用 7 位编码表示,占用一个字节(首位补 0)。而遇到与其他 Unicode 字符混合的情况,将按一定算法转换,每个字符使用 1-3 个字节编码,并利用首位为 0 或 1 进行识别。这样对以 7 位 ASCII 字符为主的西文文档就大幅节省了编码长度(具体方案参见UTF-8)。类似的,对未来会出现的需要 4 个字节的辅助平面字符和其他 UCS-4 扩充字符,2 字节编码的 UTF-16 也需要通过一定的算法进行转换。

—— Unicode - 维基百科,自由的百科全书

在 Unicode 的各种实现方式中,常见的为 UTF-16 和 UTF-8,此外,还有一些只用于特带领域或者历史时期的实现方式,比如用于域名解析的 Punycode,用于老版本 STMP 协议的 UTF-7 等。而与 Unicode 码位完全一一对应的 UTF-32(每个码点使用四个字节表示,字节内容一一对应码点)实际上十分不常用,即使 UTF-32 可以直接由 Unicode 码位来索引,令其在计算机程序设计中操作 UTF-32 编码的字符串就和操作 ASCII 字符串一样简单,但它的空间浪费实在是太大了,所需空间接近 UTF-16 的两倍和 UTF-8 的四倍(具体取决于文本中 ASCII 字符的比例),因此 UTF-32 几乎没有人使用——甚至让 HTML 5 标准明文规定,网页不得以 UTF-32 方式编码,以此提高网页传输的速度。

UTF-8

UTF-8 是互联网世界中最流行的编码方式,没有之一,因为它解决了 Unicode 编码最大的问题——空间占用问题。同样的一段文字,占用空间越小,在网络上传输的耗时也就越短,这个世界上的互联网内容绝大多数还是西洋人的,ASCII 字符还是占互联网信息的大多数,而 UTF-8 作为一种变长的编码方式,可以用 1-4 个字节表示一个字符,根据不同的字符改变不同的编码长度。

UTF-8 的编码规则很简单,只有二条:

  1. 对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
  2. 对于 n 字节的符号(n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

下表总结了编码规则,字母 x 表示可用编码的位。

图 5 Unicode 和 UTF-8 之间的转换关系表

跟据上表,解读 UTF-8 编码非常简单。如果一个字节的第一位是 0,则这个字节单独就是一个字符;如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。下面是一个例子:

以汉字为例,演示如何实现 UTF-8 编码。
的 Unicode 是 4E25100111000100101),根据上表,可以发现 4E25 处在第三行的范围内(0000 0800 - 0000 FFFF),因此的 UTF-8 编码需要三个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx。然后,从的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是 E4B8A5

—— 《字符编码笔记:ASCII,Unicode 和 UTF-8》,阮一峰的网络日志

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

又能统一编码,占用的空间又小。因此,UTF-8 无疑是最适合西洋人的一种 Unicode 实现方式,对他们而言,越是常用的字符,字节越短,最前面的 128 个字符,只使用 1 个字节表示,与 ASCII 码完全相同。

不过对于我们中国人而言,UTF-8 编码实际上是有些坑爹的的,因为绝大部分汉字是位于 0x0800-0xFFFF 区间内的(部分生僻字位于辅助平面),这就意味着每个汉字本来只需要 2 个字节空间,用 UTF-8 方式编码后反而大了 50%,需要用 3 个字节空间储存了……不过没办法,谁让西洋人在互联网上的话语权更大呢?

UTF-16

编码方式

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

但这就带来了一个问题,当计算机读取字节流时,在读取了两个字节之后,是应该立刻把这两个字节当做一个 UTF-16 编码的字符解释呢?还是再读两个字节,把这 4 个字节的数据当做一个字符解释呢?

上文我们看到,为了判断到底 UTF-8 编码下的字符应该读取几个字节,UTF-8 编码的字符在头部用「连续的 1 的数量」来做标记,提示计算机程序如何正确断字。UTF-16 也采用了一个巧妙地设计来解决这个问题。在基本平面内,Unicode 规定,从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符,帮助计算机判断这是否是一个 4 字节的 UTF-16 编码字符。

具体来说,辅助平面的字符从 U+10000 到 U+10FFFF,共有 220=1,048,576 个,也就是说,对应这些字符至少需要 20 个二进制位。UTF-16 将这 20 位拆成两半,前 10 位映射在 U+D800 到 U+DBFF(空间大小 210),称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF(空间大小 210),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。这样的一对 16 位长的码元,被称之为「代理对(surrogate pair)」,具体的编码方式为:

  • 码位减去 0x10000, 得到的值的范围为 20 比特长的 0..0xFFFFF.
  • 高位的 10 比特的值(值的范围为 0..0x3FF)被加上 0xD800 得到第一个码元或称作高位代理(high surrogate),值的范围是 0xD800..0xDBFF. 由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode 标准现在称高位代理为前导代理(lead surrogates)。
  • 低位的 10 比特的值(值的范围也是 0..0x3FF)被加上 0xDC00 得到第二个码元或称作低位代理(low surrogate),现在值的范围是 0xDC00..0xDFFF. 由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode 标准现在称低位代理为后尾代理(trail surrogates)。

通过这种算法,在基本平面中只保留了 2*2^10=2048 个字符,就可以巧妙地根据 16 位整数的值直接判明属于前导整数代理的值的范围(210=1024),还是后尾整数代理的值的范围(也是 210=1024)。这对于基本多语言平面总计 65536 个码位来说,仅占 3.125%,并不影响大局。

而且,由于前导代理、后尾代理、BMP 中的有效字符的码位,三者互不重叠,搜索是简单的:一个字符编码的一部分不可能与另一个字符编码的不同部分相重叠。这意味着 UTF-16 是自同步(self-synchronizing): 可以通过仅检查一个码元就可以判定给定字符的下一个字符的起始码元. UTF-8 也有类似优点,但许多早期的编码模式就不是这样,必须从头开始分析文本才能确定不同字符的码元的边界。

不过,即使 Unicode 标准规定 U+D800 至 U+DFFF 的值不对应于任何字符。但是在使用 UCS-2(UTF-16 的子集,不包括辅助平面字符)的时代,U+D800 至 U+DFFF 内的值被占用,用于某些字符的映射。但只要不构成代理对,许多 UTF-16 编码解码还是能把这些不符合 Unicode 标准的字符映射正确的辨识、转换成合规的码元。按照 Unicode 标准,这种码元序列本来应算作编码错误,JavaScript 就在这里被坑惨了,具体情况会在之后展开说明,这里先略过不谈。

总而言之,在现在的标准下,如果计算机读取 UTF-16 字节流时遇到两个字节,且这两个字节都处于 U+D800 到 U+DBFF 之间,就可以断定这是一个 4 位 UTF-16 字符的前导代理,而紧跟在这两个字节后面的两个字节,一定在 U+DC00 到 U+DFFF 之间,是这个 4 位 UTF-16 字符的后尾代理,这四个字节必须放在一起解读才是一个完整的 UTF-16 字符。

综上,UTF-8 到 UTF-16 的转码方式为:

  • 首先区分这是基本平面字符,还是辅助平面字符。如果是前者,直接将码点转为对应的十六进制形式,长度为两字节。
  • 如果是辅助平面字符,则根据字符的码点值,带入转码公式计算。这里假设字符的码点值为 c,则前导代理(高位)的值 H = Math.floor((c-0x10000) / 0x400)+0xD800,后尾代理(低位)的值 L = (c - 0x10000) % 0x400 + 0xDC00.

再举个例子:

以字符为例,它是一个辅助平面字符,码点为 U+1D306,将其转为 UTF-16 的计算过程如下。

H = Math.floor((0x1D306-0x10000)/0x400)+0xD800 = 0xD834

L = (0x1D306-0x10000) % 0x400+0xDC00 = 0xDF06

所以,字符的 UTF-16 编码就是 0xD834 DF06,长度为四个字节。

—— 《字符编码笔记:ASCII,Unicode 和 UTF-8》,阮一峰的网络日志

大端序与小端序

UTF-16 由两个字节组成一个字符,那么这两个字节保存的时候,应该哪个在前那个再后呢?好像不管是理解成哪一种,解析时都能解析出合理的结果。

事实上,在 Mac 与 Windows、Linux 之间,对字节顺序的理解就是不一致的。这时同一字节流可能会被解释为不同内容,如某字符为十六进制编码 4E59,按两个字节拆分为 4E 和 59,在 Mac 上读取时是从低字节开始,那么在 Mac OS 会认为此 4E59 编码为 594E,找到的字符为 “奎”,而在 Windows 上从高字节开始读取,则编码为 U+4E59 的字符为 “乙”。就是说在 Windows 下以 UTF-16 编码保存一个字符 “乙”,在 Mac OS 环境下开启会显示成 “奎”。

—— Unicode - 维基百科,自由的百科全书

此类情况说明 UTF-16 的编码顺序若不加以人为定义就可能发生混淆,于是在 UTF-16 编码实现方式中使用了大端序(Big-Endian,简写为 UTF-16 BE)、小端序(Little-Endian,简写为 UTF-16 LE)的概念,以及可附加的位元组顺序记号(BOM)解决方案,目前在 PC 机上的 Windows 系统和 Linux 系统对于 UTF-16 编码默认使用 UTF-16 LE,而 Mac 则默认使用 UTF-16 BE。

端(endian)」一词来源于十八世纪爱尔兰作家乔纳森·斯威夫特(Jonathan Swift)的小说《格列佛游记》(Gulliver's Travels)。小说中,小人国为水煮蛋该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开而争论,争论的双方分别被称为「大端派」和「小端派」。

1980 年,丹尼·科恩(Danny Cohen),一位网络协议的早期开发者,在其著名的论文《On Holy Wars and a Plea for Peace》中,为平息一场关于字节该以什么样的顺序传送的争论,而第一次引用了该词。在哪种字节顺序更合适的问题上,人们表现得非常情绪化,实际上,就像鸡蛋的问题一样,没有技术上的原因来选择字节顺序规则,因此,争论沦为关于社会政治问题的争论,只要选择了一种规则并且始终如一地坚持,其实对于哪种字节排序的选择是任意的。下面是一些实例:

虽然在互联网传输 UTF-8 编码的文件时,带位元组顺序记号(英语:byte-order mark,BOM是一件很罕见的事情,但在 UTF-16 编码中相对还是比较常见的。BOM 是用来标示这个文件到底是大端序还是小端序的记号。

UTF-16 中,位元组顺序记号被放置为档案或字符串流的第一个字符,以标示在此档案或字符串流中,以所有十六位元为单位的字码的尾序(位元组顺序)。

  • 如果十六位元单位被表示成大尾序,这位元组顺序记号字符在序列中将呈现 0xFE,其后跟著 0xFF
  • 如果十六位元单位使用小尾序,这个位元组序列为 0xFF,其后接著 0xFE

也就是说,如果一个文件头是 U+FEFF,那么就代表这是一个大端序的 UTF-16 编码文件;如果一个文件头是 U+FFFE,那么就代表这是一个小端序的 UTF-16 编码文件。在我们常用的 Windows 系统下,如果一个 UTF-16 编码的文件没有加 BOM,那么不论是记事本这样的简单小工具,还是 Excel 这样的大杀器软件,均无法正常显示文件的内容,这些软件会直接将文件当做本地语言环境的编码读取处理(例如,在中文环境下,会将其当做 GB2312 编码格式的文件处理;在英文环境下,则会将其当成 ASCII 编码格式的文件处理),出现一屏幕的乱码。

JavaScript 与 UTF-16

JavaScript 是幸运的,也是不幸的。之所以说它是幸运的,是因为当 JavaScript 开始设计时(1995 年),UCS-2 标准已经公布(1990 年),使其避免了 Python(于 1989 年开始设计,1991 年发行)那样坑爹式的 Unicode 支持。但不幸的是,JavaScript 没有赶上 UTF-16 标准的发布(1996 年),这使其只能支持 UCS-2 这个 UTF-16 的子集,而 UCS-2 中所有字符都用 2 个字节表示,并不支持辅助平面上的字符。

虽然没有 Python 立足于 ASCII 编码之上那么惨,但由于 JavaScript 只能处理 UCS-2 编码,造成所有字符在这门语言中都是 2 个字节表示的,这其实也很坑爹了。如果是位于辅助平面的 4 个字节的字符,JavaScript 会把它们都当作两个双字节的字符处理,因此所以字符函数都受到这一点的影响,无法返回正确结果。

所幸,在互联网的早期时代,我们用到的字符大多都是基本平面中的这 65536 个字符,就像 MySQL 里那个著名的三字节假 UTF-8 编码问题一样,JavaScript 不支持辅助平面字符的问题一直没造成太大的影响。但随着时间发展,越来越多的程序员遇到了 JavaScript 无法处理 4 字节码点的辅助平面字符的问题,其中,个人认为最典型的就是 JavaScript 的默认字符操作函数无法正确处理 emoji,十分坑爹。

在 2015 年公布的 ECMAScript 6 版本中,JavaScript 大幅度加强了对 UTF-16 的支持,解决了这个遗留了将近 20 年的问题。在 ES6 中,所有字符都以着 UTF-16 BE(大端序)的方式存储,4 字节的码点也终于可以被程序自动识别,在操作时不会在出现半个码点的悲剧了。不过在使用上,考虑到对历史代码的向下兼容包袱,一些地方 ES6 还是沿用了传统的错误方式处理,但都提供了新的属性/语法来解决,比如说:

  • String.length 属性仍然返回的还是 4 字节码点「一个顶俩」的长度,但我们可以用 Array.from(string).length 的方式来获取字符串的正确长度;
  • 「反斜杠+u+码点」的表示方式仍然无法识别 4 字节码点,但可以用「反斜杠+u+{码点}」的方式,用大括号把码点框起来,让程序正确识别;
  • String.fromCodePoint() 静态方法返回使用指定的 Unicode 码点创建的字符串,取代 String.fromCharCode()
  • String.prototype.codePointAt() 方法返回 一个 Unicode 编码点值的非负整数,取代 String.prototype.charCodeAt()
  • String.prototype.at() 方法返回字符串给定位置的字符,取代 String.prototype.charAt()
  • 为正则表达式供了 u 修饰符作为对 4 字节码点的支持。
  • 提供了 normalize 方法,允许 「Unicode 正规化」,即将两种方法转为同样的序列。

什么是「Unicode 正规化」?

有些字符除了字母以外,还有附加符号。比如,汉语拼音的Ǒ,字母上面的声调就是附加符号。对于许多欧洲语言来说,声调符号是非常重要的。

Unicode 提供了两种表示方法。一种是带附加符号的单个字符,即一个码点表示一个字符,比如Ǒ的码点是 U+01D1;另一种是将附加符号单独作为一个码点,与主体字符复合显示,即两个码点表示一个字符,比如Ǒ可以写成 O(U+004F)+ ˇ(U+030C)。

这两种表示方法,视觉和语义都完全一样,理应作为等同情况处理。但是,JavaScript 无法辨别。

ES6 提供了 normalize 方法,允许"Unicode 正规化",即将两种方法转为同样的序列。

—— 《Unicode 与 JavaScript 详解》,阮一峰的网络日志

0x03:ANSI 编码

如果在一些文本编辑软件,如记事本中创建、编辑文本文件,那么我们一定见过这一个一个编码:ANSI,但如果我们查找各种编码系统,会发现实际上并没有一个编码系统叫做 ANSI。如果我们在维基百科上查找 ANSI,并不会像 GB2312 等编码系统一样弹出详细的关于编码的介绍,它只会显示一些和字符编码没什么关系的话:

美国国家标准学会(American National Standards Institute,ANSI)是负责制定美国国家标准的非营利组织。美国国家标准学会授权标准起草机构按照一系列规范编写标准草案。由此产生的候选文献通过 ANSI 审核批准后成为美国国家标准。

美国国家标准学会是国际标准化组织国际电工委员会的成员。美国五家行业标准学会于 1918 年 10 月 19 日成立美国工程标准委员会(AESC),1928 年改为美国标准协会(ASA)。1966 年 8 月改组为美利坚合众国标准组织(USASI)。1969 年 10 月 6 日改为现名。

—— 美国国家标准学会 - 维基百科,自由的百科全书

事实上,ANSI 不代表具体的编码,它是指本地编码。我也不知道为什么 Windows 要把本地编码叫做 ANSI,但它就是这么代表的,在不同的系统中,ANSI 表示不同的编码。比如,在中国大陆的简体版 windows 上,ANSI 就表示 GB2312 编码,在湾湾的繁体版 windows 上,ANSI 就表示 Big5 编码;在东洋的日文版 Windows 上,ANSI 就表示 JIS 编码;在统治互联网英文 Windows 上,ANSI 就表示 ASCII 编码……

所以如果我们新建了个文本文件并保存为 ANSI 编码,那么实际上这个文件就是以「本地编码」保存的,在不同的操作系统下,系统会尝试以自己当前的本地编码来尝试解析这个文件。

0x04:Node.js 与 Iconv

在网页浏览器中,JavaScript 处理的字符比较简单,浏览器会帮忙做一些转码的事情,JavaScript 只需要把字符串都当做 UTF-16 BE 编码来处理就可以了。但是,在 Node.js 中,如果涉及到文本文件的保存与读取,文本编码就是绕不开的一座大山。

在类 Unix 系统中,有著名的 iconv 程序可以在多种国际编码格式之间进行文本内码的转换,支持 Unicode 相关编码(如 UTF-8、UTF-16 等)与各国采用的 ANSI 编码(如 GB2312、Big5、JIS 等)。在 Node.js 下,也有两个包可以做相同的事情,分别是node-iconviconv-lite

这两个包的功能相近,不过,按照 iconv-lite 自己的测评,它的运行速度比 node-iconv 快大约 2-3 倍,可能正因为如此,在 npm 上 iconv-lite 的使用量远大于 node-iconv。

比如,在我们需要保存一个 csv 文件到本地时,如果我们直接调用 fs.writeFile() 方法将字符串写入文本文件,那么写入进去的字节流实际上是 UTF-16 BE without BOM 格式的,如果我们直接用 Excel 打开就会显示乱码,因为 Excel 对没有 BOM 的文件默认是以 ANSI,即本地编码(在中国的简体中文版 WIndows 下是 GB2312)的格式来解析的。

所以,在写入前,我们要么在文件头添加「'\uFEFF'」的 BOM,注明这是一个大端序的 UTF-16 文本文件,要么用 iconv-lite 或 node-iconv 将其转码为 gb2312 格式,这样才能用 Excel 正常打开。

0x05:Base64

有的新手认为 Base64 是一种对字符串的加密方式,因为文本在以 Base64 方法编码后,确实看起来变成了一串加密乱码。但是,Base64 实际上是一种 「用 64 个可打印字符来表示二进制数据」的方法。

由于 64=2^6,所以,在 Base64 中,以每 6 个二进制位作为一个单元,对于某个可打印字符。前文我们也说过,每个字节由 8 个二进制位组成,那么,6 和 8 的最大公约数是 24,这就意味着在 Base64 编码下,我们将用 24/6=4 个可打印字符,来表示 24/8=3 个字节的二进制数据,这使得我们可以在那些通常只能传输文本内容的场合,表示、传输、存储一些二进制数据,如 MIME 的电子邮件,网页中的小图片等等。

这 64 个可打印字符包括小写字母 a-z、大写字母 A-Z、数字 0-9、符号"+"、"/"。完整的 Base64 定义可见 RFC 1421 和 RFC 2045。编码后的数据比原始数据略长,为原来的 4/3。在电子邮件中,根据 RFC 822 规定,每 76 个字符,还需要加上一个回车换行。可以估算编码后数据长度大约为原长的 135.1%。

转换的时候,将 3 字节的数据,先后放入一个 24 bits 的缓冲区中,先来的字节占高位。数据不足 3 字节的话,于缓冲区中剩下的位元用 0 补足。然后,每次取出 6 位元,,按照其值选择 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/中的字符作为编码后的输出,直到全部输入数据转换完成。

若原数据长度不是 3 的倍数时且剩下 1 个输入数据,则在编码结果后加 2 个=;若剩下 2 个输入数据,则在编码结果后加 1 个=

例如,编码「Man」:

图 6 用 Base64 编码「Man」的过程

而 Base64 的索引表如下:

图 7 Base64 索引表

如果要编码的字节数不能被 3 整除,最后会多出 1 个或 2 个字节,那么可以使用下面的方法进行处理:先使用 0 字节值在末尾补足,使其能够被 3 整除,然后再进行 Base64 的编码。在编码后的 Base64 文本后加上一个或两个=号,代表补足的字节数。也就是说,当最后剩余两个八位字节(2 个 byte)时,最后一个 6 位的 Base64 字节块有四位是 0 值,最后附加上两个等号;如果最后剩余一个八位字节(1 个 byte)时,最后一个 6 位的 base 字节块有两位是 0 值,最后附加一个等号。参考下表:

图 8 Base64 末尾补位图

Base64 编码可用于在 HTTP 环境下传递较长的标识信息。例如,在 Java 持久化系统 Hibernate 中,就采用了 Base64 来将一个较长的唯一标识符(一般为 128-bit 的 UUID)编码为一个字符串,用作 HTTP 表单和 HTTP GET URL 中的参数。在其他应用程序中,也常常需要把二进制数据编码为适合放在 URL(包括隐藏表单域)中的形式。此时,采用 Base64 编码不仅比较简短,同时也具有不可读性,即所编码的数据不会被人用肉眼所直接看到。

然而,标准的 Base64 并不适合直接放在 URL 里传输,因为 URL 编码器会把标准 Base64 中的/和+字符变为形如%XX 的形式,而这些% 号在存入数据库时还需要再进行转换,因为 ANSI SQL 中已将% 号用作通配符。

为解决此问题,可采用一种用于 URL 的改进 Base64 编码,它不在末尾填充=号,并将标准 Base64 中的+和/分别改成了-和_,这样就免去了在 URL 编解码和数据库存储时所要作的转换,避免了编码信息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。

另有一种用于正则表达式的改进 Base64 变种,它将+和/改成了! 和-,因为+,*以及前面在 IRCu 中用到的 [和] 在正则表达式中都可能具有特殊含义。

此外还有一些变种,它们将+/改为-或.(用作编程语言中的标识符名称)或.-(用于 XML 中的 Nmtoken)甚至_:(用于 XML 中的 Name)。

正因为 Base64 编码只是对二进制数据的编码,因此,如果我们用 Base64 编码文本,它实际上并不在乎文本用的是前文中哪一种编码系统。例如,如果我们用 Base64 编码一段汉字文本,那这段文本到底是 GB2312 格式的还是 UTF-8 格式还是 UTF-16 格式都不要紧,Base64 格式只负责 Base64 文本和二进制数据之间的翻译过程,至于二进制字节流和字符之间的转换是另外一回事了。这里以 UTF-8 格式的汉字「文」为例讲解一下:

汉字「」的 UTF-8 编码为 E6 96 87,转化位二进制就是 11100110 10010110 10000111,按照 6 位一个单元重新分组后就是 111001 101001 011010 000111,转化为十进制就是 57 41 26 7,按照上文的 Base64 对应码表重新映射后是「5paH」,这就是「文(UTF-8 编码)」的 Base64 编码值。

现在互联网上传输的文字内容的 Base64 编码,基本上都默认是对 UTF-8 格式的文本进行的 Base64 编码,例如 PHP 里内置的 base64_encode() 函数不管输入的文本是什么格式,都会把它当做 UTF-8 格式的文本进行 Base64 编码,这基本已经是一个默认的标准了。

所以,如果我们在 JavaScript 中进行 Base64 转换,就会面临一个比较尴尬的局面,前文说到 JavaScript 内部的字符串,都以 UTF-16 BE 的形式进行保存,因此编码的时候,我们还需要先完成一步 UTF-16 --> UTF-8 的转换,再进行 Base64 编码,这个过程手写不难,不过网上有很多已经写好的函数或包了,我们日常使用时直接调用就可以了。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注