本文继《深入JavaScript之进一步理解变量提升(一)》进一步理解变量提升的相关内容,本文会先分析为什么JavaScript中会存在变量提升,以及变量提升带来的问题,然后介绍如何通过块级作用域配合let和const关键字来修复这种缺陷,最后介绍在同一段代码中,ES6是如何让做到既要支持变量提升的特性,又要支持块级作用域。
为什么JavaScript中会存在变量提升这个特性
为什么JavaScript中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要弄明白这个问题,就得从作用域讲起。
作用域是指在程序中定义变量的区域,该位置决定了变量的声明周期。通俗地理解,作用域就是变量于函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在ES6之前,ES的作用域只有两种:全局作用域和函数作用域。
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
在ES6之前,相较于其他语言,js不支持块级作用域。
块级作用域:就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。如下面的代码所示:
// if块
if(1){}
// while块
while(1){}
// 函数块
function foo(){}
// for循环块
for(let i=1; i< 100; i++){}
// try/catch
try{
}catch(e) {
}
// 单独一个块
{}
简单来说,如果一种语言支持块级作用域,那么其代码内部定义的变量在代码块外部是访问不到的,并且等该代码中的代码执行完成之后,代码块中定义的变量会被销毁。
和Java、C/C++不同,ES6之前不支持块级作用域,因为当初设计这门语言的时候,并没有想到JavasScript会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑不是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部任何地方都是能被访问的,这也就是JavaScript中的变量提升。
变量提升所带来的问题
由于变量提升作用,使用JavaScript来编写和其他语言相同逻辑的代码,都可能会导致不一样的执行结果。
1. 变量容易在不被察觉的情况下被覆盖掉
代码如下所示:
var myname = "极客时间"
function showName(){
console.log(myname);
if(0){
var myname = "极客邦"
}
console.log(myname);
}
showName()
执行上面这段代码,打印出来的是undefined。为什么输出的内容是undefined呢?我们来分析一下:
首先,当刚执行到showName函数调用时,执行上下文和执行栈的状态如下图所示:
showName函数的执行上下文创建后,JavaScript引擎便开始执行showName函数的执行上下文中,其值是undefined。那么到底该使用哪个呢?当然时先用函数执行上下文里面的变量啦!这是因为在函数执行过程中,JavaScript会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中就包含了变量myname,而值是undefined,所以获取到的myname的值就是undefined。
这输出的结果和其他大部分支持块级作用域的语言都不一样,所以很容易造成误解,特别是在你会一些其他语言的基础之上,再来学习JavaScript,你就会觉得这种结果很不自然。
2. 本应销毁的变量没有被销毁
接下来再看下面这段让人误解更大的代码:
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
如果使用C语言或者其他的大部分语言实现类似代码,在for循环结束之后,i就已经被销毁了,但是在JavaScript代码中,i的值并未被撤销,所以最后打印出来的时7。
这同样也是由变量提升而导致的,在创建执行上下文阶段,变量i就已经被提升了,所以当for循环结束后,变量i并没有被销毁。
ES6时如何解决变量提升带来的缺陷
上面介绍了变量提升而带来的一系列问题,为了解决这些问题,ES6引入了let和const关键字,从而使JavaScript也能像其他语言一样拥有了块级作用域。
下面通过实际的例子来分析下,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块里面的声明不应该影响到外面的变量。
既然支持块级作用域和不支持块级作用域的代码执行逻辑是不一样的,那么接下来我们就来改造上面的代码,让其支持块级作用域。
改造后的代码如下所示:
function letTest() {
let x = 1;
if (true) {
let x = 2; // 不同的变量
console.log(x); // 2
}
console.log(x); // 1
}
执行这段代码,输出结果就和我们的预期是一致的。这是因为let关键字是支持块级作用域的,所以在编译阶段,JavaScript引擎并不会把if块中通过let声明的变量存放到变量环境中,这也就意味着在if块通过let声明的关键字,并不会提升到全函数可见。所以if块之内打印出来的值是2,跳出语块之后,打印出来的值就是1了。这种就非常符合我们的编程习惯了:作用域块内的声明的变量不影响块外面的变量。
JavaScript是如何支持块级作用域的
现在我们知道了ES可以通过使用let或者const关键字来实现块级作用域,不过你是否有过这样的疑问:在同一段代码中,ES6是如何做到既要支持变量提升的特性,又要支持块级作用域呢?
下面,我们就站在执行上下文的角度来揭开答案。
JavaScript引擎是通过变量环境实现函数级作用域的,那么ES6又是如何让在函数级作用域的基础之上,首先对块级作用域的支持呢?先看下面这段代码:
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关键字,let关键字会创建块级作用域,那么let关键字是如何影响执行上下文的呢?
下面我们就来一步步分析上面这段代码的执行流程。
第一步是编译并创建执行上下文,示意图如下图所示:
通过上图,我们可以得出以下结论:
- 函数内部通过var声明的变量,在编译阶段全都被存放到变量环境中
- 通过let声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中
- 在函数的作用域内部,通过let声明的变量并没有被存放到词法环境中
第二步继续执行代码,当执行到代码块里面时,变量环境中的a的值已经被设置成了1,词法环境中的b的值已经被设置成了2,这时候函数的执行上下文就如下图所示:
从图中可以看出,当进入函数的块级作用域时,作用域块中通过let声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量b, 在该作用域块内部也声明了变量b,当执行到作用域内部时,它们都是独立的存在。
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。
再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a的值了,具体查找方式是:**沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。**如下图所示:
从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了。
当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
通过上面的分析,我们总结下回答上面的疑问:
块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。
思考题:
let myname= '极客时间'
{
console.log(myname)
let myname= '极客邦'
}
解析:
【最终打印结果】:VM6277:3 Uncaught ReferenceError: Cannot access 'myname' before initialization
【分析原因】:在块作用域内,let声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。
【拓展】
- var的创建和初始化被提升,赋值不会被提升。
- let的创建被提升,初始化和赋值不会被提升。
- function的创建、初始化和赋值均会被提升。