HOME

JavaScript与Unicode

字符串是任何一个编程语言中的重要概念,同时也是一个非常复杂的问题。

日常编码中可能并不一定能见到它的复杂性,下面是几个字符串操作,使用你最熟悉的编程语言,看看结果如何。

  • 逆转字符串"noël",正确结果应该是"lëon"
  • 获取字符串"noël"前三个字符,正确结果应该是"noë"
  • 获取字符串"😸😾"的长度,正确答案应该是2
  • 字符串"noël"和字符串"noël"规整化以后应该相等(他们看起来一样,但是内部表示不一样,一个6字节,一个5字节,这里涉及到Unicode的规整化)

对于大部分编程语言,包括Ruby,Python,JS,C#,Java等,上面的问题都无法全部返回正确结果。(但是,拥有超强Unicode支持的Elixir可以。)

基本概念

首先来看关于字符串的几个基本概念。

  • 字符集 (Character Set):定义了有哪些字符,任何一段字符串,里面的字符都属于某个字符集,比如经典的ASCII字符集以及目前最为常用的Unicode字符集。
  • 码点 (Code Point):字符集中的每个字符,都有一个唯一的编号,叫做码点,比如A在ASCII字符集中的码点是65。
  • 字符编码 (Character Encoding):将字符转换为字节的方式。对于某些字符集,比如ASCII,字符编码很简单,直接存储码点即可,比如A,计算机中存储就是65的二进制补码,0b01000001。但是对于Unicode,字符编码就有很多种,后文我们再详细介绍。
  • 编码单元 (Code Unit):UTF16中的一个概念,每两个字节称为一个编码单元,一个字符可能使用一个或两个编码单元进行编码

Unicode

Unicode是一项了不起的发明,这个字符集诞生的初衷很简单,我们需要有一个大的字符集囊括地球上的所有语言中的所有文字。

在Unicode诞生之前,每个语言有自己的字符集,比如英语的ASCII,繁体中文的Big Five,简体中文的GB2312等等。这就使得计算机处理多语言的文档变得十分麻烦,同时,跨语言交流也非常不便,A语言的文档发给B语言的计算机,B不知道该如何解码,说不定都没有安装A语言对应的字符集。

Unicode项目诞生于1987年,1.0版本于1991年发布,目前最新版是11.0。

Unicode字符集目前一共分为17个平面 (Plane),编号为0 - 16,每个平面由16位构成,也就是每个平面可以编码2^16 = 65536个字符。

其中,第一个平面叫做基本平面,*BMP, Basic Multilingual Plane*,里面编码了最为常用的一些字符。

剩下16个平面都叫做补充平面,*Supplementary Plane*。

Unicode的码点从0开始,也就是说,目前,Unicode的字符码点范围为0x0000 - 0x10FFFF。当然,这中间很多码点没有定义。

Unicode Encoding

有了字符集,剩下的问题就是字符编码,即怎样将字符编码成字节。常见的方式有UTF32,UTF16以及UTF8。我们来分别看看每个编码的方式和优缺点。

UTF32

因为目前Unicode只用了三个字节就可以完全表示,最为简单的做法是:使用三个字节直接编码码点。

这种思路对应的编码方式是*UTF32*,使用四个字节直接编码码点。这里可能有的同学会问,为什么不使用三个字节?有两个原因:

  1. 为了以后扩充性考虑,虽然目前三个字节够用,但是以后可能不够用
  2. 计算机处理四字节对齐数据会更快,使用三字节,虽然节省了内存,但是处理起来效率很低。这就和我们编程语言中一般有int8int16int32,但是没有int24是一个道理。

UTF32的优点是编码和解码都非常简单。缺点也非常明显:对于英文文本(互联网上绝大部分信息是英文),体积要比ASCII大4倍。这是一个无法接受的缺点,因此UTF32基本上是不使用的,HTML5规范就明确规定网页不得使用UTF32进行编码。

UTF16 && UCS-2

UCS-2 (2-byte Universal Character Set)是一个已经废弃的定长编码,始终使用两个字节编码BMP中的字符。对于非BMP中的字符,UCS-2无法编码。

UTF16是UCS-2的一个扩展,是一个变长编码,结果可能为两个字节,也可能为四个字节。其中每两个字节叫做*Code Unit*,编码单元。对于BMP中的字符,UTF16的编码和UCS-2一样,使用一个编码单元直接编码字符的码点,对于非BMP中的字符,UTF16使用一个叫做Surrogate Pairs的技术来进行编码。

在BMP中,并不是所有的码点都定义了字符,存在一个空白区,0xD800 - 0xDFFF这个范围内的码点不定义任何字符。

除了BMP,剩下的码点一共是0x10FFFF - 0xFFFF = 1048576 = 2^20个,也就是需要20位进行编码。

