(译者注:本文含有Unicode辅助平面的特殊字符,部分浏览器可能无法正确显示,但并不影响理解文章内容。)
在动笔写这篇文章之前,我得先忏悔一下:在很长一段时间里我对Unicode充满了恐惧。
每次遇到需要Unicode知识的编程问题时,我总是找一个hack方案来解决,但解决方案的原理我也不懂。
直到遇见一个需要深入了解Unicode知识才能解决的问题,我才停止了这种逃避。因为这个问题没办法应用特定情境的解决方案。
在努力读了一大堆文章之后,我惊讶地发现Unicode并不难懂。好吧,确实是有些文章起码得看3遍才能看懂。
但我发现Unicode标准不仅世界通用,而且十分优雅简洁,只不过要理解其中一些抽象概念有点困难。
如果你觉得理解Unicode很难,那么是时候来面对它了!其实它没你想的那么难。去沏一杯香浓的茶或咖啡吧☕,让我们进入抽象概念、字符、星光平面(辅助平面)和代理对的世界。
本文首先会解释Unicode中的基本概念,这是必需的背景知识。
然后会说明JavaScript如何解析Unicode,以及你可能踩到哪些坑。
你还会学到如何利用ECMAScript 2015的新特性来解决部分难题。
准备好了?那就燥起来吧!
目录:
1 Unicode背后的思想
2 Unicode基本概念
- 2.1 字符与代码点
- 2.2 Unicode平面
- 2.3 码元
- 2.4 代理对
- 2.5 组合用字符
3 JavaScript中的Unicode
- 3.1 转义序列
- 3.2 字符串比较
- 3.3 字符串长度
- 3.4 字符定位
- 3.5 正则匹配
4 结语
1. Unicode背后的思想
首先问一个最基础的问题:你是怎样阅读并理解这篇文章的?答案很简单,因为你明白这些字以及由字组成的单词的含义。
那你又是如何明白这些字的含义的呢?答案也很简单,因为你(读者)和我(作者)对于这些(呈现在屏幕上的)图形与汉字(即含义)之间的联系有着相同的认知。
对计算机来说这个原理也差不多,只有一点不同:计算机不懂这些字(字母)的含义,只是将其理解为特定的比特序列。
让我们设想一个情景:计算机User1向计算机User2发送一条消息'hello'
。
计算机并不知道这些字母的含义。所以计算机User1将消息'hello'
转换为一串数字序列0x68 0x65 0x6C 0x6C 0x6F
,每个字母对应一个数字:h
对应0x68
, e
对应0x65
,等等。
接着将这些数字发送给计算机User2。
计算机User2收到数字序列0x68 0x65 0x6C 0x6C 0x6F
后,使用同一套字母与数字的对应关系重建消息内容,'hello'
就能正确地显示出来了。
不同计算机之间对字母与数字之间对应关系的协议就是Unicode进行标准化的结果。
根据Unicode,h
是一个名为LATIN SMALL LETTER H的抽象字符。这个抽象字符对应数字0x68
,也就是一个标记为U+0068
的代码点。这些概念将在下一章中说明。
Unicode的作用就是提供一个抽象字符列表(字符集),并给每一个字符分配一个独一无二的标识符代码点(编码字符集)。
2. Unicode基本概念
www.unicode.org
网站提到:
Unicode为每一个字符分配一个专有的数字
不分平台
不分程序
不分语言
Unicode是一个世界通用的字符集,它定义了全世界大部分书写体系的字符集,并为每一个字符分配了一个独一无二的数字(代码点)。
Unicode囊括了大部分现代语言、标点符号、附加符号(变音符)、数学符号、技术符号、箭头和表情符号等。
Unicode第一版1.0于1991年10月发布,包含7161个字符。最新版9.0(2016年6月发布)则提供了128172个字符的编码。
Unicode的通用性与开放性解决了过去一直存在的一个问题:供应商们各自实现不同的字符集和编码规则,很难处理。
创建一个支持所有字符集和编码规则的应用是十分复杂的。更不用说你选用的编码可能不支持所有你需要的语言。
如果你觉得Unicode很难,那就想想如果没有它编程会更难。
我还记得从前随机选择所需的字符集和编码规则去读取文件内容的时候。全靠人品啊!
2.1 字符与代码点
抽象字符(即文本字符)是用来组织、管理或表现文本数据的信息单位。
Unicode中的字符是一个抽象概念。每一个抽象字符都有一个对应的名称,例如LATIN SMALL LETTER A。该抽象字符的图像表现形式(glyph)是a
。(译者注:glyph即图像字符)
代码点是指被分配给某个抽象字符的数字
代码点以U+<hex>
的形式表示,U+
是代表Unicode的前缀,而<hex>
是一个16进制数。例如U+0041
和U+2603
都是代码点。
代码点的取值范围是从U+0000
到U+10FFFF
。
记住代码点就是一个简单的数字。思考有关Unicode的问题时要记得这一点。
代码点就好像数组元素的下标。
Unicode的神奇之处就在于将代码点与抽象字符关联起来。例如U+0041
对应的抽象字符名为LATIN CAPITAL LETTER A (表现为A
),而U+2603
对应的抽象字符名为SNOWMAN(表现为☃
)
注意,并非所有的代码点都有对应的抽象字符。可用的代码点有1114112个,但分配了抽象字符的只有128237个。
2.2 Unicode平面
平面是指从
U+n0000
到U+nFFFF
的区间,也就是65536(1000016)个连续的Unicode代码点,n的取值范围是从016到1016。
这些平面将Unicode代码点分为17个大小相等的集合:
- 平面0包含从
U+0000
到U+FFFF
的代码点 - 平面1包含从
U+**1**0000
到U+**1**FFFF
的代码点 - …
- 平面16包含从
U+**10**0000
到U+**10**FFFF
的代码点
基本多文种平面
平面0比较特殊,被称为基本多文种平面或简称BMP。它包含了大多数现代语言的字符 (基本拉丁字母, 西里尔字母, 希腊字母等)和大量的符号。
如上文所述,基本多文种平面的代码点取值范围是从U+0000
到U+FFFF
,最多可以有4位16进制数字。
大多数时候开发者处理的都是BMP中的字符。它包含了大多数情况下的必需字符。
BMP中的一些字符:
e
对应代码点U+0065
抽象字符名: LATIN SMALL LETTER E|
对应代码点U+007C
抽象字符名: VERTICAL BAR■
对应代码点U+25A0
抽象字符名: BLACK SQUARE☂
对应代码点U+2602
抽象字符名: UMBRELLA
星光平面
BMP之后的16个平面(平面1,平面2,…,平面16)被称为星光平面或辅助平面。
星光平面的代码点被称为星光代码点。这些代码点的取值范围是从U+10000
到U+10FFFF
。
星光代码点可能会有5位或6位16进制数字:U+ddddd
或U+dddddd
。
来看几个星光平面里的字符:
对应
U+1D11E
抽象字符名:MUSICAL SYMBOL G CLEF对应
U+1D401
抽象字符名:MATHEMATICAL BOLD CAPITAL B对应
U+1F035
抽象字符名:DOMINO TITLE HORIZONTAL-00-04对应
U+1F600
抽象字符名:GRINNING FACE
2.3 码元
计算机在存储时当然不会使用代码点或抽象字符,它们是存在于开发者大脑中的概念。
所以自然要有一种在物理层面表示Unicode代码点的方式:码元。
码元是指使用某种给定的编码规则给抽象字符编码后得到的比特序列。
字符编码将抽象层面的代码点转换为物理层面的比特序列:码元。
换句话说,字符编码的作用就是将Unicode代码点翻译成独一无二的码元序列。
常用的字符编码有UTF-8, UTF-16 和 UTF-32.
大多数JavaScript引擎使用UTF-16编码字符。它会影响JavaScript处理Unicode的方式。所以从这里开始让我们集中精力于UTF-16吧。
UTF-16(全称:16位统一码转换格式)是一种变长编码:
- BMP中的代码点编码为单个16位的码元
- 星光平面的代码点编码为两个16位的码元
来看几个例子
假设我们想把LATIN SMALL LETTER A,也就是抽象字符a
存入硬盘。Unicode告诉我们抽象字符LATIN SMALL LETTER A对应代码点U+0061
。
现在我们来看看UTF-16如何转换U+0061
。编码规范上说,对于BMP中的代码点只需将它的16进制数字U+0061存入一个16位的码元就行了。
显然,BMP中的代码点刚好能存进一个16位的码元。编码BMP可谓小菜一碟。
2.4 代理对
现在让我们来研究一个复杂些的例子。假设我们想存储一个星光代码点(属于星光平面):GRINNING FACE character 。该字符对应的代码点是
U+1F600
。
由于星光代码点需要21个比特来存储字符信息,UTF-16需要两个码元来编码,每个16比特。代码点 U+1F600
被拆分为所谓的代理对:0xD83D
(高位代理码元)与0xDE00
(低位代理码元)。
代理对用来表示那些对应2个16位码元序列的抽象字符,其中第一个码元是高位代理码元而第二个是低位代理码元。
编码一个星光代码点需要两个码元:即一个代理对。比如前面那个例子,使用UTF-16编码U+1F600
()就使用了一个代理对:
0xD83D 0xDE00
。
1 |
`console.log('\uD83D\uDE00'); // => ''` |
高位代理码元的取值范围是从0xD800
到0xDBFF
。 低位代理码元的取值范围是从0xDC00
到0xDFFF
。
代理对与代码点之间互相转换的算法如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function getSurrogatePair(astralCodePoint) { let highSurrogate = Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800; let lowSurrogate = (astralCodePoint - 0x10000) % 0x400 + 0xDC00; return [highSurrogate, lowSurrogate]; } getSurrogatePair(0x1F600); // => [0xDC00, 0xDFFF] function getAstralCodePoint(highSurrogate, lowSurrogate) { return (highSurrogate - 0xD800) * 0x400 + lowSurrogate - 0xDC00 + 0x10000; } getAstralCodePoint(0xD83D, 0xDE00); // => 0x1F600 |
代理对并不是一个令人愉快的东西。在JavaScript中处理字符串时我们必须将它们视为特殊情况来处理,具体内容我们在下章细说。
但UTF-16的存储效率很高。因为99%需要处理的字符都属于BMP,只需要1个码元。
2.5 组合用字符
在一个书写系统的上下文中,一个字素或者符号是最小的可区分单元。
字素就是用户所认为的一个字符。屏幕上所展示的一个有形的字素称为图像字符(glyph)。
在大多数情况下,一个Unicode字符就代表一个字素。例如 U+0066
LATIN SMALL LETTER F就是一个英文字母f
。
但有时候一个字素会包含一系列字符。
例如å
在丹麦语书写系统中是一个不可再分的字素。但它是用U+0061
LATIN SMALL LETTER A (渲染为a
) 结合一个特殊字符U+030A
COMBINING RING ABOVE(渲染为◌̊)来显示的。
U+030A
用来修饰前一个字符,这种字符称为组合用字符。
1 2 |
console.log('\u0061\u030A'); // => 'å' console.log('\u0061'); // => 'a' |
组合用字符是应用在前一个基础字符上以形成完整字素的字符。
组合用字符包括以下字符:重音符号、变音符、希伯来语点、阿拉伯语元音符号和印度语节拍符。
组合用字符通常不会离开基础字符单独使用。我们应该避免单独显示它们。
与代理对一样,在JavaScript中处理组合用字符也很棘手。
在用户看来一个组合字符序列(基础字符+组合用字符)是【一】个符号(例如'\u0061\u030A'
就是'å'
)。但开发者必须清楚实际上要用到两个代码点U+0061
和U+030A
来生成å
。
3. JavaScript中的Unicode
ES2015规范提到源代码文本使用Unicode(5.1及以上版本)表示。源码文本是一串取值范围从U+0000
到U+10FFFF
的代码点序列。尽管ECMAScript规范没有指明源码储存和交换的方式,但通常都以UTF-8编码(在web中推荐使用的编码)。
我建议将源代码文本控制在Basic Latin Unicode block(或者说ASCII)中。超出ASCII的字符应该避免使用。这能保证源码文本在编码时少出些问题。
ECMAScript 2015在语言层面上给出了JavaScript中String(字符串)的明确定义:
String类型是由16比特无符号整型数值(“元素”)组成的集合,最少包含0个元素,最多包含253-1个元素。String类型通常用来在运行ECMAScript的程序中表示文本信息,因此String中的每个元素都被当作一个UTF-16码元值。
字符串中的每一个元素都会被引擎解释为一个码元。而字符串的渲染结果并不能明确地反映它包含的码元(及其所代表的代码点)。看下面这个例子:
1 2 |
console.log('cafe\u0301'); // => 'café' console.log('café'); // => 'café' |
虽然字面量'cafe\u0301'
和'café'
有轻微的差别,但两者都被渲染为同样的字符序列café
。
字符串的长度是指其中包含的元素(即16位数值)的个数。ECMAScript在解释String类型时,字符串的每一个元素都被解释为一个UTF-16码元。
从上一章关于代理对和组合用字符的内容可知,某些字符需要2个以上的码元来表示。所以在计算字符长度或通过字符串索引访问字符时要格外小心。