深入浅出javascript (3)—— let 和 const 以及 TDZ

991 阅读10分钟

深入浅出javascript (1)—— 变量提升中我们知道使用var 声明变量经常会有意想不到的效果,因此在ES6中引入了块级作用域以及 let、const 关键字来规避这种情况。今天我们就来说说。

作用域

想要讲明白 JavaScript 的变量提升这个特性,我们需要先从作用域讲起。

作用域是一块在程序中定义变量的区域,该区域决定了变量的生命周期。换句话说,作用域就是变量与函数的可访问范围,它控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

而其他语言基本都支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

例如下面的代码都是块级作用域:

//ifif(1){} 
//whilewhile(1){} 
//函数块 
function foo(){} 
//for循环块 
for(let i = 0; i<100; i++){} 
//单独一个块 
{}

对于块级作用域最重要的就是其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。 JavaScript 语言设计之初并没有引入块级作用域的概念,于是把作用域内部的变量统一提升无疑是最快速、最简单的设计,所以才有了 var 的变量提升。

变量提升所带来的问题

1. 变量容易在不被察觉的情况下被覆盖掉

我们看这种情况:

var myname = "wens" 
function showName(){ 
    console.log(myname); 
    if(0){ 
        var myname = "leon" 
    } 
    console.log(myname); 
} 
showName()

执行这段代码,打印出来的是 undefined,而并不会像具有块级作用域那样的语音一样打印出来“wens”的字符串。至于为什么输出的内容是 undefined ?相信看过我上一篇文章的同学一定能知道答案

2. 本应销毁的变量没有被销毁

接下来我们再来看下面这段让人误解更大的代码:

function foo(){ 
    for (var i = 0; i < 7; i++) { } 
    console.log(i); 
} 
foo()

如果我们使用有块级作用域的语音,在 for 循环结束之后,i 就已经被销毁了,但是在 JavaScript 代码中,i 的值并未被销毁,所以最后打印出来的是 7。

这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

let 和 const

上面我们介绍了变量提升而带来的一系列问题,为了解决这些问题,ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。

关于 let 和 const 的用法,参考下面代码:

let x = 5 
const y = 6 
x = 7 
y = 9 //报错,const声明的变量不可以修改

从这段代码你可以看出来,两者之间的区别是,使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的(对于引用类型的值可以修改堆内存中那个值,但是不能修改指针的指向)。但不管怎样,两者都可以生成块级作用域,为了简单起见,在下面的代码中,我统一使用 let 关键字来演示。

那么接下来,我们就通过实际的例子来分析下,ES6 是如何通过块级作用域来解决上面的问题的。

你可以先参考下面这段存在变量提升的代码:

function varTest() { 
    var x = 1; 
    if (true) { 
        var x = 2; // 同样的变量! 
        console.log(x); // 2
    } 
    console.log(x); // 2 
}

在这段代码中,有两个地方都定义了变量 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:

从执行上下文的变量环境中可以看出,最终只生成了一个变量 x,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。

所以上述代码最后通过 console.log(x) 输出的是 2,而对于相同逻辑的代码,有块级作用域的语言最后一步输出的值应该是 1,因为在 if 块里面的声明不应该影响到块外面的变量。 下面我们使用 let 关键字替换 var 关键字使其具备块级作用域,改造后的代码如下:

function varTest() { 
    let x = 1; 
    if (true) { 
        let x = 2; // 同样的变量! 
        console.log(x); // 2
    } 
    console.log(x); // 2 
}

执行这段代码,其输出结果就和我们的预期是一致的。

那么,JavaScript 是如何在没有破坏变量提升的情况下还支持块级作用域呢? 下面我们通过一段代码来说明:

function foo(){ 
    var a = 1 
    let b = 2 
    { 
        let b = 3 
        var c = 4 
        let d = 5 
        console.log(a) 
        console.log(b) 
    } 
    console.log(b) 
    console.log(c) 
    console.log(d)
}
foo()

读过前面两篇文章的同学都知道,当执行上面这段代码的时候,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码。但是现在的情况有点不一样,我们引入了 let 关键字这就有了块级作用域。下面是执行过程:

第一步是编译并创建执行上下文:

通过上图,我们可以得出以下结论:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
  • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

接下来,第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:

从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找(一个完整的查找变量和函数的过程会涉及到作用域链,这个我们在下篇文章会介绍)。

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

通过上面的分析,我们已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

暂时性死区 —— TDZ

有了上面的基础,现在我们来看看另一个非常重要的新概念 —— 暂时性死区。 这是ES6里面随着let 和 const 一起出现的概念。相信大家对下面这个图一定很熟悉:

有了上面的解释,大家一定知道之所以 a 有值是因为变量提升,而对于 b 出现了报错,这其实就是暂时性死区在作怪。那么怎么解释这个现象呢?

直接上官方说明( www.ecma-international.org/ecma-262/6.…

let and const declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated. A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.

用我拙劣的翻译水平为大家翻译一下:

通过let 和 const声明的变量被放在词法环境中,当它们包含的词法环境(Lexical Environment)被实例化时会被创建,但只有在变量的词法绑定(LexicalBinding)已经被求值运算后,而不是变量被创建的时候,才能够被访问。

再说的明白点就是:只有代码运行到let 和 const赋值语句之后才可以访问,否则任何访问都会进入TDZ。

那么现在我们就可以定义TDZ了 —— 其实就是对于用let和const定义的变量,在变量初始化之前是不能访问的,一访问就会抛ReferenceError。这个不能访问的区域就叫TDZ。

值得注意的是下面这种情况是不会进行我们上面提到的那个查询过程:

let name = 'wens';
{
    console.log(name);
    let name = 'leon';
}

这里并不会打印任何结果,而是会产生报错。我们在块作用域里面的name被赋值之前(已经初始化了)访问了name,所以进入了 TDZ 。

下面用两个例子再来体会一下:

let a = a;

这个例子是用 a 赋值给a,当赋值的时候访问了a这个变量,而这个时候a还没有定义好,自然会进入TDZ。

下面这个稍微绕一些:

let a = fn();
const b = 1;
function fn() {
    return b;
};

这里使用函数 fn 的返回值给 a 进行赋值,而 fn 的返回值是 b ,此时 b 还没有被赋值,所以也进入了 TDZ。

相信通过上面两个例子大家应该理解了 TDZ ,其实在ES6中可以说 TDZ 是无处不在,不只是let 和 const,在设置默认参数的时候也可能发生。

另外,还有一个细节值得我们注意,我们看下面这种情况:

我们可以得出两个结论:

  • 如果一个用let声明的词法绑定没有初始化器,那么这个变量在初始化绑定被执行的时候会被用undefined赋值
  • 使用const声明的变量必须被绑定初始化器,否则产生错误

总结

今天讲解的内容就结束了,下面我来简单总结下今天的内容。

由于 JavaScript 的变量提升存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字来解决这些问题。

我们通过对变量环境和词法环境的介绍,分析了 JavaScript 引擎是如何同时支持变量提升和块级作用域的。

最后介绍了TDZ的产生原因以及如何避免。

下一篇文章我们来讲解一下作用域链。