ES6中的变量和作用域

发布于 大漠

这篇文章主要探讨和学习如何在ES6中处理变量和作用域。

通过let和const决定块作用域

letconst创建的变量只在块作用域中有效。它们只存于包含它们的块中。下面演示的代码,通过letif语句块中声明一个tmp变量。这个变量仅在if语句中有效。

function func() {
    if (true) {
        let tmp = 123;
        console.log(tmp); // => 123
    }
    console.log(tmp);     // => ReferenceError: tmp is not defined
}

相比之下,var声明的变量作用域的范围是函数范围内的:

function func() {
    console.log(tmp);      // => undefined
    if (true) {
        var tmp = 123;
        console.log(tmp);  // => 123
    }
    console.log(tmp);      // => 123
}
console.log(tmp);          // => ReferenceError: tmp is not defined

块作用域意味着你可在有函数内有变量的阴影。

function func() {
    let foo = 5;
    console.log(foo);       // => 5
    if (true) {
        let foo = 10;
        console.log(foo);   // => 10
    }
    console.log(foo);       // => 5
}

const创建不可变的变量

let创建的变量是可变的:

let foo = 'abc';
foo = 'def';
console.log(foo); // => def

const创建的是变量是一个常量,这个变量是不可变的:

const foo = 'abc';
foo = 'def';
console.log(foo);  // => TypeError: Assignment to constant variable.

请注意,如果一个常量指的是一个对象,那么const并不影响常量本身的值是否是可变的,因为它总是指向那个对象,但是对象本身仍然是可以被改变的。

const obj = {};
obj.prop = 123;
console.log(obj.prop); // => 123
console.log(obj);      // => {prop: 123}
obj = {}               // => TypeError: Assignment to constant variable.

如果你想让obj真正成为一个常量,你必须冻结它的值

const obj = Object.freeze({});
obj.prop = 123;

也就是说,如果const定义的常指向的是一个对象。这个时候,它实际上指向的是当前对象的地址。这个地址是在栈里面的,而这个真实的对象是在堆栈里面的。所以,我们使用const定义这个对象后,是可以改变对象的内容的。但是这个地址是不可以改变的。意思也就是不可以给这个对象重新赋值,比如const obj= {}, obj = {},即使是这样,obj好像什么也没有改变,但还是错误的。然而在普通模式下,并没有报错,而obj.name = 'abc'这是完全可以的。这跟JavaScript存储引用对象的值的方式有密切的关系。

const obj = Object.freeze({});
const newObj = {};

obj.name = 'w3cplus';
newObj.name = 'damo';

使用Babel把上面ES6的代码编译成ES5代码:

'use strict';

var obj = Object.freeze({});
var newObj = {};

obj.name = 'w3cplus';
newObj.name = 'damo';

事实上不管我们是以const声明freeze object或者是不freeze,Babel都会把它转换成var。但我们把编译后代码执行之后,会报错TypeError: Cannot add property name, object is not extensible

循环体内的const

一旦创建了const变量,就不能更改它。但这并不意味着你不能在作用域内重新给它新的值。例如:

function logArgs(...args) {
    for (let [index, elem] of args.entries()) {
        const message = index + '.' + elem;
        console.log(message);
    }
}
logArgs('Hello', 'everyone');
// => 0.Hello
// => 1.everyone

上面的示例演示了循环体通过const声明的变量message,还是取出不同的值。

什么时候使用let,什么时候使用const

如果你想改变变量的原始值,则不能使用const

const foo = 1;
foo++; // => TypeError: Assignment to constant variable.

然而,你可以使用const变量来引用一些可变的东西:

const bar = [];
bar.pus('abc'); // => bar是可变的

我还在考虑最好的样式是什么,但我现在使用的是像前面的示例,用的是let,因为bar指的是可变的东西。如果确定使用const来表示的变量,那么这个变量和其值都是不可变的:

const EMPTY_ARRAY = Object.freeze([]);

时间死区(TDZ)

let或者const声明的变量都有一个所谓的时间死区(TDZ):当进入它的作用域时,它不能被访问,直到执行到达声明为止。

