彻底搞懂 Unicode

终于下定决心好好搞懂 Unicode,曾经感觉不是必须掌握,但是还是放不下,基础设施决定上层建筑。

本篇博客仅涉及 Unicode 及其源头 —— ASCII,至于 ASCII 衍生出的其他编码,如:ISO8859-1、GB2312 等,日后会再起一篇博文记录。

因为字符编码是计算机世界里最基础、最重要的一个主题之一,也有非常有趣的历史故事,篇幅在这一篇短短的文章中是不足以体现的,值得我,甚至任何一个互联网工作者来细细品尝和研究。

ASCII

要说 Unicode,还得先从它的祖先 ASCII1 说起。

在计算机世界,所有数据最终都是二进制值的形式进行存储。每一个二进制位(bit)有 01 两种状态,因此八个二进制位就可以组合出 256 种状态,这被称为一个字节(byte)。

也就是说,一个字节一共可以用来表示 256 种不同的状态,每一个状态对应一个符号,就是 256 个符号,从 0000000011111111

上个世纪 60 年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。

ASCII 码一共规定了 128 个字符的编码,比如大写的字母 A 是 65(二进制 01000001)。

这 128 个符号(包括 32 个不能打印出来的控制符号),只占用了一个字节的后面 7 位,最前面的一位统一规定为 0

参考链接:ASCII 字符表

Unicode

ASCII 用来表示英语已经足够,但是其他语言咋办?

不同国家的因为字符的不同,标准也不太一样,往往一个国家的字符,对应的 ASCII 码对应着另一个字符,比如,130法语编码中代表了 é,在希伯来语编码中却代表了字母 Gimel(ג)。

这就更不用说亚洲国家的文字,多到根本装不下,单论汉字就有 10 万个字符,所以,我们需要一个编码,将世界上所有的符号囊括进去,用一种统一的方式规定字符的编码,让每一个字符都有一个独一无二的编码,这样乱码的问题就能得到解决。

正是出于这种需求,Unicode2 诞生了,它是一个非常大的集合,它可以容易 100 多万个字符。

Unicode 用 U+ 开头,后接一个十六进制数(码点),来表示一个 Unicode 编号。(如 U+597D 代表字符「好」)

Unicode 只是一个标准,是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储,并不是一种具体的实现。

Unicode 的实现方式称为 Unicode 转换格式(Unicode Transformation Format,简称为 UTF),也就是我们接下来介绍的几种格式。

知识梳理

关系:一个字节 = 8 bit = 8 位二进制数 = 2 位十六进制数

码点:每个字符所在位置,也就是其所在编号。

基本平面

Unicode 是一本很厚的字典,全世界所有的字符定义在一个集合里。

这么多的字符不是一次性定义的,而是分区定义。每个区可以存放 65536 个(216)字符,称为一个平面(plane)。

目前,一共有 17 个(24)平面,也就是说,整个 Unicode 字符集的大小现在是 220

最前面的 65536 个字符,也就是第一个平面,被称为「基本平面」(BMP),码点范围为 0 ~ 65535(216-1),十六进制就是 U+0000 ~ U+FFFF

基本平面内存放的是最常见的字符,这也是 Unicode 最先定义和公布的一个平面。

辅助平面

除去基本平面内的字符,剩下的字符都被存放在「辅助平面」(SMP),十六进制码点范围是 U+010000 ~ U+10FFFF

字符总数(平面大小):16 * 216 = 220 = 1048576(16 个平面)

起点:216 = 65536 = 0x10000

终点:216 + (17-1) * 216 - 1 = 216 * 17 - 1 = 1114111 = 0x10FFFF(起点 + 16 个平面的字符数)

UTF-32

最直观的编码方法是 UTF-32,它是一种定长编码,也就是每个 UTF-32 码点使用四个字节表示,字节内容一一对应 Unicode 码点。

特点就是每个码点都用四个字节表示,不足位用 0 填充。

如:字母 a 对应的 Unicode 为 U+0061,对应的 UTF-32 编码为 0x00000061

优点

转换规则简单直观,查找效率高。

缺点

浪费空间,同样内容的英语文本,它会比 ASCII 编码大四倍。基本没有人会使用这种编码方式,且 HTML5 标准明文规定:网页不得编码成 UTF-32

UTF-8

UTF-8 是一种变长字节字符长度从 1 个字节到 4 个字节不等。它是 Unicode 的实现方式之一,也是主流的 Unicode 编码方式。

这种特性的优点就是:越常用的字符,字节越短,最前面的 128 个字符,只需要用 1 个字节表示,与 ASCII 码完全相同(实现了 ASCII 码的向后兼容,保证了 Unicode 可以被大众接受,同时也意味着 ASCII 码那个年代的文档用 UTF-8 编码打开完全没有问题)。

