【译】《Understanding ECMAScript6》- 第一章-基础知识(二)
目录
块绑定
JavaScript中使用`var`进行变量声明的机制非常怪异。在大多数C系列的编程语言中,变量的创建是在被声明的时刻同时进行的。但是JavaScript并不是这样,使用`var`声明变量时,不论声明语句在什么位置,变量的创建都会被提升至函数作用域(或全局)的顶部。如下:function getValue(condition) {
if (condition) {
var value = "blue";
// other code
return value;
} else {
// 此处value可被访问,值为undefined
return null;
}
// 此处value可被访问,值为undefined
}
对于不熟悉JavaScript的开发者来说,期望的结果是在condition
为正值时value
变量才被声明创建。实际上,变量value
的创建与声明语句的位置并没有关系。JavaScript引擎会将上例中的方法解析为如下结构:
function getValue(condition) {
var value;
if (condition) {
value = "blue";
// other code
return value;
} else {
return null;
}
}
变量value
的声明创建被提升至函数作用域的顶部,其初始化赋值仍然停留在原位置。也就是说,在else
块内也可以访问value
变量,值为undefined
,因为未被初始化赋值。
JavaScript开发者往往需要很长时间去适应这种声明提升机制,并且很容易在这上面犯错误。为弥补这种缺陷,ES6引入了块级作用域的概念,使变量的生命周期更易控制。
Let声明
用let
声明变量的语法与var
相同,唯一不同的是,用let
声明的变量只在当然块级域内有效,举例如下:
function getValue(condition) {
if (condition) {
let value = "blue";
// other code
return value;
} else {
// value 在此处无法访问
return null;
}
// value 在此处无法访问
}
用let
声明变量的创建不会被提升至函数作用域顶部,其创建和初始化赋值是同时进行的,而且只在if
的块级域内有效,一旦if
块级域的逻辑执行完毕,value
变量就会被回收。如果condition
为非正值,变量value
将不会被创建和初始化。这种特性更加接近C系列编程语言。
开发者们或许更加期望在for
循环中引进块级作用域,比如以下代码:
for (var i=0; i < items.length; i++) {
process(items[i]);
}
//变量i在此处仍然可以被访问到,并且值为itemts.length
由于var
的声明提升机制,循环运行结束后仍然可以访问到变量i
。使用let
可以得到预期的结果:
for (let i=0; i < items.length; i++) {
process(items[i]);
}
// 变量i 在此处已经被回收
上例中,变量i
只在for
循环的块级域内有效,一旦循环运行结束,变量i
就会被回收,不会被其他域访问到。
Let在循环中的妙用
与常规块级域相比,let
变量在循环块级域内的使用有细微的差别。循环中的let
变量并不是被所有迭代运算共享的,而是为每次迭代运算创建一个专属变量。这主要是为了解决由JavaScript闭包引起的一个常见问题。举例如下:
var funcs = [];
for (var i=0; i < 10; i++) {
funcs.push(function() { console.log(i); });
}
funcs.forEach(function(func) {
func(); // 输出10次数字10
});
上述代码将连续十次输出数字10
。用var
声明的变量i
被所有迭代运算共享,也就是说每次迭代运算生成的函数域内都存在对变量i
的引用。循环运行结束后,变量i
的值为10
,也就是每个函数的输出值。
开发者通常使用IIFE(immediately-invoked function expressions,立即执行函数)来解决这种问题,在每次穿件函数时,将变量i
的值传入,在函数内部创建一个与变量i
值相等的局部变量:
var funcs = [];
for (var i=0; i < 10; i++) {
funcs.push((function(value) {
return function() {
console.log(value);
}
}(i)));
}
funcs.forEach(function(func) {
func(); // 输出0,1,2...9
});
变量i
作为IFFE的参数被传入,IFFE内部创建变量value
保留i
的值,变量value
只在本次迭代函数的内部有效,所以最后输出了预期的结果。
与IIFE繁琐的逻辑相比,使用let
声明变量更加简洁。循环的每次迭代运算都会产生一个与上次迭代中相同名称的新变量,并且根据上次迭代中同名变量的值,对新变量重新初始化赋值。有了这种机制的支持,你可以简单地将var
替换为let
即可:
var funcs = [];
for (let i=0; i < 10; i++) {
funcs.push(function() { console.log(i); });
}
funcs.forEach(function(func) {
func(); // 输出0,1,2...9
})
与IIFE相比,这种方案更加简洁有效。
由于let
不具备var
的声明提升特性,用let
声明的变量在声明语句之前是不可被访问的,否则会报引用错误,如下:
if (condition) {
console.log(value); // ReferenceError!
let value = "blue";
}
上述代码中,使用let
对变量value
进行声明并初始化赋值,但是由于前一行代码运行错误,导致声明语句无法执行。这种情况,我们通常称变量value
存在于TDZ(temporal dead zone,临时访问禁区)内。TDZ并未被任何规范命名,通常作为一种描述let
非声明提升特性的名词。
当JavaScript解析器对所有代码块进行预解析时,除了会导致var
变量的声明提升,还会导致let
变量进入TDZ。任何企图访问TDZ内部变量的操作都会导致运行错误。只有等待声明语句被执行后,let
变量才会离开TDZ,这时可以被访问。
即使在let
变量的同一个块级域内,任何在声明语句之前对let
变量的操作都会出错,包括typeof
:
if (condition) {
console.log(typeof value); // ReferenceError!
let value = "blue";
}
上述代码的typeof value
抛出引用错误,因为此操作是在let
变量value
的同一个块级域内,并且在let
声明之前。如果typeof
操作在let
变量的块级域以外就不会报错,如下:
console.log(typeof value); // "undefined"
if (condition) {
let value = "blue";
}
上述代码中的value
不在TDZ内,因为typeof
操作发生在let
变量value
的块级域之外,实际上是访问的typeof
作用域或者其父作用域内的value
变量,此value
变量没有块作用域绑定,因此typeof
的操作返回undefined
。
如果块级域内声明了一个变量,在同一块级域内使用let
声明同名变量会抛出语法错误。如下:
var count = 30;
// Syntax error
let count = 40;
count
变量先后被var
和let
声明了两次。这是由于JavaScript不允许使用let
重新定义同域的已存变量。但是允许在块级子域内使用let
声明父域内的同名变量。如下:
var count = 30;
// Does not throw an error
if (condition) {
let count = 40;
// more code
}
上述代码的let
在if
块级子域内声明了一个父域的同名变量,在if块级域内,此变量会屏蔽父域的同名变量。
let全局变量
使用let
进行全局变量声明有可能造成命名冲突,这是由于全局域内存在一些预定义的变量和属性。某些全局变量和属性是不可配置(nonconfigurable )的,如果使用let
声明一个与不可配置全局变量同名的变量时,将会抛出错误。由于JavaScript不允许let
重新定义同域内的已存变量,使用let
并不能屏蔽不可配置的全局变量。如下:
let RegExp = "Hello!"; // ok
let undefined = "Hello!"; // throws error
第一行代码重新定义了全局变量RegExp
,虽然这是很危险的操作,但是并未报错。第二行对undefined
的重定义操作会报错,因为undefined
是不可配置的全局函数,被锁定不允许重定义,所以此处的let
声明是非法的。
译者注:可能你会疑惑上节中提到的,使用
var
声明的变量被let
重定义时报错,但是第一行对RegExp
的重定义未报错。这是因为使用var
声明的变量在它的作用域内是不可配置的。
我们并不推荐使用let
进行全局变量的声明,如果你有这种需求,在声明变量之前,请注意上述的问题。
let
的诞生便是为了取代var
,它使JavaScript中变量声明更加接近其他编程语言。如果你的JavaScript应用程序只运行在ES6兼容环境,你应该考虑尽量使用let
。
因为let
变量不会被声明提升至函数作用域的顶部,如果想在整个函数作用域内使用let
变量,你应该在函数的起始位置声明它。
常量声明
ES6新增了const
变量声明语法,使用const
声明的变量被称为常量。常量一旦被赋值就不能被修改,因此,常量在声明的同时必须被赋值。如下:
// Valid constant
const MAX_ITEMS = 30;
// Syntax error: missing initialization
const NAME;
与let
一样,const
常量也是块级域范畴。也就是说,一旦常量会在所在的块级域逻辑执行完毕后被回收。常量同样不会被声明提升。
if (condition) {
const MAX_ITEMS = 5;
// more code
}
// MAX_ITEMS isn't accessible here
与let
不同的是,无论是严格模式或者非严格模式,const
常量一旦被声明赋值,任何对它进行再赋值的操作都会报错。如下:
const MAX_ITEMS = 5;
MAX_ITEMS = 6; // throws error
目前许多浏览器对ES6新增的const
声明有不同程度的实现,实现程度较高的也只能允许在全局域和函数域范畴进行常量声明。所以,现阶段生产环境中使用常量声明要非常谨慎。
解构赋值
JavaScript开发者在获取对象或数组中的数据时往往需要很繁琐的处理,如下:
var options = {
repeat: true,
save: false
};
// later
var localRepeat = options.repeat,
localSave = options.save;
为了代码的简洁和易操作性,我们通常将对象的属性储存在本地变量中。ES6新增的解构赋值机制可以更加系统地处理这种需求。
需要注意的是,解构赋值的右操作数如果是null
或者undefined
,会抛出错误。
Object解构
Object的解构赋值语法如下,赋值操作符的左操作数是以Object字面量格式(key-value,键值对)声明:
var options = {
repeat: true,
save: false
};
// later
var { repeat: localRepeat, save: localSave } = options;
console.log(localRepeat); // true
console.log(localSave); // false
上述代码的运算结果是将options.repeat
属性值储存在本地变量localRepeat
中,options.save
属性值储存在本地变量localSave
中。其中,左操作数以Object字面量格式表示,key
代表options
中的属性键,value
代表储存options
属性值的本地变量名称。
如果options
中没有key指定的属性,那么对应的本地变量将被赋值为undefined
。
如果左操作数的value
省略不写,options
的属性键名称将作为本地变量的名称,如下:
var options = {
repeat: true,
save: false
};
// later
var { repeat, save } = options;
console.log(repeat); // true
console.log(save); // false
上述代码运行结束后,两个以options
属性键命名的本地变量repeat
和save
被创建。这种写法可以令代码更加简洁。
解构赋值同样可以处理嵌套对象,如下:
var options = {
repeat: true,
save: false,
rules: {
custom: 10,
}
};
// later
var { repeat, save, rules: { custom }} = options;
console.log(repeat); // true
console.log(save); // false
console.log(custom); // 10
上述代码中的custom
是options
内部嵌套对象的一个属性,解构赋值的左操作数内部的花括号可以获取到嵌套对象的属性。
语法
上文提到的解构赋值表达式如果不用var
、let
或const
赋值,会抛出语法错误:
// syntax error
{ repeat, save, rules: { custom }} = options;
花括号通常用来生成一个代码块,而代码块是不能作为赋值表达式的操作数的。
为解决这种错误,可以将整个解构赋值表达式包含在一对括号中:
// no syntax error
({ repeat, save, rules: { custom }} = options);
这样代码可以正常运行。
数组解构
数组的解构赋值与对象类似,左操作数以数组的字面量格式声明,如下:
var colors = [ "red", "green", "blue" ];
// later
var [ firstColor, secondColor ] = colors;
console.log(firstColor); // "red"
console.log(secondColor); // "green"
上述代码将数组colors
的第一、第二个元素值分别储存在本地变量firstColor
和secondColor
中。数组本身没有任何修改。
与嵌套对象的解构赋值类似,处理嵌套数组的解构时只需在对应的位置使用额外的方括号即可,如下:
var colors = [ "red", [ "green", "lightgreen" ], "blue" ];
// later
var [ firstColor, [ secondColor ] ] = colors;
console.log(firstColor); // "red"
console.log(secondColor); // "green"
上述代码将colors
内嵌套数组的第一个元素值green
赋值给本地变量secondColor
。
混合解构
对于混合嵌套数据的处理,可以使用对象字面量和数组字面量混合的语法,如下:
var options = {
repeat: true,
save: false,
colors: [ "red", "green", "blue" ]
};
var { repeat, save, colors: [ firstColor, secondColor ]} = options;
console.log(repeat); // true
console.log(save); // false
console.log(firstColor); // "red"
console.log(secondColor); // "green"
上述代码提取了对象options
的属性repeat
和save
,以及colors
数组的前两个元素。提取整个colors
数组的语法更简单:
var options = {
repeat: true,
save: false,
colors: [ "red", "green", "blue" ]
};
var { repeat, save, colors } = options;
console.log(repeat); // true
console.log(save); // false
console.log(colors); // "red,green,blue"
console.log(colors === options.colors); // true
上述代码将options.colors
数组整体提取出来并且储存在本地变量colors
中。需要注意的是,colors
数组是options.colors
的引用而不是复制。
混合解构在解析JSON配置文件时非常有用。
数字
JavaScript中的数字采用IEEE 754规范的双精度浮点数格式,但并不区分整型和浮点型,导致对数字的处理过程非常复杂。作为JavaScript基本类型(其余两种是string和boolean)之一,数字在开发中占据相当大的比重。为了提升JavaScript在游戏和图形处理方面的表现,ES6在数字处理方面投入了很多精力。
八进制和二进制
为了解决处理数字时的易犯错误,ES5从parseInt()
和严格模式中移除了对八进制字面量的支持。在ES3及其之前的版本中,八进制数字是由0
开头的一串数字。如下:
// ECMAScript 3
var number = 071; // 十进制57
var value1 = parseInt("71"); // 71
var value2 = parseInt("071"); // 57
八进制数字的表示方式令很多开发者产生困惑,人们经常会误解开头0
的作用。parseInt()
函数会将以0
开头的数字默认为是八进制而不是十进制。这与Douglas Crockford制定的JSLint规范产生冲突:parseInt()
函数应该始终根据第二个参数规定的类型对string进行解析。
译者注:Douglas Crockford是Web开发领域最知名的技术权威之一,ECMA JavaScript2.0标准化委员会委员,JSON、JSLint、JSMin和ADSafe的创造者。被JavaScript之父Brendan Eich称为JavaScript的大宗师(Yoda)。
ES5通过修复了这个问题。首先,如果第二个参数未被传入,parseInt()
函数将忽略起始的0
,避免了常规数字被误认为是八进制。其次,严格模式下禁止八进制字面量。如果使用八进制字面量表达式,将会抛出语法错误:
// ECMAScript 5
var number = 071; // 十进制57
var value1 = parseInt("71"); // 71
var value2 = parseInt("071"); // 71
var value3 = parseInt("071", 8); // 57
function getValue() {
"use strict";
return 071; // syntax error
}
通过以上两种方案,ES5修复了大量与八进制字面量相关的问题。
ES6提供了更深入的改善:引入了全新的八进制和二进制字面量表达式。灵感来自于十六进制的字面量表达式(以0x
或0X
开头)。新的八进制字面量以0o
或0O
开头,二进制字面量以0b
或0B
开头。两种字面量的前缀后面必须有至少一个数字,八进制接受0-7,二进制接受0-1。如下:
// ECMAScript 6
var value1 = 0o71; // 十进制57
var value2 = 0b101; // 十进制5
新增的两种字面量表达式使开发者可以更快速简捷地处理二进制、八进制、十进制和十六进制数字,使不同进制数字的数学运算更加精确。
parseInt()
函数仍然不支持新增的八进制和二进制字面量:
console.log(parseInt("0o71")); // 0
console.log(parseInt("0b101")); // 0
因此ES6引入了Number()
方法,提供对以上两种字面量的支持:
console.log(Number("0o71")); // 57
console.log(Number("0b101")); // 5
在string中使用八进制和二进制字面量时,务必谨慎处理使用场景,并且使用合适的函数来转化它们。
isFinite()和isNaN()
JavaScript提供了很多全局方法用来获取数字的某些特征:
isFinite()
检测一个值是否是有限数isNaN()
检测一个值是不是数字类型(NaN是唯一一个不等于自身的数据)
这两个函数并不会对传入参数的类型过滤,即使传入非数字类型的参数也不会报错,当然运行结果是错误的,如下:
console.log(isFinite(25)); // true
console.log(isFinite("25")); // true
console.log(isNaN(NaN)); // true
console.log(isNaN("NaN")); // true
isFinite()
和isNan()
首先将接收到的参数传给Number()
,Number()
函数将原始参数处理成数字类型后返回给isFinite()
和isNan()
,然后两者对返回的数字进行处理。这种机制下,如果在使用上述两个函数之前不对参数进行类型检测,可能会使应用程序产生错误的运行结果。
ES6新增了两个功能与isFinite()
和isNan()
功能相同的函数:Number.isFinite()
和Number.isNaN()
。这两个函数只接受数字类型的参数,对于非数字类型的参数会返回false
。如下:
console.log(isFinite(25)); // true
console.log(isFinite("25")); // true
console.log(Number.isFinite(25)); // true
console.log(Number.isFinite("25")); // false
console.log(isNaN(NaN)); // true
console.log(isNaN("NaN")); // true
console.log(Number.isNaN(NaN)); // true
console.log(Number.isNaN("NaN")); // false
比较上述代码中的两种函数的运行结果可知,对于非数字类型参数的处理,这种函数得到的结果全然不同。
Number.isFinite()
和Number.isNaN()
避免了很多由isFinite()
和isNan()
处理数字类型时产生的错误。
parseInt()和parseFloat()
ES6新增的Number.parseInt()
和Number.parseFloat()
函数对应原有的两个全局函数parseInt()
和parseFloat()
。新增的两种函数与原有的parseInt(
)和parseFloat()
作用完全一样。新增函数的目的是令JavaScript中的函数分类更加精确,Number.parseInt()
和Number.parseFloat()
很明显的提示开发者两者是跟数字处理有关的。
整型
JavaScript语言并不区分整型和浮点型数字,这种机制的初衷是为了令开发者不用关心细节问题,从而使开发过程更加简洁。但是随着时间的积累以及JavaScript语言被使用的场景越来越多,这种单类型数字机制导致了很多问题。ES6试图通过将整型数字的处理精细化来解决这种问题。
识别整型数字
新增的Number.isInterger()
函数可以判别一个数字是否为整型。JavaScript引擎根据整型与浮点型底层储存不同的原理进行判断。需要注意的是,即使一个看起来像浮点型的数字,使用Number.isInterger()
判断时也可能被认为是整型并且返回true
,如下:
console.log(Number.isInteger(25)); // true
console.log(Number.isInteger(25.0)); // true
console.log(Number.isInteger(25.1)); // false
上述代码中Number.isInterger()
处理25和25.0时都返回true
,即使25.0开起来像一个浮点型数字。JavaScript中,如果只是添加一个小数点,并不会令整型数字转化为浮点型。25.0等价于25,被储存为整型数字。而25.1的小数位不为0,所以被储存为浮点型。
安全整型
JavaScript的整型数字被限定在-2^53
和2^53
范围内,超出这个“安全范围”以外的值使用边界值表示。如下:
console.log(Math.pow(2, 53)); // 9007199254740992
console.log(Math.pow(2, 53) + 1); // 9007199254740992
上述代码中的两个运算结果都是JavaScript整型数的上边界值。任何超出“安全范围”的数值都会被修正为边界值。
ES6新增的Number.isSafeInteger()
函数可以判断一个整型数字是否在安全范围内。另外,Number.MAX_SAFE_INTEGER
和Number.MIN_SAFE_INTEGER
分别代表安全范围的上下边界值。Number.isSafeInteger()
函数处理一个在安全范围以内的整型数字时返回true
,否则返回false
。如下:
var inside = Number.MAX_SAFE_INTEGER,
outside = inside + 1;
console.log(Number.isInteger(inside)); // true
console.log(Number.isSafeInteger(inside)); // true
console.log(Number.isInteger(outside)); // true
console.log(Number.isSafeInteger(outside)); // false
译者注:
Number.isSafeInteger()
的参数如果不是数字类型,也将返回false
上述代码中的inside
取值安全范围的上边界值,Number.isInteger()
和Number.isSafeInteger()
均返回true
。outside
超出了安全范围,即使它仍然是一个整型数字,但被Number.isSafeInteger()
函数认为是“不安全的”。
通常情况下,整型数字的运算应该只针对“安全”的数值,使用Number.isSafeInteger()
函数对输入值进行规范验证是很有必要的。
一些新增的数学函数
前文提到的对JavaScript在游戏和图形处理方面的提升,相比较代码的实现,将很多数学运算交由JavaScript引擎处理可以很大程度地改善性能。一些JavaScript子集的优化策略(如asm.js),往往需要开发者对深层次知识有深入的了解。比如,在处理数学运算之前要确定数字是32位整型还是64位浮点型。
ES6对Math
对象进行了扩展,新增了许多新的数学函数。这些新函数可以一定程度上提升数学运算的效率,特别是对于严重依赖数学元素的应用程序(如图形处理)有很大帮助。参照以下表格:
函数名 | 描述 |
Math.acosh(x) | 返回x的反双曲余弦函数 |
Math.asinh(x) | 返回x的反双曲正弦函数 |
Math.acosh(x) | 返回x的双曲正切函数 |
Math.atanh(x) | 返回x的反双曲余弦函数 |
Math.cbrt(x) | 返回x的立方根 |
Math.clz32(x) | 返回x对应的32位整型数字的前导零位 |
Math.cosh(x) | 返回x的双曲余弦函数 |
Math.expm1(x) | 返回无理数e的x方减1,即e^x-1 |
Math.fround(x) | 返回最接近x的单精度浮点数 |
Math.hypot(...values) | 返回所有参数平方之和的平方根 |
Math.imul(x, y) | 返回x,y的32位乘法运算结果 |
Math.log1p(x) | 返回以x为真数的自然对数 |
Math.log10(x) | 返回以x为真数,10为底数的自然对数 |
Math.log2(x) | 返回以x为真数,2为底数的自然对数 |
Math.sign(x) | 如果x为负数则返回-1,如果x为+0或-0则返回0,如果x为整数则返回1 |
Math.sinh(x) | 返回x的双曲正弦函数 |
Math.tanh(x) | 返回x的双曲正切函数 |
Math.trunc(x) | 去掉浮点数x的小数位并返回修正后的整型数字 |
每种函数的详细作用不在本书的讨论范畴内,感兴趣的读者可自行查询相关资料。
总结
ES6对JavaScript语言进行了许多改进,有些比较明显,有些则偏重细节。本章提到的一些细节的改动可能会被很多人忽视,但是这些细节与一些较大的改动一样,在JavaScript的演变过程中有着不可磨灭的作用。
全面的Unicode支持可以使JavaScript用更加符合逻辑的方法处理UTF-16。codePointAt()
和String.fromCodePoint()
函数转化码点和字符的能力可以令字符串的操作更加精确。正则表达式的u
标识令正则匹配精确到码点而不在局限于16位字符。normalize()
函数可以使字符串的比较精确到码点级别。
新增的字符串操作函数可以更加精确的获取子字符串,正则表达式的改进提供了更好的功能化方法。Object.is()
方法在对比特殊数值时提供比===
更佳的安全保障。
块级域绑定的let
和coust
变量只在被声明的块级域内有效,不会被声明提升。这种机制令JavaScript变量更加接近其他编程语言,并且减少了全局性的错误发生。随着这两种声明方式的广泛使用,var
会逐渐淡出JavaScript的舞台。
ES6新增了一些函数和语法来改善数字的处理。你可以在源码中直接使用全新的二进制和八进制字面量。 Number.isFinite()
和Number.isNaN()
比他们的同名全局变量更加安全。Number.isInteger()
和Number.isSafeInteger()
可以更精确的识别整型数字,Math对象的新增函数可以令JavaScript的数学运算更加全面。
尽管本章提到的这些改进比较琐碎,但它们对JavaScript的发展有不可或缺的作用。有了这些功能的支撑,开发者可以集中精力在应用开发商,而不用在意底层的原理。