Surrogate Pairs使用两个编码单元来编码一个非BMP字符。第一个编码单元的范围为0xD800 - 0xDBFF,换成二进制为0b1101_10xx_xxxx_xxxx,叫做Lead Surrogate,正好可以编码10位。

第二个编码单元的范围为0xDC00 - 0xDFFF,换成二进制为0b1101_11xx_xxxx_xxxx,叫做Tail Surrogate,正好也可以用来编码10位。

这样,通过使用两个编码单元,UTF16就可以将非BMP字符的偏移码点值(减去0x10000以后的码点值),使用Surrogate Pair进行存储,从而编码非BMP字符。同时,由于编码单元的范围都在BMP未定义字符的区间中,解码也不会产生任何歧义。

以emoji😜为例,码点为0x1F61C,减去0x10000,结果为0xF61C,换成二进制,填充为20位,结果是0000_1111_0110_0001_1100。将这20位填充到Surrogate Pair中,得到的结果是,Lead Surrogate:1101_1000_0011_1101,Tail Surrogate:1101_1110_0001_1100,换成16进制便是0xD83D 0xDE1C,这就是😜的UTF16编码。

UTF8

UTF8是目前使用最多也是最为灵活的一种变长编码,同UTF16一样,UTF8的编码结果是不定长的,在1到4个字节之间。

具体规则如下,左边为码点范围,右边为二进制编码形式。

  • 0x0000 – 0x007F: 0xxx_xxxx,使用一个字节,编码7位。
  • 0x0080 – 0x07FF: 110x_xxxx, 10xx_xxxx,使用两个字节,编码11位。
  • 0x0800 – 0xFFFF: 1110_xxxx, 10xx_xxxx, 10xx_xxxx,使用三个字节编码16位。
  • 0x10000 – 0x1FFFFF: 1111_0xxx, 10xx_xxxx, 10xx_xxxx, 10xx_xxxx,使用四个字节,编码21位

还是以emoji😜为例,码点为0x1F61C,在区间0x10000 - 0x1FFFFF之中,需要使用四个字节进行编码。首先将其转换为二进制,填充为21位,结果是0_0001_1111_0110_0001_1100,然后将这21位按照上述说明填入,结果是1111_00001001_11111001_10001001_1100,换成16进制便是0xF0 0x9F 0x98 0x9C,这就是😜的UTF8编码。

UTF8因为它的灵活性,尤其是与ASCII的兼容性,目前已经成为事实上的标准。对于编码问题的处理很简单,一律选择使用UTF8即可

JS中的字符串问题和解决方法

JS的字符串和字符

JS中的字符串,我们可以认为是理解Surrogate Pairs的UCS-2

这是因为,JS中的字符串,我们可以使用Surrogate Pairs来编码非BMP字符,这是UTF16的特性,单纯的UCS-2是不能理解Surrogate Pairs的。

但是JS中的字符允许无效的Surrogate Pairs,比如"\uDFFF\uD800",或者单个Surrogate,比如"\uD800"。因此JS中的字符也不是UTF16,单纯的UTF16是不允许上面的字符串的。

另一个问题是,在JS看来,什么样的东西是一个字符?因为JS是理解Surrogate Pair的UCS-2,因此,在JS眼中,一个编码单元是一个字符

这就给JS中的Unicode处理带来了很多问题,基本上所有的字符串操作函数在处理非BMP字符时都是错误的。

length

最基本的问题就是,非BMP的字符,由于使用了Surrogate Pair,含有两个编码单元,导致JS认为字符的长度为2,这显然不是我们要的结果。

"😜".length // 2

解决这个问题,可以自己编写一个strLength函数,特别处理码点范围在0xD800 - 0xDFFF中的字符,当然这比较麻烦,简单的方案是使用Punycode库。

var puny = require("punycode")
puny.ucs2.decode("😜").length // 1

或者利用ES6的新特性:ES6中的for of循环可以正确识别Unicode,这也就使得和for of循环使用相同机制的...操作符可以正确识别Unicode。

// 这个做法很浪费内存
[..."😜"].length // 1

charAt && charCodeAt

charAt以及charCodeAt两个方法用于返回某个偏移量的字符和字符码点,对于非BMP字符,返回结果是错的,返回的是Lead Surrogate的字符和码点。

"😜".charAt(0) // "�"
"😜".charCodeAt(0) // 55357

可以使用ES6的String.prototype.codePointAtString.fromCodePoint两个方法来解决这个问题。

"😜".codePointAt(0) // 128540
String.fromCodePoint("a😜b".codePointAt(1)) // "😜"

Unicode Escape

JS中允许使用\udddd以及\xdd的形式指定十六进制的数字插入字符。但是对于非BMP的字符,使用这个方式插入,需要首先得到Surrogate Pairs才行,不能直接根据码点插入,比较麻烦。

