Unicode

我只能说这一节不是一个穷尽了“关于Unicode你想知道的一切”的资料。我想讲解的是,你需要知道在ES6中对Unicode改变了什么,但是我们不会比这深入太多。Mathias Bynens (http://twitter.com/mathias) 大量且出色地撰写/讲解了关于JS和Unicode (参见 https://mathiasbynens.be/notes/javascript-unicodehttp://fluentconf.com/javascript-html-2015/public/content/2015/02/18-javascript-loves-unicode)。

0x00000xFFFF范围内的Unicode字符包含了所有的标准印刷字符(以各种语言),它们都是你可能看到过和互动过的。这组字符被称为 基本多文种平面(Basic Multilingual Plane (BMP))。BMP甚至包含像这个酷雪人一样的有趣字符: ☃ (U+2603)。

在这个BMP集合之外还有许多扩展的Unicode字符,它们的范围一直到0x10FFFF。这些符号经常被称为 星形(astral) 符号,这正是BMP之外的字符的16组 平面 (也就是,分层/分组)的名称。星形符号的例子包括? (U+1D11E)和? (U+1F4A9)。

在ES6之前,JavaScript字符串可以使用Unicode转义来指定Unicode字符,例如:

  1. var snowman = "\u2603";
  2. console.log( snowman ); // "☃"

然而,\uXXXXUnicode转义仅支持四个十六进制字符,所以用这种方式表示你只能表示BMP集合中的字符。要在ES6以前使用Unicode转义表示一个星形字符,你需要使用一个 代理对(surrogate pair) —— 基本上是两个经特殊计算的Unicode转义字符放在一起,被JS解释为一个单独星形字符:

  1. var gclef = "\uD834\uDD1E";
  2. console.log( gclef ); // "?"

在ES6中,我们现在有了一种Unicode转义的新形式(在字符串和正则表达式中),称为Unicode 代码点转义

  1. var gclef = "\u{1D11E}";
  2. console.log( gclef ); // "?"

如你所见,它的区别是出现在转义序列中的{ },它允许转义序列中包含任意数量的十六进制字符。因为你只需要六个就可以表示在Unicode中可能的最高代码点(也就是,0x10FFFF),所以这是足够的。

Unicode敏感的字符串操作

在默认情况下,JavaScript字符串操作和方法对字符串值中的星形符号是不敏感的。所以,它们独立地处理每个BMP字符,即便是可以组成一个单独字符的两半代理。考虑如下代码:

  1. var snowman = "☃";
  2. snowman.length; // 1
  3. var gclef = "?";
  4. gclef.length; // 2

那么,我们如何才能正确地计算这样的字符串的长度呢?在这种场景下,下面的技巧可以工作:

  1. var gclef = "?";
  2. [...gclef].length; // 1
  3. Array.from( gclef ).length; // 1

回想一下本章早先的“for..of循环”一节,ES6字符串拥有内建的迭代器。这个迭代器恰好是Unicode敏感的,这意味着它将自动地把一个星形符号作为一个单独的值输出。我们在一个数组字面量上使用扩散操作符...,利用它创建了一个字符串符号的数组。然后我们只需检查这个结果数组的长度。ES6的Array.from(..)基本上与[...XYZ]做的事情相同,不过我们将在第六章中讲解这个工具的细节。

警告: 应当注意的是,相对地讲,与理论上经过优化的原生工具/属性将做的事情比起来,仅仅为了得到一个字符串的长度就构建并耗尽一个迭代器在性能上的代价是高昂的。

不幸的是,完整的答案并不简单或直接。除了代理对(字符串迭代器可以搞定的),一些特殊的Unicode代码点有其他特殊的行为,解释起来非常困难。例如,有一组代码点可以修改前一个相邻的字符,称为 组合变音符号(Combining Diacritical Marks)

考虑这两个数组的输出:

  1. console.log( s1 ); // "é"
  2. console.log( s2 ); // "é"

它们看起来一样,但它们不是!这是我们如何创建s1s2的:

  1. var s1 = "\xE9",
  2. s2 = "e\u0301";

你可能猜到了,我们前面的length技巧对s2不管用:

  1. [...s1].length; // 1
  2. [...s2].length; // 2

那么我们能做什么?在这种情况下,我们可以使用ES6的String#normalize(..)工具,在查询这个值的长度前对它实施一个 Unicode正规化操作

  1. var s1 = "\xE9",
  2. s2 = "e\u0301";
  3. s1.normalize().length; // 1
  4. s2.normalize().length; // 1
  5. s1 === s2; // false
  6. s1 === s2.normalize(); // true

实质上,normalize(..)接受一个"e\u0301"这样的序列,并把它正规化为\xE9。正规化甚至可以组合多个相邻的组合符号,如果存在适合他们组合的Unicode字符的话:

  1. var s1 = "o\u0302\u0300",
  2. s2 = s1.normalize(),
  3. s3 = "ồ";
  4. s1.length; // 3
  5. s2.length; // 1
  6. s3.length; // 1
  7. s2 === s3; // true

不幸的是,这里的正规化也不完美。如果你有多个组合符号在修改一个字符,你可能不会得到你所期望的长度计数,因为一个被独立定义的,可以表示所有这些符号组合的正规化字符可能不存在。例如:

  1. var s1 = "e\u0301\u0330";
  2. console.log( s1 ); // "ḛ́"
  3. s1.normalize().length; // 2

你越深入这个兔子洞,你就越能理解要得到一个“长度”的精确定义是很困难的。我们在视觉上看到的作为一个单独字符绘制的东西 —— 更精确地说,它称为一个 字形 —— 在程序处理的意义上不总是严格地关联到一个单独的“字符”上。

提示: 如果你就是想看看这个兔子洞有多深,看看“字形群集边界(Grapheme Cluster Boundaries)”算法(http://www.Unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries)。

字符定位

与长度的复杂性相似,“在位置2上的字符是什么?”,这么问的意思究竟是什么?前ES6的原生答案来自charAt(..),它不会遵守一个星形字符的原子性,也不会考虑组合符号。

考虑如下代码:

  1. var s1 = "abc\u0301d",
  2. s2 = "ab\u0107d",
  3. s3 = "ab\u{1d49e}d";
  4. console.log( s1 ); // "abćd"
  5. console.log( s2 ); // "abćd"
  6. console.log( s3 ); // "ab?d"
  7. s1.charAt( 2 ); // "c"
  8. s2.charAt( 2 ); // "ć"
  9. s3.charAt( 2 ); // "" <-- 不可打印的代理字符
  10. s3.charAt( 3 ); // "" <-- 不可打印的代理字符

那么,ES6会给我们Unicode敏感版本的charAt(..)吗?不幸的是,不。在本书写作时,在后ES6的考虑之中有一个这样的工具的提案。

但是使用我们在前一节探索的东西(当然也带着它的限制!),我们可以黑一个ES6的答案:

  1. var s1 = "abc\u0301d",
  2. s2 = "ab\u0107d",
  3. s3 = "ab\u{1d49e}d";
  4. [...s1.normalize()][2]; // "ć"
  5. [...s2.normalize()][2]; // "ć"
  6. [...s3.normalize()][2]; // "?"

警告: 提醒一个早先的警告:在每次你想得到一个单独的字符时构建并耗尽一个迭代器……在性能上不是很理想。对此,希望我们很快能在后ES6时代得到一个内建的,优化过的工具。

那么charCodeAt(..)工具的Unicode敏感版本呢?ES6给了我们codePointAt(..)

  1. var s1 = "abc\u0301d",
  2. s2 = "ab\u0107d",
  3. s3 = "ab\u{1d49e}d";
  4. s1.normalize().codePointAt( 2 ).toString( 16 );
  5. // "107"
  6. s2.normalize().codePointAt( 2 ).toString( 16 );
  7. // "107"
  8. s3.normalize().codePointAt( 2 ).toString( 16 );
  9. // "1d49e"

那么从另一个方向呢?String.fromCharCode(..)的Unicode敏感版本是ES6的String.fromCodePoint(..)

  1. String.fromCodePoint( 0x107 ); // "ć"
  2. String.fromCodePoint( 0x1d49e ); // "?"

那么等一下,我们能组合String.fromCodePoint(..)codePointAt(..)来得到一个刚才的Unicode敏感charAt(..)的更好版本吗?是的!

  1. var s1 = "abc\u0301d",
  2. s2 = "ab\u0107d",
  3. s3 = "ab\u{1d49e}d";
  4. String.fromCodePoint( s1.normalize().codePointAt( 2 ) );
  5. // "ć"
  6. String.fromCodePoint( s2.normalize().codePointAt( 2 ) );
  7. // "ć"
  8. String.fromCodePoint( s3.normalize().codePointAt( 2 ) );
  9. // "?"

还有好几个字符串方法我们没有在这里讲解,包括toUpperCase()toLowerCase()substring(..)indexOf(..)slice(..),以及其他十几个。它们中没有任何一个为了完全支持Unicode而被改变或增强过,所以在处理含有星形符号的字符串是,你应当非常小心 —— 可能干脆回避它们!

还有几个字符串方法为了它们的行为而使用正则表达式,比如replace(..)match(..)。值得庆幸的是,ES6为正则表达式带来了Unicode支持,正如我们在本章早前的“Unicode标志”中讲解过的那样。

好了,就是这些!有了我们刚刚讲过的各种附加功能,JavaScript的Unicode字符串支持要比前ES6时代好太多了(虽然还不完美)。

Unicode标识符名称

Unicode还可以被用于标识符名称(变量,属性,等等)。在ES6之前,你可以通过Unicode转义这么做,比如:

  1. var \u03A9 = 42;
  2. // 等同于:var Ω = 42;

在ES6中,你还可以使用前面讲过的代码点转义语法:

  1. var \u{2B400} = 42;
  2. // 等同于:var ? = 42;

关于究竟哪些Unicode字符被允许使用,有一组复杂的规则。另外,有些字符只要不是标识符名称的第一个字符就允许使用。

注意: 关于所有这些细节,Mathias Bynens写了一篇了不起的文章 (https://mathiasbynens.be/notes/javascript-identifiers-es6)。

很少有理由,或者是为了学术上的目的,才会在标识符名称中使用这样不寻常的字符。你通常不会因为依靠这些深奥的功能编写代码而感到舒服。