【译】《Understanding ECMAScript6》- 第一章-基础知识(二)

时间:2022-04-25
本文章向大家介绍【译】《Understanding ECMAScript6》- 第一章-基础知识(二),主要内容包括块绑定、Let声明、Let在循环中的妙用、let全局变量、常量声明、解构赋值、数组解构、混合解构、数字、isFinite()和isNaN()、parseInt()和parseFloat()、整型、一些新增的数学函数、总结、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

块绑定

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变量先后被varlet声明了两次。这是由于JavaScript不允许使用let重新定义同域的已存变量。但是允许在块级子域内使用let声明父域内的同名变量。如下:

var count = 30;
// Does not throw an error
if (condition) {
    let count = 40;
    // more code
}

上述代码的letif块级子域内声明了一个父域的同名变量,在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属性键命名的本地变量repeatsave被创建。这种写法可以令代码更加简洁。

解构赋值同样可以处理嵌套对象,如下:

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

上述代码中的customoptions内部嵌套对象的一个属性,解构赋值的左操作数内部的花括号可以获取到嵌套对象的属性。

语法

上文提到的解构赋值表达式如果不用varletconst赋值,会抛出语法错误:

// 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的第一、第二个元素值分别储存在本地变量firstColorsecondColor中。数组本身没有任何修改

与嵌套对象的解构赋值类似,处理嵌套数组的解构时只需在对应的位置使用额外的方括号即可,如下:

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的属性repeatsave,以及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提供了更深入的改善:引入了全新的八进制和二进制字面量表达式。灵感来自于十六进制的字面量表达式(以0x0X开头)。新的八进制字面量以0o0O开头,二进制字面量以0b0B开头。两种字面量的前缀后面必须有至少一个数字,八进制接受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^532^53范围内,超出这个“安全范围”以外的值使用边界值表示。如下:

console.log(Math.pow(2, 53));      // 9007199254740992
console.log(Math.pow(2, 53) + 1);  // 9007199254740992

上述代码中的两个运算结果都是JavaScript整型数的上边界值。任何超出“安全范围”的数值都会被修正为边界值。

ES6新增的Number.isSafeInteger()函数可以判断一个整型数字是否在安全范围内。另外,Number.MAX_SAFE_INTEGERNumber.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()均返回trueoutside超出了安全范围,即使它仍然是一个整型数字,但被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()方法在对比特殊数值时提供比===更佳的安全保障。

块级域绑定的letcoust变量只在被声明的块级域内有效,不会被声明提升。这种机制令JavaScript变量更加接近其他编程语言,并且减少了全局性的错误发生。随着这两种声明方式的广泛使用,var会逐渐淡出JavaScript的舞台。

ES6新增了一些函数和语法来改善数字的处理。你可以在源码中直接使用全新的二进制和八进制字面量。 Number.isFinite()Number.isNaN()比他们的同名全局变量更加安全。Number.isInteger()Number.isSafeInteger()可以更精确的识别整型数字,Math对象的新增函数可以令JavaScript的数学运算更加全面。

尽管本章提到的这些改进比较琐碎,但它们对JavaScript的发展有不可或缺的作用。有了这些功能的支撑,开发者可以集中精力在应用开发商,而不用在意底层的原理。