块级作用域

我们已经了解了 JavaScript 中变量提升的相关内容,正是由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

虽然 ES6 已经通过引入块级作用域并配合 let、const 关键字,来避免这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间还会继续存在,这也加大了理解概念的难度,因为既要理解新的机制,又要理解变量提升这套机制,关键是这两套机制还是运行在 “一套” 系统中。

如果抛开 JavaScript 的底层去理解这些,大概率很难深入其概念。所以为了便于更好地理解和学习,今天我们就来分析为什么在 JavaScript 中存在变量提升,以及变量提升所带来的问题。然后介绍如何通过 块级作用域配合 let 和 const 关键字 来修复这种缺陷。

作用域(scope)

为什么 JavaScript 会存在变量提升这个特性,而其他语言似乎都没有这个特性?要想搞清楚这个问题,我们就得先从作用域讲起。

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

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

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

在 ES6 之前,JavaScript 只支持这两种作用域。ES6 之前是不支持块级作用域的,因为设计这门语言的时候,并没有想过 JavaScript 会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一无疑是最快速、最简单的设计,不过这也导致了函数中的变量无论是在哪声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升。

变量提升带来的问题

由于变量提升,使用 JavaScript 来编写和其他语言相同逻辑的代码,都有可能导致不一样的执行结果。

变量覆盖

比如下面这段代码:

var myname = 'heora'
function showName() {
  console.log(myname)
  if (0) {
    var myname = 'yueluo'
  }
  console.log(myname)
}
showName()
js

执行上述代码,打印出来的都是 undefined。

当函数执行到 showName 函数调用时,执行上下文和调用栈的状态如下图所示:

call_stack10.png

showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码。首先执行:

console.log(myname)
js

执行这段代码需要使用变量 myname,结合上面的调用栈状态图,你可以看到这里有两个 myname 变量:一个在全局上下文中,值为 heora ,一个在 showName 函数的执行上下文中,其值是 undefiend

在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升,当前的执行上下文中包含了变量 myname,其值是 undefined,所以获取到的 myname 的值就是 undefiend

未被销毁的变量

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

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

执行上述代码打印出来的结果是 7,这也是由变量提升所导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束后,变量 i 并没有被销毁。

解决变量提升缺陷

为了解决上述问题,ES6 中引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有块级作用域。

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

let x = 5
const y = 6
x = 7
y = 9 // TypeError: Assignment to constant variable.
js

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

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

function varTest() {
  var x = 1
  if (true) {
    var x = 2
    console.log(x) // 2
  }
  console.log(x) // 2
}
js

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

call_stack11.png

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

所以上述代码最后通过 console.log(x) 输出的是 2。

既然支持块级作用域的不支持块级作用域的代码执行逻辑是不一样的,那么接下来我们就改造上述代码,使其支持块级作用域。

function varTest() {
  var x = 1
  if (true) {
    let x = 2
    console.log(x) // 2
  }
  console.log(x) // 1
}
js

执行这段代码,其输出结果就和我们的预期是一致的。这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也意味着 if 块通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块内打印出来的是 2,跳出语块之后,打印出来的值就是 1 了。这种表现就非常符合我们的编程习惯:作用域块内的变量不影响外面的变量。

如何支持块级作用域

现在你已经知道 ES6 可以通过 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) // 1
    console.log(b) // 3
  }
  console.log(b) // 2
  console.log(c) // 4
}   
foo()
js

当执行上面这段代码的时候,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码,关于如何创建执行上下文我们已经在前面的文章中分析过了,但是现在的情况有点不同,我们引入了 let 关键字,let 关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的的?

接下来我们就来一步步分析上面这段代码的执行流程。

第一步是编译并创建执行上下文,如下图所示。

call_stack12.png

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

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

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

call_stack13.png

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

在 词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入作用域块后,会把该作用域的变量压到栈顶。当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里的变量指得是通过 let 或者 const 声明的变量。

接下来,当执行到作用域块中的 console.log(a) 这行代码时,就需要在词法环境和变量环境中查找变量 a 的值,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查到,就直接返回给 JavaScript 引擎,如果没有查找到,继续在变量环境中查找。这里其实涉及到作用域链的概念,这个我们后面在做详细介绍。

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

call_stack14.png

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

总结

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

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

既然聊到作用域,最后我们再来简单聊下编程语言。经常有人争论什么编程语言是世界上最好的语言,但如果站在语言本身来说,这种争议其实并没有意义,因为语言是工具,而工具是用来创造价值的,至于能否创造价值或创造多少价值不完全由语言本身的特定决定。即使一门设计不那么好的语言,它有可能拥有很好的生态,比如有完善的框架、非常多的落地应用,又或者能够给开发者带来更多的回报,这些都是评判因素。

如果站在语言层面来谈,每种语言其实都是在相互借鉴对方的优势,协同进化。比如 JavaScript 引入块级作用域、迭代器和协程,其底层虚拟机的本质实现和 Java、Python 又非常相似,也就是说你理解了 JavaScript 协程和 JavaScript 中的虚拟机,其实你也就理解了 Java、Python 中的协程和虚拟机的实现机制。

所以说,语言本身好坏并不重要,重要的是能为开发者创造价值。

技术拓展

你可以通过分析词法环境,得出最终的打印结果吗?

let myname= 'heora'
{
  console.log(myname) 
  let myname= 'yueluo'
}
js

打印结果:

ReferenceError: Cannot access 'myname' before initialization

原因是在块作用域内,let 声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。

var 的创建和初始化被提升,赋值不会被提升。
let 的创建被提升,初始化和赋值不会被提升。
function 的创建、初始化和赋值均会被提升。