让我们先来看看var变量的生命周期,它没有时间死区:

  • 当进入var变量的作用域(比如在一个函数内)时,就会为它创建存储空间(所谓的绑定)。通过将变量设置为undefined,可以立即初始化该变量。
  • 当范围内的执行到达声明时,变量被设置为初始化器指定的值(如果有赋值)。如果没有,变量的值仍然是undefined

通过let声明的变量有时间死区,这意味着它们的生命周期是这样的:

  • 当进入let变量的作用域(比如一个块内)时,就会为它创建存储空间(所谓的绑定)。但这个变量仍然是一个未初始化的变量
  • 获取或设置未初始化会导致引用错误ReferenceError
  • 当范围内的执行到达声明时,变量被设置为初始化器指定的值(如果有赋值)。如果没有,变量的值将被设置为undefined

const声明变量的工作方式类似于let声明变量,但它闪必须有一个初始化器(即,立即设置为一个值),不能更改。

在TDZ中,如果有一个变量,则抛出一个异常:

if (true) { // 进入新的作用域,TDZ开始
    // 创建`tmp`的未初始化绑定
    tmp = 'abc'; // ReferenceError: tmp is not defined
    console.log(tmp); // => ReferenceError: tmp is not defined

    let tmp; // TDZ结束,`tmp`初始化为`undefined`
    console.log(tmp); // => undefined

    tmp = 123;
    console.log(tmp); // => 123
}

下面的示例演示了死区实际上是时间(基于时间)而不是空间(基于位置):

if (true) { // 进入新的作用域, TDZ开始

    const func = function () {
        console.log(myVar); // => 3
    }

    // 这里我们在TDZ中
    // 访问`myVar`导至一个参考错误

    let myVar = 3; // TDZ 结束

    func(); // TDZ之外

}

typeof 和 TDZ

一个变量在时间死区是无法访问的,这意味着你甚至不能使用typeof来判断它的类型:

if (true) {
    console.log(typeof tmp); // => ReferenceError: tmp is not defined
    let tmp = 'abc';
}

我并不认为这在实践中是一个问题,因为你不能有条件地将let声明的变量添加到作用域中。与此相反,你可以使用var像这样做来声明变量,比如分配一个window属性将创建一个全局变量:

if (typeof myVar === 'undefined') {
    // `myVar` 没有退出 => 创建它
    window.myVar = 'abc';
}

循环头里的let

在循环中,如果你将使用let声明一个变量,则为每次迭代获得一个新的绑定。允许你在forfor-infor-of中这样做:

let arr = [];
for (let i = 0; i < 3; i++){
    arr.push(() => i);
}
console.log(arr.map(x => x())); // =>  [0, 1, 2]

上面的代码转换成ES5:

var arr = [];

var _loop = function _loop(i) {
    arr.push(function () {
        return i;
    });
};

for (var i = 0; i < 3; i++) {
    _loop(i);
}
console.log(arr.map(function (x) {
    return x();
}));

相反,var声明会导致整个循环的单个绑定(const声明的工作原理相同):

let arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(() => i);
}
console.log(arr.map(x => x())); // =>  [3, 3, 3]

这个编译出来的ES5代码:

var arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(function () {
        return i;
    });
}
console.log(arr.map(function (x) {
    return x();
}));

首先,为每次迭代获得一个新的绑定看起来很奇怪,但是当你使用循环来创建函数(比如事件处理的回调时),它就显得非常有用。

参数

参数 vs 局部变量

如果你使用let声明一个和参数同名的变量,则会得到一个静态(加载时)错误:

function func(arg) {
    let arg; // => SyntaxError: Identifier 'arg' has already been declared
}

在块中做同样的事情:

function func(arg) {
    if (true) {
        let arg; // => undefined
    }
}

与此相反,var声明一个与参数相同的变量什么都不做,就像在同一范围内重新声明一个var变量,什么也不做:

function func(arg) {
    var arg; // => undefined
}

function func(arg) {
    if (true) {
        var arg; // => undefined
    }
}

参数默认值和TDZ