编码规则

Unicode 十六进制编码范围 字节数 UTF-8 二进制编码方式
0x0000 ~ 0x007F 1 0xxxxxxx
0x0080 ~ 0x07FF 2 110xxxxx 10xxxxxx
0x0800 ~ 0xFFFF 3 1110xxxx 10xxxxxx 10xxxxxx
0x010000 ~ 0x10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

字符的第一个字节开头,有多少个连续的 1,就表示该字符的字节数;后面的字节都由 10 开头。(如果 0 开头,表示该字节对应一个字符)

剩下的 x 位置,由该字符 Unicode 码点的二进制数值,从后往前依次填充,未填满则用 0 填充。

如:「」的 Unicode 是 4E25(二进制为 100111000100101),根据上表,可以发现 4E25 处在第三行的范围内(0x0800 ~ 0xFFFF),因此严的 UTF-8 编码需要三个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx

然后,从「严」的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,严的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是 E4B8A5

UTF-16

UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长变长两种编码方法的特点。

编码规则

基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF),要么是 4 个字节(U+010000 到 U+10FFFF)。

在基本平面内,从 U+D800U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。

因为「辅助平面」共有 220 个字符,所以我们需要至少 20 个二进制为来表示某个辅助平面上的字符。

UTF-16 规定这 20 个二进制位,分为两部分,前 10 位(称为高位)映射在 U+D800U+DBFF 之间,后 10 位(称为低位)映射在 U+DC00U+DFFF 之间。

这两部分的计算公式(Unicode 3.0 提供)如下:

c // Unicode 码点

// 高位计算
H = Math.floor((c - 0x10000) / 0x400) + 0xD800

// 低位计算
L = (c - 0x10000) % 0x400 + 0xDC00

所以 Unicode 转 UTF-16 的处理函数如下:

const unicodeToUTF16 = (unicode) => {
    const h = Math.floor((unicode - 0x10000) / 0x400) + 0xD800
    const l = (unicode - 0x10000) % 0x400 + 0xDC00
    
    return `0x${h.toString(16).toUpperCase()} 0x${l.toString(16).toUpperCase()}`
}

使用:

unicodeToUTF16(0x10000) // "0xD800 0xDC00"
unicodeToUTF16(0x10001) // "0xD800 0xDC01"
unicodeToUTF16(0x10002) // "0xD800 0xDC02"

// 0x10000 与 0x10400 隔了 2^10-1 个字符
// 相当于用完了第一个高位 `0xD800`

unicodeToUTF16(0x103FF) // "0xD800 0xDFFF"
unicodeToUTF16(0x10400) // "0xD801 0xDC00"
unicodeToUTF16(0x10401) // "0xD801 0xDC01"
unicodeToUTF16(0x10402) // "0xD801 0xDC02"

// ...
unicodeToUTF16(0x10FFFE) // "0xDBFF 0xDFFE"
unicodeToUTF16(0x10FFFF) // "0xDBFF 0xDFFF"

排序方式

从上面的结果中可以看出来 Unicode「辅助平面」的码点在「基本平面」的空白区域中的排列方式:高位(H)不变,依次在低位(L)上排序,从 0xDC00 排到 0xDFFF,当排到最后一位(也就是 0xDFFF)的时候,高位(H)进一位,低位(L)从 0xDC00 开始重新排序。

公式解析

unicode - 0x10000:表示该 Unicode 码点位于「辅助平面」的位置(从 0 开始),下面记作 pos

0x400 为 210,为高位(也是低位)的长度,因为有 10 个二进制位;

根据上头的「排序方式」,可以知道优先递增的是低位,所以咱们先看看低位的公式:

  • pos % 0x400 + 0xDC00:显而易见,每 210 个一组,计算 pos 在某个组(高位)中的第几个位置,然后加上低位的起点 0xDC00(大学刚开始学 C 语言的必经之路);

  • Math.floor(pos / 0x400) + 0xD800:不用多说,就是计算 pos 在第几组(高位),加上高位的起点 0xD800

所以,Unicode 码点用 UTF-16 编码表示,要是码点不在「基本平面」内,则会被分为两个码点,这两个码点对应了「基本平面」中的两个空白字符

因为一个码点占两个字节(一个码点 = 4 位十六进制数 = 16 bit = 2 个字节),所以辅助平面的字符用 UTF-16 编码需要占用 4 个字节(两个码点:高位低位)。

反过来,如果 UTF-16 编码用 Unicode 表示,若读取到的码点位于「基本平面」的空白区域(范围 U+D800U+DFFF 之间)

