Javascript 与 unicode 编码

2,293 阅读10分钟

一、缘由

最早主要是围绕 javascript 高级程序设计深入理解 ES6 两本书来学习 js, 学习的范围倾向于主要语法,一些细节的新特性觉得暂时不会用到就草草略过。现在临近春节,工作相对不是那么忙,也顺手把丢掉的基础知识捡回来。

本篇文章整合了学习过程中参考的其他优秀文章和我自己的理解,对于引用也会标明出处。

由文章标题可知,这篇文章的内容主要是要讲 Unicode 编码。与之相关的内容在 深入理解 ES6 这本书的第二章:字符串与正则表达式。这里我会先贴上原文开头部分的内容,它和本文后面的一些内容有所关联:

在 ES6 之前,JS 的字符串以 16 位字符编码(USC-2)为基础。每个 16 位序列都是一个码元(code unit),用于表示一个字符。字符串的所有属性和方法(像是 length 属性和 chatAt 方法)都是基于 16 位的码元。当然,16 位曾经足以容纳任何字符,然而由于 Unicode 引入了扩展字符集,这就不在够用了。

在开始之前,让我们先了解一下什么是 Unicode

二、Unicode

本节部分内容引用 阮一峰 Unicode 与 JavaScript详解

Unicode 源于一个很简单的想法:将全世界所有的字符包含在一个集合里,计算机只要支持这一个字符集,就能显示所有的字符,再也不会有乱码了。

它从 0 开始,为每个符号指定一个编号,这叫做"码点"(code point)。 比如,码点 0 的符号就是 null(表示所有二进制位都是 0)。

每个字符的编号以十六进制的格式进行书写,例如:

0x0000 -> null
0x0001 -> SOH Start Of Heading
0x0002 -> STX Start Of Text
...
0x4E2D -> 中
0x6587 -> 文
...

在这里就不一一列举了,可以通过这个网站来进行 Unicode 转换

Unicode 就类比与我们使用的身份证系统,通过某一个身份证号就能识别出对应的人的信息。

这么多符号,Unicode 不是一次性定义的,而是分区定义。每个区可以存放 65536 个(216)字符,称为一个平面(plane)。目前一共有 17 个平面,也就是说,整个 Unicode 字符集的大小现在是 220 + 216

最前面的 65536 个字符位,称为基本平面(缩写 BMP ),它的码点范围是从 0 一直到 216 - 1,写成十六进制就是从 0x00000xFFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。

剩下的字符都放在辅助平面(缩写 SMP ),编号范围从 0x010000 一直到 0x10FFFF,各个平面使用的编号如下:

编号范围平面名
0x0000 - 0xFFFF基本平面
0x10000 - 0x1FFFF第 1 辅助平面
0x20000 - 0x2FFFF第 2 辅助平面
0x30000 - 0x3FFFF第 3 辅助平面
0x40000 - 0xDFFFF第 4 - 13 辅助平面
0xE0000 - 0xEFFFF第 14 辅助平面
0xF0000 - 0xFFFFF第 15 辅助平面
0x100000 - 0x10FFFF第 16 辅助平面

三、编码

虽然我们给每个字符都赋予了一个编号,但是在数据传输的时候以什么样的字节序表示这个编号,就涉及到编码方法。

1. UTF-32

直观来讲,我们对字符的编号的十六进制范围是 0x0000 - 0x10FFFF,那么是不是可以将这些编号直接转为二进制传输,这就是 UTF-32 编码。它的规则是每个编号以 4 个字节来表示,字节内容一一对应编号,就比如汉字 的编号是 0x4E2D,那么用 UTF-32 进行编码后就是 0x00004E2D

UTF-32 的优点在于,转换规则简单直观,查找效率高。缺点在于浪费空间,同样内容的英语文本,它会比 ASCII 编码大四倍。

2. UTF-16

UTF-32 编码对于编号在前的字符存在空间浪费,因此 UTF-16 规定,对于基本平面(0x0000 - 0xFFFF)的字符使用 2 字节编码,对于辅助平面(0x10000 - 0x10FFFF)的字符使用 4 字节编码。

以汉字 为例,它的编号为 0x4E2D,属于基本平面的字符。根据上面的规则,使用 UTF-16 进行编码,其编码和编号一一对应(编号是两个字节),即编码过后还是 0x4E2D

于是就有一个问题,当我们遇到两个字节,怎么看出它本身是一个字符,还是需要跟其他两个字节放在一起解读(辅助平面的字符是以四个字节进行编码的)?

这里就要解释一下如何计算辅助平面字符的编码,具体的做法是:在基本平面内,从 0xD8000xDFFF 是一个空段,使用这个空段来映射辅助平面的字符。

首先根据字符的编号,计算出字符在辅助平面内的位置,而辅助平面一共有 220 个字符,因此字符在平面的位置范围必然是 0x000000xFFFFF

辅助平面的范围是 0x10000 - 0x10FFFF,两个值进行减法即 0x10FFFF - 0x10000 = 0xFFFFF

也就是说,要表示一个字符在辅助平面上的位置需要 20 个二进制位。UTF-16 将这 20 位二进制拆成两半,前 10 位映射在 0xD800 - 0xDBFF(空间大小 210),称为高位;后 10 位映射在 0xDC00 - 0xDFFF(空间大小 210),称为低位。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。

