变量提升和块级作用域

开始接触JS的时候,想必大家都听过“变量提升”这个词。由于它的存在,一些代码的执行结果可能会出乎我们的意料,其实,这可以算是JS的一个设计缺陷吧。ES6引入了块级作用域和let、const关键字的概念,通过它们能够较好的规避变量提升。但是作为一门语言来说,向下兼容是必要的,所以变量提升的机制我们还是有必要去理解。今天我们尽量去深入底层来分析变量提升的存在原因,以及ES6又是如何去填补这块缺陷的。
首先我们需要了解作用域的概念,对于它的定义不同的参考资料都各有各的说法。一个我认为比较好理解的是:作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。它是变量和函数的可访问范围,控制着变量和函数的可见性和生命周期。
那么在ES6之前我们知道,只有全局作用域和函数作用域。全局作用域顾名思义就是在代码的任何地方都能访问到里边的对象,而函数作用域呢,就是指变量或者函数是在函数内部定义的,它只能在函数内部被访问。函数执行完后内部定义的变量会被销毁。
我们早期学过的C语言、C佳佳、Java等都是有块级作用域这么一个概念的,它是一对大括号包裹的一段代码,比如函数、if语句、for循环语句等。给一些简单等代码吧(全宇宙语言通用)

1
2
3
if (1) { };
while (1) { };
for (let i = 9; i++; i< 10) { };

代码块内定义的变量在代码块外部是无法被访问到的,而且上面有提到,代码块中的代码执行完毕后其定义的变量会被销毁。让我们梦回大一,看一段C语言的代码。

1
2
3
4
5
6
7
8
9
10
11
12
char* name = "able";
void printName() {
printf("%s \n", name);
if (0) {
char* name = "moriarty";
}
}

int main() {
printName();
return 0;
}

由于C语言有块级作用域,obviously上边的代码打印结果为全局变量的值able。但是如果这段代码是用JS写的呢,结果还会是一样么,我们接着往下看。
在ES6之前,Javascript是不支持块级作用域的。可能这门语言设计的初衷就是简约(而不简单),没有考虑块级作用域,所以作用域内部的变量都被统一提升了。导致函数中的变量无论在何处声明,编译时都会被进入到执行上下文的变量环境中,因此在整个函数体内所有地方都能被访问到。
我们来用JS来实现上面的代码。

1
2
3
4
5
6
7
8
9
var name = "able";
printName = () => {
console.log(name);
if (0) {
var name = "moriarty";
}
console.log(name);
}
printName();

输出结果是什么呢,没想到吧,既不是”able”也不是”moriarty”,是undefined,这是为什么呢?根据之前文章所学,首先创建执行上下文和调用栈,其中printName函数执行上下文的变量环境中name为undefined。执行上下文创建好后,JS引擎执行函数内部代码。函数执行时JS会优先从当前的执行上下文中查找变量,首先执行console打印,那么它会使用函数上下文中的变量值undefined。相信很多人跟为一样,刚开始接触JS的时候会觉得这种结果有些奇怪吧。
还有一种情况就是本应被销毁的变量却没有销毁。

1
2
3
4
5
function S () {
for (var i = 0; i < 6; i++) {
}
console.log(i);
}

为了解决变量提升,ES6引入了let和const关键字,使JS也拥有了块级作用域。let和const的用法大家都很清楚了,我们来看看ES6是如果解决变量提升的吧。

1
2
3
4
5
6
7
8
9
function test() {
let a = 1;
if (1) {
let a = 2;
console.log(a); // 2
}
console.log(a); // 1
}
test();

可以看到输出结果就很符合我们的预期了,这是因为let关键字支持块级作用域,在作用域内声明的变量不会影响外面的变量。JS引擎通过变量环境来实现函数作用域,那么大家肯定会想,ES6即支持变量提升,又如何在函数作用域的基础上来支持块级作用域,它是如何做到的呢?接下来我们结合前面的博客执行上下文相关的知识来一起分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function setBlock () {
var m = 0;
let n = 1;
{
let n = 2;
var o = 3;
let p = 4;
console.log(m);
console.log(n);
}
console.log(n);
console.log(o);
console.log(p);
}
setBlock();

执行上面的代码时,首先编译并创建执行上下文,函数内部var声明的变量在编译阶段存放到了变量环境中,let声明的变量,存放到了词法环境中,但是如果是在函数的块级作用域内部let声明的变量并没有存放到词法环境中。
setBlock函数执行上下文.png
接下来执行代码,此时词法环境中n的值被设置成1。而当进入块级作用域时,作用域块中通过let声明当变量会被存放在词法环境一个单独的区域中,与作用域块外面的变量互不影响,也产生了一个小型栈结构。
setBlock函数执行.png
栈的底部是函数最外层的变量,进入块级作用域后块内let和const声明的变量会进栈,作用域执行完后该作用域的信息从顶部弹出。而console.log(m)时需要在词法环境和变量环境中来找变量a的值了。首先在词法环境的栈中从顶部向底部查找,如果在某个作用域块中找到了则直接返回,没有找到则继续在变量环境中查找。
变量查找过程.png
通过以上我们可以了解到,块级作用域是通过词法环境的小型栈结构来实现的,而JS变量提升的特性是通过变量环境来实现的。想必通过今天的分析,大家对变量提升和块级作用域有了比较清晰的了解啦。