与 JavaScript 的爱恨情仇

说到 Unicode 和 JavaScript,虽然 JavaScript 采用的是 Unicode 字符集,但是 JavaScript 其实只支持一种编码方式:UCS-2

为啥是 UCS-2

拿好板凳,开始听阮一峰老师讲故事了 🍖

互联网还没出现的年代,曾经有两个团队,不约而同想搞统一字符集。

一个是 1988 年成立的 Unicode 团队,另一个是 1989 年成立的 UCS 团队。

UCS 的开发进度快于 Unicode,1990 年就公布了第一套编码方法 UCS-2,使用 2 个字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以 2 个字节就够用了。)

UTF-16 编码迟至 1996 年 7 月才公布,明确宣布是 UCS-2 的超集,即基本平面字符沿用 UCS-2 编码,辅助平面字符定义了 4 个字节的表示方法。

那 JavaScript 啥时候发布的呢?

巧了,1995 年 5 月,Brendan Eich 用了 10 天设计了 JavaScript 语言;同年 10 月,第一个解释引擎问世;次年 11 月,Netscape 正式向 ECMA 提交语言标准(整个过程详见阮老师的《JavaScript 诞生记》)。

对比 UTF-16 的发布时间(1996 年 7 月),就会明白 Netscape 公司那时没有其他选择,只有 UCS-2 一种编码方法可用!

所以,这就是 JavaScript 只支持 UCS-2 编码的历史~

历史遗留问题

由于 JavaScript 只能处理 UCS-2 编码,这直接导致了所有字符在这门语言中都是 2 个字节,如果是 4 个字节的字符,会当作两个双字节的字符处理。

𝌆 字符举例,详见字符百科,码点为 U+1D306

'𝌆'.length
// 2
'𝌆' === '\u1D306'
// false
'𝌆'.charAt(0)
// ''
'𝌆'.charCodeAt(0)
// 55348(0xD834)
'𝌆'.charCodeAt(1)
// 57094(0xDF06)

'𝌆' === '\uD834\uDF06'
// true

可以看到字符 𝌆 长度为 2,取到的第一字符是空字符 '',取到的第一个字符码点是 0xD834

可以看到,结果和我们期望的 Unicode 都不一样。

所以,在我们处理类似字符操作的时候,需要对码点进行一个判断,如果对应的码点落在 0xD800 ~ 0xDBFF 之间(高位),就需要连同后面的 2 个字节一起读取(低位)。

同理,其他的字符串操作,如 replace()slice()substring() 等方法也会存在类似的问题,需要注意。

ES6 中的字符串

关于 ES6 中对字符串的扩展详情,可以查阅阮老师的字符串的扩展 | ECMAScript 6入门,这里归纳几点

  • 正确识别字符

    ES6 将会自动识别 4 个字节的码点,遍历的时候,就不需要考虑这么多啦~

    for (let s of '𝌆') { console.log(s) }
    // '𝌆'
    
    '𝌆'.length // 2
    Array.from('𝌆').length // 1 可以获取正确的长度
    
  • 码点表示法

    在 ES6 中,只要将码点放在大括号内,就能正确识别内容

    '𝌆' === '\u1D306' // false
    '𝌆' === '\u{1D306}' // true
    
  • 新增字符串处理函数

    ES6 针对 4 字节字符新增以下函数:

    String.fromCodePoint():从 Unicode 码点返回对应字符

    String.prototype.codePointAt():从字符返回对应的码点

    String.fromCodePoint(0x1d306) // '𝌆'
    '𝌆'.codePointAt(0) // 0x1d306
    
  • 正则表达式

    ES6 提供了 u 修饰符,对正则表达式添加 4 字节码点的支持。

    /^.$/.test('𝌆') // false 
    /^.$/u.test('𝌆') // true
    
  • Unicode 正规化

    以字符 Ǒ 为例,码点为 U+01D1,也可以写成 OU+004F) + ˇU+030C)合成,ES6 提供的 String.prototype.normalize() 可以将两种方法转为同样的序列:

    '\u01D1' // 'Ǒ'
    '\u004F\u030C' // 'Ǒ'
    
    '\u01D1'==='\u004F\u030C' //false
    '\u01D1'.normalize() === '\u004F\u030C'.normalize() // true
    

参考资料

刨根究底字符编码 - 随笔分类 - 笨笨阿林 - 博客园

彻底弄懂 Unicode 编码

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

Unicode 与 JavaScript详解 - 阮一峰的网络日志

字符串的扩展 | ECMAScript 6入门

Unicode Consortium

滨哥加油鸭!

上次更新: 8/15/2019, 6:01:19 PM