一、缘由
最早主要是围绕 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,写成十六进制就是从 0x0000
到 0xFFFF
。所有最常见的字符都放在这个平面,这是 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
。
于是就有一个问题,当我们遇到两个字节,怎么看出它本身是一个字符,还是需要跟其他两个字节放在一起解读(辅助平面的字符是以四个字节进行编码的)?
这里就要解释一下如何计算辅助平面字符的编码,具体的做法是:在基本平面内,从 0xD800
到 0xDFFF
是一个空段,使用这个空段来映射辅助平面的字符。
首先根据字符的编号,计算出字符在辅助平面内的位置,而辅助平面一共有 220 个字符,因此字符在平面的位置范围必然是 0x00000
到 0xFFFFF
。
辅助平面的范围是
0x10000
-0x10FFFF
,两个值进行减法即0x10FFFF
-0x10000
=0xFFFFF
也就是说,要表示一个字符在辅助平面上的位置需要 20 个二进制位。UTF-16
将这 20 位二进制拆成两半,前 10 位映射在 0xD800
- 0xDBFF
(空间大小 210),称为高位;后 10 位映射在 0xDC00
- 0xDFFF
(空间大小 210),称为低位。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
所以,当我们遇到两个字节,发现它的值在 0xD800
到 0xDBFF
之间,就可以断定,紧跟在后面的两个字节的值,应该在 0xDC00
到 0xDFFF
之间,这四个字节必须放在一起解读。说了这么多,接下来举个例子辅助理解这个过程。
以字符 🀄 为例:
字符 🀄 的 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 - 0x007F | 1 | 0??????? |
0x0080 - 0x07FF | 2 | 110????? 10?????? |
0x0800 - 0xFFFF | 3 | 1110???? 10?????? 10?????? |
0x010000 - 0x10FFFF | 4 | 11110??? 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.charCodeAt
和 String.fromCharCode
两个方法。
对于 charCodeAt
,我们先来看看 MDN 对这个方法的描述:
The
charCodeAt()
method returns an integer between0
and65535
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.fromCodePoint
、String.prototype.normalize
方法和正则表达式新增的 u
标志,这些方法和特性都是为了解决之前无法处理辅助平面字符的问题,感兴趣的话可以自行查看一下文档。
关于 Javascript 和 Unicode 编码内容大致就这么多,如果有问题欢迎留言~