在上节课中我们讲到了”先定义后执行” 这个基础概念。其他在专业术语中也叫变量提升,正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

这种设计上的缺陷主要带来的问题有两个。
变量容易在不被察觉的情况下被覆盖掉

  1. var myname = "max"
  2. function showName(){
  3. if(0){
  4. var myname = "james"
  5. }
  6. console.log(myname);
  7. }
  8. showName()

执行上面这段代码,打印出来的是 undefined, 原因是在执行函数的过程中if 语句中定义的变量提升到函数作用域,但是并未赋值。 在函数体中访问 myname 变量因为本身作用域中有定义说以就不会去外层全局作用域中找, 这种方式很违反直觉。

我们一起来看下面这段 C 代码:

  1. char* myname = "max";
  2. void showName() {
  3. printf("%s \n",myname);
  4. if(0){
  5. char* myname = "james";
  6. }
  7. }
  8. int main(){
  9. showName();
  10. return 0;
  11. }

上面这段 C 代码执行后,最终打印出来的是上面全局变量 myname 的值,之所以这样,是因为 C 语言是支持块级作用域的,所以 if 块里面定义的变量是不能被 if 块外面的语句访问到的。

本应销毁的变量没有被销毁
接下来我们再来看下面这段让人误解更大的代码:

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

如果你使用 C 语言或者其他的大部分语言实现类似代码,在 for 循环结束之后,i 就已经被销毁了,但是在 JavaScript 代码中,i 的值并未被销毁,所以最后打印出来的是 7。

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

这依旧和其他支持块级作用域的语言表现是不一致的,所以必然会给一些人造成误解。

为什么会这样?

var hoisting was the unintended consequence of function hoisting, no block scope, JS as a 1995 rush job. var变量提升功能是一个的意外结果,没有块作用域,JavaScript是1995年的紧急工作。 大概意思就是JavaScript在设计之初比较匆忙没想那么多。

参考地址:https://twitter.com/BrendanEich/status/522395336615428097

好在是 ES6 已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。

作用域

为了更好的了解变量提升的特性,我们还需要先理解作用域的概念。

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

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

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

在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

为了更好地理解块级作用域,你可以参考下面的一些示例代码:

//if块if(1){}

//while块while(1){}

//函数块function foo(){} 

//for循环块for(let i = 0; i<100; i++){}

//单独一个块{}

简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

ES6 是如何解决变量提升带来的缺陷

刚刚我们讲到ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。
关于 let 和 const 的用法,你可以参考下面代码:

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

从这段代码你可以看出来,两者之间的区别是,使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。但不管怎样,两者都可以生成块级作用域。

接下来,我们就通过实际的例子来分析下,ES6 是如何通过块级作用域来解决上面的问题的。
先参考下面这段使用 var 的代码:

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

使用了 let 之后的代码:

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 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?”
image.png
那是因为函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。通过 let 声明的变量,在编译阶段会被存放到词法环境中,它们都是独立的存在。

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

当代码执行到要去查询某个变量时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出。

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