如果参数具有默认值,那么它们就被视为一个序列的let语句,并受到TDZ的影响:

// ES6
// `y`在声明后访问`x`
function foo(x = 1, y = x) {
    return [x, y];
}
foo(); // => [1, 1]

// ES5
function foo() {
    var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
    var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x;

    return [x, y];
}
foo(); // =>  [1, 1]

// ES6
// `x`试图在TDZ中访问`y`
function bar(x = y, y = 2) {
    return [x, y];
}
bar(); // => ReferenceError: y is not defined

// ES5
function bar() {
    var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : y;
    var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 2;

    return [x, y];
}
bar(); // => [undefined, 2]

参数默认值不在作用域主体内

参数默认值的范围与主体的范围是分开的(前者包围后者)。这意味着,定义“inside”参数默认值的方法或函数没有看到主体的局部变量:

// ES6
let foo = 'outer';
function bar (func = x => foo) {
    let foo = 'inner';
    console.log(func()); // => outer
}
bar();

全局对象

JavaScript的全局对象(Web浏览器中指的是window,Node.js中指的是global),与其说是一个特性,还不如说是一个bug,特别是在性能方面。这就是为什么ES6引入了一个区别(Distinction),而不会让人感到有任何奇怪的原因。

  • 全局对象的所有属性都是全局变量。在全局作用域内,使用varFunction声明的属性都是一个全局变量
  • 但现在使用letconstClass在全局作用域下声明的变量是全局变量但不是全局对象

函数声明和类声明

函数声明:

  • let一样是一个块作用域
  • var在全局作用域下创建全局对象
  • 被提升:独立于其声明的函数作用域内,它总是在作用域的开始创建

下面的代码演示了函数声明的提升:

{ // Enter a new scope
    console.log(foo());
    function foo() {
        return 'hello';
    }
}

类声明:

  • 是一个块作用域
  • 不能创建全局对象
  • 不能提升

类没有提升可能会令人惊讶,因为在引擎下,它们会创建函数。这种行为的基本原理是,它们是通过表达式定义来扩展子句的值,而这些表达式必须在适当的时候执行。

{ // Enter a new scope
    const identity = x => x;

    // Here we are in the temporal dead zone of `MyClass`
    let inst = new MyClass(); // ReferenceError

    // Note the expression in the `extends` clause
    class MyClass extends identity(Object) {
        
    }

}

模拟块作用域

在JavaScript中开发人员期望变量被限定在特定的块中(比如forif),但是在使用var声明的变量,则作用域是靠近最近的父函数。

首先,让我们来看看这是如何出错的。

var avatar = 'Ang';
var element = 'Air';

var elements = ['Air', 'Earth', 'Fire', 'Water'];

for (var i = 0; i < elements.length; i++) {
    var element = elements[i];
    console.log(avatar + ' has mastered ' + element);
}

console.log(avatar + "'s primary element is " + element);

使用块作用域语言的开发人员可能不会看到上面的代码有任何问题,并且期望Ang's primary element is Air替代实际的结果。

一旦你意识到这个问题,这个问题很容易就可以避免。在块内避免变量声明往往可以避免任何混淆。

但是假设我们真的想用JavaScript来使用块作用域。我们可能会这样做:

var avatar = "Ang"; var element = "Air";

var elements = [
    "Air",
    "Earth",
    "Fire",
    "Water"
];

for (var i = 0; i < elements.length; i++) {
    (function() {
        var element = elements[i];
        console.log(avatar + " has mastered " + element);
    })();
}

console.log(avatar + "'s primary element is " + element);

这个解决方案使用一个IIFE来模拟块作用域。由于函数是JavaScript的作用域(Scoping)机制,因此我们定义并立即在每个循环中调用一个新函数,因此近似于块作用域的行为。

而在ES6中,在循环体中使用let来声明变量,就不会有前面所说的问题存在了。

扩展阅读

特别声明:上述内容主要来源于Variables and scoping in ECMAScript 6Emulating Block Scope in JavaScript两篇文章。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/es6-scoping.htmlAfterpay