【译】《Understanding ECMAScript6》- 第一章-基础知识(一)
目录:
ES6在ES5的基础上做了大量的改动,有一些较大的改动涉及到新的数据类型和语法,也有一些对语言原有功能做的一些较小的改进。本章主要介绍这些细节的改动,这些改动在一定程度上令某些现存问题得到较好的改善。
更好的Unicode编码支持
在ES6之前,JavaScript字符串完全基于16-bit的编码思想。所有字符串的属性和方法,比如length
和charAt()
,都是建立在“每16-bit序列代表一个字符”的前提之下。ES5支持两种编码格式:UCS-2和UTF-16(两者均使用16-bit为编码单元(code units),得到的结果相同)。虽然至今为止所有的字符都可以用16位表示,但这种局面将不会维持太久了。
继续使用16位最为编码单元并不能实现Unicode的“为世界上所有字符提供全局唯一标识符”的目标。这些称为码点(code points)的全局唯一标识符是一个从0开始的数字(你或许将其理解为字符编码,但其实是有细微差别的)。字符编码的作用是将码点编码为内部一致的编码单元。UCS-2编码对码点和编码单元做一对一映射,UTF-16则有更多可能性。
UTF-16编码中,起始的2^16码点被表现为一个16-bit编码单元,这就是所谓的基本多语言面(Basic Multilingual Plane,简称BMP)。任何超出这个范围的码点都不能表现为16-bit,这些码点被认为处于补充平面(supplementary plane)中。为解决这个问题,UTF-16引入了代理编码对(surrogate pairs)的概念,用两个16-bit的编码单元表示一个码点。也就是说,字符串中的一个字符要么是一个16-bit编码单元(处于BMP中,占位16 bits),要么是两个编码单元(处于补充平面,占位32 bits)。
ES5规定字符串的所有操作遵循16-bit编码单元规则,所以如果你操作的字符包含代理编码对,将会得到意想不到的结果。如下:
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-bit编码单元。所以,其length
值为2,匹配单字符的正则表达式返回false
,charAt()
方法不能获取有效的字符。charCodeAt()
方法返回每个编码单元对应的的16-bit数字,这已经是你可以在ECMAScript 5中得到的最接近真实值的东西了。
ES6强制使用UTF-16编码。字符编码的标准化意味着JavaScript可以正确处理包含代理编码对的字符了。
codePointAt()函数
codePointAt()
函数完全支持UTF-16编码,可以哦你过来获取给定字符的Unicode码点。codePointAt()
函数接受码点位置(而不是字符位置)作为参数,并返回一个整数值:
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
对于BMP字符来说,codePointAt()
函数与charCodeAt()
函数的返回结果完全相同。上例中,text
的第一个字符𠮷
不是BMP字符,它由两个编码单元构成,也就是说,text
的length
是3而不是2。对于索引0,charCodeAt()
函数只获取到第一个编码单元,而codePointAt()
函数获取了组成第一个字符的所有编码单元。两个函数对索引1(第一个字符的第二个编码单元)和索引2(第二个字符a
)的运算结果相同。
利用codePointAt()
函数可以非常方便地判断给定字符到底映射为一个还是两个码点:
function is32Bit(c) {
return c.codePointAt(0) > 0xFFFF;
}
console.log(is32Bit("𠮷")); // true
console.log(is32Bit("a")); // false
大于号右侧的16-bit字符0xFFFF
代表十六进制的FFFF
,所以任何大于它的码点都由两个编码单元组成。
String.fromCodePoint()
ECMAScript在提供某种功能时,往往会同时提供与其相反的功能。比如你可以用codePointAt()
函数获取给定字符的码点,同时也可用String.fromCodePoint()
函数获取给定码点对应的字符。如下:
console.log(String.fromCodePoint(134071)); // "𠮷"
你可以简单的认为
String.fromCodePoint()
函数是增强版的String.fromCharCode()
。两者在处理BMP字符时的运算结果完全一致,区别在于对BMP范围外字符的处理。
用转义序列对Non-BMP字符编码
ES5允许由转义序列代表16-bit的字符。转义序列是由\u
与四个十六进制值组成。比如转义序列\u0061
代表字符a
:
console.log("\u0061"); // "a"
如果转义序列的十六进制值超过了FFFF
,它对应的字符就超出了BMP字符的上限,然后你会得到一些匪夷所思的结果:
console.log("\u20BB7"); // "₻7"
由于Unicode转义序列被严格定义为只能包含4个十六进制值,ECMAScript在处理\u20BB7
时将其视为两个字符:\u20BB
和7
。第一个字符是不可打印的,第二个字符时数字7.
为弥补上述缺陷,ES6引入了扩展Unicode转义序列。扩展转义序列包含在花括号内,可以接收任意个数(理论上不超过8个)的十六进制值来表示一个字符:
console.log("\u{20BB7}"); // "𠮷"
上例中,利用扩展转义序列获取到了正确的字符。
上述方法只能运行在支持ES6的环境下,其他环境会报语法错误。可以通过以下函数判断运行环境是否支持扩展转义字符:
function supportsExtendedEscape() {
try {
"\u{00FF1}";
return true;
} catch (ex) {
return false;
}
}
normalize()函数
Unicode另一个有趣的方面是,某些不同的字符在进行排序或某些基于对比的操作中可以被认为是等价的。有两种方式来定义这些关系。第一种,规范等价是指两个码点序列在所有方面都被认为是可互换的,甚至两个字符的组合也可以被规范等价为一个字符。第二种,兼容性是指两个码点序列虽然映射为明显不同的两个字符,但是在某些场景下可以互换。
由于这种关系的存在,两个完全不同的码点序列可能被映射为一个相同的字符串。比如,字符"æ"
和字符串"ae"
虽然由不同的码点序列组成,但两者在某些场景下可以互相取代。但是如果不将其标准化,这两个字符串在JavaScript中是完全不等的。
ES6支持字符串通过normalize()
函数进行Unicode标准化。normalize()
函数接收一个可选参数,代表Unicode标准化格式,参数值可选:NFC
(默认值)、NFD
、NFKC
和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;
}
});
另外需要注意的是,所有的字符串必须被标准化为同一种格式。上例中采用的默认值NFC
,你也可以使用其他格式:
values.sort(function(first, second) {
var firstNormalized = first.normalize("NFD"),
secondNormalized = second.normalize("NFD");
if (firstNormalized < secondNormalized) {
return -1;
} else if (firstNormalized === secondNormalized) {
return 0;
} else {
return 1;
}
});
如果你的工作中不涉及Unicode标准化问题,normalize()
函数并不能给你提供帮助。但是了解它的存在和作用,当你遇到相关问题时将会非常有用。
正则表达式的u标志
很多字符串操作需要正则表达式协助完成。前文提到,正则表达式也是在“每个字符由单个16-bit编码单元组成”的前提下工作,这也是前文例子中匹配单个字符的正则表达式无法匹配给定字符的原因。为解决这个问题,ES6在正则表达式中新增了u
标志来处理Unicode。
带有u
标志的正则表达式将基于字符匹配,而不是基于编码单元。这种模式下,匹配带有代理编码对的字符将会返回正确的预期结果。如下:
var text = "𠮷";
console.log(text.length); // 2
console.log(/^.$/.test(text)); // false
console.log(/^.$/u.test(text)); // true
上例中带有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
上例中的正则表达式全局匹配包括空格在内的所有字符,并且支持Unicode。result
是一个包含所有匹配结果的数组,它的length
也就是给定字符串的编码单元个数。
尽管上述方案可以解决需求,但执行效率并不高,尤其是对长字符串的处理。所以,请尽量减少编码单元个数的检测。希望ES7能够带给我们获取编码单元个数更加有效的方法。
因为u
标志的使用涉及语法的改变,所以在不兼容的JavaScript运行环境中会抛出语法错误。可以使用以下方法检测运行环境是否支持u
标志:
function hasRegExpU() {
try {
var pattern = new RegExp(".", "u");
return true;
} catch (ex) {
return false;
}
}
上述函数用RegExp
构造函数声明正则表达式,并且将u
作为参数传入。这种语法兼容低版本的JavaScript引擎,如果构造函数不支持u
,将会抛错。
如果你的代码运行在低版本的JavaScript引擎,建议用
RegExp
构造函数来探测u
标志的兼容性。这种方法可以有效的检测,并且能够避免因语法错误导致的执行中断。
Unicode标识符
ES6对Unicode的良好支持意味着当Unicode作为标识符声明变量时一些用法的改变。ES5已经允许Unicode转义序列作为标识符声明变量了,如下:
// Valid in ECMAScript 5 and 6
var \u0061 = "abc";
console.log(\u0061); // "abc"
// equivalent to
// console.log(a); // "abc"
ES6中,你也可以用Unicode编码单元的转义序列作为标识符:
// Valid in ECMAScript 5 and 6
var \u{61} = "abc";
console.log(\u{61}); // "abc"
// equivalent to
// console.log(a); // "abc"
另外,ES6的标识符声明语法遵循规范Unicode Standard Annex #31: Unicode Identifier and Pattern Syntax:
- 起始首字符必须是
$
、_
或者带有ID_Start
核心衍生属性的Unicode码; - 首字符以外的每个字符必须是
$
、_
、\u200c
(ZWNJ)、\u200d
(ZWJ)或者带有ID_Continue
核心衍生属性的Unicode码。
ID_Start
和ID_Continue
的核心衍生属性由Unicode Identifier and Pattern Syntax规定,以便Unicode标识符作为变量名和域名使用(此规范并不仅限于JavaScript)。
更多字符串相关改动
JavaScript对字符串处理的完备性完全不如其他编程语言。直到ES5才引入了trim()
方法,ES6在字符串处理上也扩展了很多新方法。
`includes()`,`startsWith()`,`endsWith()`
自JavaScript面世以来,开发者一直使用indexOf()
方法处理子字符串。ES6新增了三个处理子字符串的方法:
includes()
- 如果字符串中包含给定的子字符串,返回true
,否则返回false
;startsWith()
- 如果给定的子字符串位于字符串的起始位置,返回true
,否则返回false
;endsWith()
- 如果给定的子字符串位于字符串的末尾,返回true
,否则返回false
。
以上三个方法均可接受两个参数:待检索的子字符串(必选)以及待检索父字符串的起始检索位置(可选,默认为0)。如果传入了第二个参数,includes()
和startsWith()
方法将检索父字符串自给定位置之后的内容,而endsWith()
则将检索父字符串自检索位置之前的内容。也就是说,第二个参数缩小了父字符串的检索范围。如下:
var msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true
console.log(msg.endsWith("!")); // true
console.log(msg.includes("o")); // true
console.log(msg.startsWith("o")); // false
console.log(msg.endsWith("world!")); // true
console.log(msg.includes("x")); // false
console.log(msg.startsWith("o", 4)); // true
console.log(msg.endsWith("o", 8)); // true
console.log(msg.includes("o", 8)); // false
以上三个方法可以更有效的处理子字符串问题,而不必关心它们的具体索引位置。
以上三个方法均返回一个Boolean值,如果你的想要获取子字符串的索引位置,请使用
indexOf
或lastIndexOf()
。如果将正则表达式作为参数传入
includes()
、startsWith()
和endsWith()
将会报错,这点与indexOf
和lastIndexOf()
不同,后两者会将正则表达式转化为字符串后进行处理。
repeat()
ES6新增的repeat()
方法接受一个代表重复次数的参数n
,返回值是将给定字符串重复n次的新字符串。如下:
console.log("x".repeat(3)); // "xxx"
console.log("hello".repeat(2)); // "hellohello"
console.log("abc".repeat(4)); // "abcabcabcabc"
repeat()
方法在某些场景下非常高效,尤其是对文本的处理。一个很典型的例子,在代码格式化工具中处理缩进时,如下:
// indent using a specified number of spaces
var indent = " ".repeat(size),
indentLevel = 0;
// whenever you increase the indent
var newIndent = indent.repeat(++indentLevel);
更多正则表达式相关改动
正则表达式是处理字符串不可或缺的一环,然而在ES6之前的几个版本升级中并没有太大改变。ES6在提升字符串操作的同时,也对正则表达式进行了改进。
正则表达式的y标志
y
标志最先作为正则表达式的一个扩展属性被Firefox实现,随后ES6将其标准化。带有y
标(也称为粘性标志)志的正则表达式从lastIndex
属性指定的位置开始匹配,如果此位置没有正确匹配的字符,正则表达式将停止对后面内容的匹配。如下:
var text = "hello1 hello2 hello3",
pattern = /hello\d\s?/,
result = pattern.exec(text),
globalPattern = /hello\d\s?/g,
globalResult = globalPattern.exec(text),
stickyPattern = /hello\d\s?/y,
stickyResult = stickyPattern.exec(text);
console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello1 "
console.log(stickyResult[0]); // "hello1 "
pattern.lastIndex = 1;
globalPattern.lastIndex = 1;
stickyPattern.lastIndex = 1;
result = pattern.exec(text);
globalResult = globalPattern.exec(text);
stickyResult = stickyPattern.exec(text);
console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello2 "
console.log(stickyResult[0]); // Error! stickyResult is null
上例中的三个正则表达式一个带有y
标志,一个带有g
标志,另一个不包含任何标志。第一次进行的三次匹配都返回了相同的结果hello1
(请注意末尾的空格)。然后将三个正则表达式的lastIndex
属性都设置为1
,作用是令三者从字符串的第二字符开始匹配。不包含任何标志的表达式pattern
并未受影响,仍然匹配到了hello1
;带有g
标志的表达式globalPattern
匹配到了hello2
,因为它从第二字字符e
往左匹配一直到末尾;而带有y
标志的表达式stickyPattern
的匹配结果为null
,这是因为第二个字符e
不符合匹配内容,粘性正则表达式立即停止了后续内容的匹配。
与全局g
标志的规则一样,粘性标志y
在完成一次匹配之后,会将lastIndex
设置为本次匹配字符串最后一个字符的索引值加一。如果本次匹配无对应结果,lastIndex
值将被初始化为0
。如下:
var text = "hello1 hello2 hello3",
pattern = /hello\d\s?/,
result = pattern.exec(text),
globalPattern = /hello\d\s?/g,
globalResult = globalPattern.exec(text),
stickyPattern = /hello\d\s?/y,
stickyResult = stickyPattern.exec(text);
console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello1 "
console.log(stickyResult[0]); // "hello1 "
console.log(pattern.lastIndex); // 0
console.log(globalPattern.lastIndex); // 7
console.log(stickyPattern.lastIndex); // 7
result = pattern.exec(text);
globalResult = globalPattern.exec(text);
stickyResult = stickyPattern.exec(text);
console.log(result[0]); // "hello1 "
console.log(globalResult[0]); // "hello2 "
console.log(stickyResult[0]); // "hello2 "
console.log(pattern.lastIndex); // 0
console.log(globalPattern.lastIndex); // 14
console.log(stickyPattern.lastIndex); // 14
上例中,粘性正则和全局正则第一次exec()
匹配之后lastIndex
值变为7,第二次匹配后变为14。
粘性标志还有以下细节需要注意:
- 只有正则表达式自身的函数(比如
exec()
和test()
)才会对粘性正则表达式的lastIndex
产生影响。如果正则表达式作为字符串函数(比如match()
)的参数,粘性正则表达式的lastIndex
值不会受影响; - 如果用
^
匹配字符串的起始字符,粘性正则表达式会从字符串的起始字符,或者多行文本第一行的起始字符开始匹配。只要lastIndex
为0,粘性正则表达式和常规的正则表达式行为完全一致。但是如果lastIndex
不为0,粘性正则表达式将不会进行匹配。
同其他标志一样,你可以用一个属性判断正则表达式是否带有y
标志。如果有,sticky
将为true
,否则为false
。如下:
var pattern = /hello\d/y;
console.log(pattern.sticky); // true
sticky
为只读属性。
同上文提到的u
标志一样,y
标志涉及语法的改变,所以在低版本JavaScript引擎下会报错。你可以用于u
标志类似的方法进行容错处理:
function hasRegExpY() {
try {
var pattern = new RegExp(".", "y");
return true;
} catch (ex) {
return false;
}
}
用RegExp
生成正则表达式可以避免低版本JavaScript引擎的语法错误。
克隆正则表达式
ES5允许将正则表达式作为参数传入RegExp
,以此方法来克隆一个正则表达式,如下:
var re1 = /ab/i,
re2 = new RegExp(re1);
但是,如果设置RegExp
的第二个参数(代表正则表达式类型),ES5中将会报错:
var re1 = /ab/i,
// throws an error in ES5, okay in ES6
re2 = new RegExp(re1, "g");
ES5中,如果RegExp
第一个参数是正则表达式,设置第二个参数会报错。ES6改善了这种规则,第二个参数可以被设置,并且会覆盖掉第一个参数正则的其他标志。如下:
var re1 = /ab/i,
// throws an error in ES5, okay in ES6
re2 = new RegExp(re1, "g");
console.log(re1.toString()); // "/ab/i"
console.log(re2.toString()); // "/ab/g"
console.log(re1.test("ab")); // true
console.log(re2.test("ab")); // true
console.log(re1.test("AB")); // true
console.log(re2.test("AB")); // false
上例中,re1
带有大小写不敏感标志i
,re2
只带有全局标志g
。RegExp
构造函数克隆了re1
并且用g
覆盖了i
。如果不设置第二个参数,re2
将会和re1
带有相同的标志。
flags属性
ES5中,可以通过source
属性获取正则表达式的文本部分(即除标志以外的部分),但是要想获取标志部分就需要将正则表达式转化为字符串再做如下处理:
function getFlags(re) {
var text = re.toString();
return text.substring(text.lastIndexOf("/") + 1, text.length);
}
// toString() is "/ab/g"
var re = /ab/g;
console.log(getFlags(re)); // "g"
ES6在保留source
属性的同时,新增了flags
属性,两者都是原型链的只读属性。flags
属性可以令正则表达式的操作更加细致。
flags
属性以字符串的形式返回应用于正则表达式的所有标志。如下:
var re = /ab/g;
console.log(re.source); // "ab"
console.log(re.flags); // "g"
通过source
和flags
属性,可以直接提取正则表达式的任何碎片,而不必将正则表达式转换为字符串操作。
Object.is()
JavaScript开发者习惯于使用双等操作符==
或者严格相等操作符===
对两个值进行比较。大多数人倾向于使用后者以避免对比过程中的强制类型转换。然而,即使是严格相等操作符也并不是完全准确。比如+0
和-0
在JavaScript中是完全不同的两个值,但是用===
比较时会认为两者是相等的。另外,NaN === NaN
的运行结果是false
,这也是isNaN()
函数不可或缺的原因之一。
为弥补===
的缺陷,ES6新增了Object.is()
函数。它接收两个参数,如果两个参数是等价的就返回true
。这里的等价意味着对比双方的数据类型和值完全相等。大多数场景下,Object.is()
函数与===
的运算结果相同,唯一的区别是Object.is()
函数认为+0
和-0
是不等价的,并且NaN
等价于NaN
。如下:
console.log(+0 == -0); // true
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
console.log(NaN == NaN); // false
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
console.log(5 == 5); // true
console.log(5 == "5"); // true
console.log(5 === 5); // true
console.log(5 === "5"); // false
console.log(Object.is(5, 5)); // true
console.log(Object.is(5, "5")); // false
当然,==
和===
能够满足绝大多数的应用场景,如果有上述提到的两种特殊情况,Object.is()
函数会提高代码的逻辑性。