更好的 Unicode 支持
在 ES6 之前,JS 的字符串以 16 位字符编码(UTF-16)为基础。每个 16 位序列都是一个码元(code unit),用于表示一个字符。字符串所有的属性与方法(像是 length 属性与 charAt() 方法)都是基于 16 位的码元。当然,16 位曾经足以容纳任何字符,然而由于 Unicode 引入了扩展字符集,这就不再够用了。
UTF-16 代码点
Unicode 的明确目标是给世界上所有的字符提供全局唯一标识符,而 16 位的字符长度限制已不能满足这种需求。这些全球唯一标识符被称为代码点(code points),是从 0 开始的简单数字。代码点是如你想象的字符代码那样,用一个数字来代表一个字符。字符编码要求将代码点转换为内部一致的码元,而对于 UTF-16 来说,代码点可以由多个码元组成。
UTF-16 中的前 216 代码点表示为单个 16 位代码单元。该范围称为基本多语言平面 (BMP)。除此之外的所有内容都被视为位于补充平面之一,其中代码点不能再仅用 16 位表示。UTF-16 通过引入 代理对 解决了这个问题,其中单个代码点由两个 16 位代码单元表示。这意味着字符串中的任何单个字符可以是 BMP 字符的一个码元(总共 16 位),也可以是补充平面字符的两个码元(总共 32 位)。
在 ECMAScript 5 中,所有字符串操作都在 16 位码元上工作,这意味着您可以从包含代理项对的 UTF-16 编码字符串中获得意外结果,如下例所示:
var text = "𠮷";
console.log(text.length); // 2
console.log(/^.$/.test(text)); // false
console.log(text.charAt(0)); // ""
console.log(text.charAt(1)); // ""
console.log(text.charCodeAt(0)); // 55362
console.log(text.charCodeAt(1)); // 57271
单个 Unicode 字符 “𠮷” 使用 代理对 表示,因此,上面的 JavaScript 字符串操作将该字符串视为具有两个 16 位字符。这意味着:
-
text 的 length 属性值是 2,而不是应有的 1。
-
意图匹配单个字符的正则表达式匹配失败了,因为它认为这里有两个字符。
-
charAt() 方法无法返回一个有效的字符,因为这里每 16 位代码点都不是一个可打印字符。
charCodeAt() 方法同样无法正确识别该字符,它只能返回每个码元的 16 位数字,但在 ES5 中,这已经是对 text 变量所能获取到的最精确的值了。
另一方面,ES6 要求这类 UTF-16 字符的编码问题必须得到解决。基于这种字符编码的字符串操作的标准化,也就意味着 JS 可以支持针对 代理对 的专门功能设计。本章接下来的部分会讨论与此有关的几个关键案例。
codePointAt() 方法
ES6 为全面支持 UTF-16 而新增的方法之一是 codePointAt(),它可以在给定字符串中按位置提取 Unicode 代码点。该方法接受的是码元位置而非字符位置,并返回一个整数值,就像下面的 console.log() 范例所展示的:
var text = "𠮷a";
console.log(text.charCodeAt(0)); // 55362
console.log(text.charCodeAt(1)); // 57271
console.log(text.charCodeAt(2)); // 97
console.log(text.codePointAt(0)); // 134071
console.log(text.codePointAt(1)); // 57271
console.log(text.codePointAt(2)); // 97
codePointAt() 方法的返回值一般与 charCodeAt() 相同,除非操作对象并不是 BMP 字符。text 字符串的第一个字符不是 BMP 字符,因此它占用了两个码元,意味着该字符串的 length 属性是 3 而不是 2。charCodeAt() 方法只返回了位置 0 的第一个码元;而 codePointAt() 返回的是完整的代码点,即使它占用了多个码元。对于位置 1 (第一个字符的第二个码元)和位置 2 ("a" 字符)来说,两个方法返回的值则是相同的。
判断字符包含了一个还是两个码元,对该字符调用 codePointAt() 方法就是最简单的方法。可以照下面的函数这么写:
function is32Bit(c) {
return c.codePointAt(0) > 0xFFFF;
}
console.log(is32Bit("𠮷")); // true
console.log(is32Bit("a")); // false
16 位字符的上边界用十六进制表示就是 FFFF
,因此任何大于该数字的代码点必须用两个码元(共 32 位)来表示。
String.fromCodePoint() 方法
当 ECMAScript 提供了某种方法时,它一般也会给出方法来处理相反的操作。你可以使用 codePointAt() 来提取字符串内中某个字符的代码点,也可以借助 String.fromCodePoint() 用给定的代码点来产生包含单个字符的字符串。例如:
console.log(String.fromCodePoint(134071)); // "𠮷"
可以将 String.fromCodePoint() 视为 String.fromCharCode() 的完善版本。 两者处理 BMP 字符时会返回相同结果,只有处理 BMP 范围之外的字符时才会有差异。
normalize() 方法
Unicode 另一个有趣之处是,不同的字符在排序或其它一些比较操作中可能会被认为是相同的。有两种方式可以定义这种关联性:第一种是规范相等性(canonical equivalence),意味着两个代码点序列在所有方面都被认为是可互换的。例如,两个字符的组合可以按规范等同于另一个字符。第二种关联性是兼容性(compatibility),两个兼容的代码点序列看起来有差别,但在特定条件下可互换使用。
由于这些关联性,文本内容在根本上相同的两个字符串就可以包含不同的代码点序列。例如,字符 "æ" 与双字符的字符串 "ae" 或许能互换使用,但它们并不严格相等,除非使用某种手段来标准化。
ES6 给字符串提供了 normalize() 方法,以支持 Unicode 标准形式。该方法接受单个可选的字符串参数,用于指示需要使用下列哪种 Unicode 标准形式:
-
Normalization Form Canonical Composition ("NFC"), which is the default
-
Normalization Form Canonical Decomposition ("NFD")
-
Normalization Form Compatibility Composition ("NFKC")
-
Normalization Form Compatibility Decomposition ("NFKD")
解释这四种形式的差异超出了本书的范围。只需记住,当比较字符串时,它们必须被标准化为同一种形式。例如:
var normalized = values.map(function(text) {
return text.normalize();
});
normalized.sort(function(first, second) {
if (first < second) {
return -1;
} else if (first === second) {
return 0;
} else {
return 1;
}
});
此代码将 values 数组中的字符串转换为一种标准形式,以便让转换后的数组可以被正确排序。你也可以在比较过程中调用 normalize() 来对原始数组进行排序。如下所示:
values.sort(function(first, second) {
var firstNormalized = first.normalize(),
secondNormalized = second.normalize();
if (firstNormalized < secondNormalized) {
return -1;
} else if (firstNormalized === secondNormalized) {
return 0;
} else {
return 1;
}
});
如果你之前从未担心过 Unicode 标准化方面的问题,那么可能暂时还不太会用到这个方法。然而若你曾经开发过国际化的应用,你就一定会发现 normalize() 方法非常有用。
新方法并不是 ES6 为 Unicode 字符串提供的唯一改进,它还新增了两个有用的语法要素。
正则表达式 u 标志
你可以使用正则表达式来完成字符串的很多通用操作。但要记住,正则表达式假定单个字符使用一个 16 位的码元来表示。为了解决这个问题,ES6 为正则表达式定义了用于处理 Unicode 的 u 标志。
u 标志如何运作
当一个正则表达式设置了 u 标志时,它的工作模式将切换到针对字符,而不是针对码元。这意味着正则表达式将不会被字符串中的代理对所混淆,而是会如预期那样工作。例如, 研究以下代码:
var text = "𠮷";
console.log(text.length); // 2
console.log(/^.$/.test(text)); // false
console.log(/^.$/u.test(text)); // true
正则表达式 /^.$/
会匹配只包含单个字符的任意输入字符串。当不使用 u 标志时,该正则表达式只匹配码元,所以不能匹配由两个码元表示的这个日文字符。启用 u 标志后,正则表达式就会比较字符而不是码元,所以这个日文字符就会被匹配到。
计算代码点数量
可惜的是,ES6 并没有添加方法用于判断一个字符串包含多少个代码点,但借助 u 标志,你就可以使用正则表达式来进行计算,如下所示:
function codePointLength(text) {
var result = text.match(/[\s\S]/gu);
return result ? result.length : 0;
}
console.log(codePointLength("abc")); // 3
console.log(codePointLength("𠮷bc")); // 3
此例调用了 match() 方法来检查 text 中的空白字符与非空白字符(使用 [\s\S] 以确保该模式能匹配换行符),所用的正则表达式启用了全局与 Unicode 特性。在匹配至少成功一次的情况下,result 变量会是包含匹配结果的数组,因此该数组的长度就是字符串中代码点的数量。在 Unicode 中,字符串 "abc" 与 "bc" 同样包含三个字符, 所以数组长度为 3。
虽然这种方法可用,但它并不快,尤其在操作长字符串时。你也可以使用字符串的迭代器( 详见第八章)来达到相同目的。一般来说,只要有可能就应尽量减少对代码点数量的计算。 |
判断是否支持u标志
既然 u 标志是一项语法变更,在不兼容 ES6 的 JS 引擎中试图使用它就会抛出语法错误。使用一个函数来判断是否支持 u 标志是最安全的方式,像这样:
function hasRegExpU() {
try {
var pattern = new RegExp(".", "u");
return true;
} catch (ex) {
return false;
}
}
此函数将 u 作为一个参数来调用 RegExp 构造器,该语法即使在旧版 JS 引擎中都是有效的,而构造器在 u 未被支持的情况下会抛出错误。
若你的代码仍然需要在旧版 JS 引擎中工作,那么在使用 u 标志时应当始终使用 RegExp 构造器。这会防止语法错误,并允许你有选择地检测并使用 u 标志,而不会导致执行被中断。 |