"\u1F61C" // "ὡC"

ES6新提供了\u{}方式,使得根据码点插入字符变得非常简单。注意escape中填写的都是码点的十六进制值。

"\u{1F61C}" // "😜"

Substring, Substr, Slice

这三个函数的行为很类似,参数的含义以及是否允许负数索引上有一些细微的不同。他们同样也都不能正确处理非BMP字符。

"😜".substr(0, 1) // "�"
"😜".substring(0, 1) // "�"
"😜".slice(0, 1) // "�"

我们可以利用ES6的for of实现重新编写这三个函数,下面的实现只用来说明思路,并不完全。

String.prototype.newSubstr = function(start, length) {
  return [...this].slice(start, start + length).join("")
}
String.prototype.newSubstring = function(start, end) {
  return [...this].slice(start, end).join("")
}
String.prototype.newSlice = function(start, end) {
  return [...this].slice(start, end).join("")
}
"😜".newSubstr(0, 1) // "😜"
"😜".newSubstring(0, 1) // "😜"
"😜".newSlice(0, 1) // "😜"

其他的一些函数都可以用类似的思路解决,不在赘述了。

Regexp Dot

JS中的正则,在处理非BMP字符时同样存在问题。

我们首先来看.字符。.字符在正则中的含义是匹配非换行符以外的任意字符。但是在JS中,.只能匹配一个编码单元,对于使用两个编码单元的非BMP字符,则无法匹配。

/./.test("😜") // false

这个问题的解决方案有两个。第一,自己编写范围来匹配非BMP字符。

/[\u0000-\uD7FF][\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/.test("😜") // true

第二,使用ES6引入的u标志。

/./u.test("😜") // true

Regexp Range

第二个问题是正则中的范围。范围中如果使用了非BMP字符,JS会报错。

/[😆-😜]/.test("😜")
Uncaught SyntaxError: Invalid regular expression: /[😆-😜]/: Range out of order in character class
    at <anonymous>:1:1

出错的原因在于/[😆-😜]/在JS中等价于/[\uD83D\uDE06-\uD83D\uDE1C]/,而\uDE06-\uD83D会被解释为一个范围,而这个范围的起始值比结束值要大,因此错误。

解决方法同样有两个。第一,改写正则。

/\uD83D[\uDE06-\uDE1C]/.test("😆") // true

第二,还是使用ES6引入的u标志。

/[😆-😜]/u.test("😜") // true
/[\u{1F606}-\u{1F61C}]/u.test("😜") // true

Unicode Normalization

最后,我们来谈谈Unicode的规整化。这个问题和JS没关系,是Unicode字符集本身的问题。

根据Unicode定义,有些字符属于*修饰字符*,也就是和别的字符一起出现的时候会修饰别的字符,两个合在一起构成一个我们人眼中的字符。

比如,这个字符,由两个Unicode码点构成,分别是U+0065U+0308。这两个都是Unicode中的合法字符,拥有自己的码点,但他们合在一起的时候,构成一个我们人类眼中的字符。

同时,在Unicode中,还有一个单独的字符ë,码点为U+00EB

ë在我们眼中是一样的字符,但在Unicode中却是不同的表现,一个是由两个字符拼接而成,另一个是独立的字符,因此,如果直接比较的话,肯定是不相等的。

"ë" === "ë" // false

这时候就需要引入规整化,将字符转变为某种特定的形式。Unicode中定义了四种形式,常用的两种是:

  1. NFD: Normalization Form Canonical Decomposition,将所有的单个的复合字符转换为多个字符拼接而成的形式
  2. NFC: Normalization Form Canonical Composition,将所有的拼接而成的符合字符转换为单个字符的形式

因此,在比较Unicode字符串之前,我们需要对两边的字符串规整化到相同的形式,这样结果才是准确的。ES6中引入的String.prototype.normalize方法可以用于字符串的规整化。

"ë".normalize("NFC") === "ë".normalize("NFC") // true

Reverse the String

由于存在修饰字符,使得字符串取反变成了一个复杂的操作。

如果不考虑非BMP字符,在JS中,对字符串取反的一般方式为str.split("").reverse().join("")

考虑到非BMP字符,我们可以使用[...str].reverse().join("")

但是,如果含有修饰字符的话,使用...一样无法返回正确的结果。

[..."mañana"].reverse().join("") // "anãnam"

这里的问题在于对于"mañana"使用...产生的字符数组为["m", "a", "n", "̃", "a", "n", "a"],取反以后,修饰字符会跟在a的后面,从而产生

这个问题需要做手动做一些的处理,在取反之前,将修饰字符和被修饰的字符颠倒一下顺序,然后再取反就行了。我们可以直接使用esrever库来处理。

esrever的reverse函数具体实现可以看这里

esrever.reverse("mañana") // "anañam"