所以,当我们遇到两个字节,发现它的值在 0xD8000xDBFF 之间,就可以断定,紧跟在后面的两个字节的值,应该在 0xDC000xDFFF 之间,这四个字节必须放在一起解读。说了这么多,接下来举个例子辅助理解这个过程。

以字符 🀄 为例:

字符 🀄 的 unicode 编号为 0x1F004

第一步:计算该字符在辅助平面的位置
0x1F004 - 0x10000 = 0x0F004
0x0F004 二进制表示为 0000 1111 0000 0000 0100

第二步,将二进制对半拆分分别得到高、低位
0000 1111 00 -> 00 0011 1100 -> 0x03C // 高位
00 0000 0100 -> 00 0000 0100 -> 0x004 // 低位

第三步,高位与 0xD800 相加,低位与 0xDC00 相加得到 `UTF-16` 编码
0xD800 + 0x03C = 0xD83C
0xDC00 + 0x004 = 0xDC04

// 因此,字符🀄的 UTF-16 编码就是 D8 3C DC 04。

3. UTF-8

UTF-8 是一种变长的编码方法,字符长度从 1 个字节到 4 个字节不等。越是常用的字符,字节越短,最前面的 128 个字符,只使用 1 个字节表示,与 ASCII 码完全相同,不同编号范围的字符使用的字节数和规则如下:

编号范围字节数规则
0x0000 - 0x007F10???????
0x0080 - 0x07FF2110????? 10??????
0x0800 - 0xFFFF31110???? 10?????? 10??????
0x010000 - 0x10FFFF411110??? 10?????? 10?????? 10??????

在进行 UTF-8 编码时,我们需要根据字符的编号确定该字符使用几个字节编码。确定好字节数之后,将 Unicode 编号转为二进制填充到规则一栏的 ? 号内。

UTF-8 在使用多字节编码的情况下,首字节均以多个 1 开头并跟着一个 0,具体 1 的数量要看编码使用了几个字节,那么开头就有几个 1;剩余字节均以 10 开头。

以汉字 为例,Unicode 编号为 0x4E2D,根据上表这个字符需要 3 字节来进行编码。编码后的格式为:1110???? 10?????? 10??????

然后将 0x4E2D 转为二进制 0100 1110 0010 1101 从后往前填充到规则内,如果不够就补 0。最终得到 11100100 10111000 10101101,即 E4 B8 AD

四、ES6 字符串与正则表达式

ES6 为字符串和正则表达式提供了更好的 Unicode 支持,并添加了新的能力。

在 JavaScript 中处理中文和其他 Unicode 字符时,我们会用到处理 Unicode 相关的 API,较为熟悉的就是 String.prototype.charCodeAtString.fromCharCode 两个方法。

对于 charCodeAt,我们先来看看 MDN 对这个方法的描述:

The charCodeAt() method returns an integer between 0 and 65535 representing the UTF-16 code unit at the given index.

翻译过来就是此方法返回字符串指定索引处的 UTF-16 码元的十进制表示。

我个人认为 MDN 上对这个方法的描述不太准确,因为 UTF-16 对基本平面字符采用 2 字节编码,对辅助平面字符采用 4 字节编码,是不是可以认为 UTF-16 编码的码元大小有 2 字节和 4 字节。

所以在对辅助平面的字符执行 charCodeAt 时,应该返回 4 个字节对应的十进制值,但是当我们运行以下代码:

"🀄".charCodeAt(0) // 55356 等价于 0xD83C

而字符 🀄 的 UTF-16 编码为 0xD83C 0xDC04,相当于 charCodeAt() 方法只返回了一半的码元,因此我个人理解这个方法将字符以 UTF-16 编码后,返回 USC-2 编码方法的一个码元,而 USC-2 编码以两个字节作为一个码元。

从上面可以看出,charCodeAt 方法作为 js 早期提供用来获取字符的 Unicode 编号的 UTF-16 编码已经不再准确,对于辅助平面的字符其无法返回正确的结果。

这里需要注意的是,charCodeAt 方法返回的是 UTF-16 编码,而不是字符对应的 Unicode 编号。只是对于基本平面的字符来说,Unicode 编号等于其 UTF-16 编码。

因此,ES6 提供了 codePointAt 方法为字符串增加更好的 Unicode 支持。方法的描述如下:

The codePointAt() method returns a non-negative integer that is the Unicode code point value at the given position.

charCodeAt 方法不同的是,codePointAt 返回的是字符的 Unicode 编号,而不是 UTF-16 编码。对于上述同一个例子:

// Unicode 编号为 0x1F004
"🀄".codePointAt(0) // 126980,unicode 编号十进制表示

同样的,深入理解 ES6 这本书还提到了新增的 String.prototype.fromCodePointString.prototype.normalize 方法和正则表达式新增的 u 标志,这些方法和特性都是为了解决之前无法处理辅助平面字符的问题,感兴趣的话可以自行查看一下文档。

关于 Javascript 和 Unicode 编码内容大致就这么多,如果有问题欢迎留言~