深入JavaScript底层:词法作用域、变量提升与执行上下文详解

196 阅读6分钟

引言

在JavaScript编程中,理解变量提升(hoisting)、词法作用域(lexical scoping)以及作用域链(scope chain)对于编写高效且易于维护的代码至关重要。这些概念不仅影响着程序的行为,还决定了如何访问和管理变量。通过具体示例,本文将深入探讨这些核心概念,并解释一些可能违反直觉的现象,帮助开发者更好地掌握JavaScript的工作原理。

变量提升:一个双刃剑

在JavaScript中,无论你在代码中的哪个位置声明了一个变量或函数,JavaScript引擎都会将它们移动到当前作用域的顶部。这一过程被称为“变量提升”。例如:

console.log(a); // undefined
var a = 1;

尽管a是在console.log之后声明的,但由于变量提升,实际上等价于:

javascript
var a;  // 提升至顶部
console.log(a); // 输出 undefined
a = 1;  // 赋值操作未被提升

这种行为可能导致意外的结果,尤其是在较大的代码库中。ES6通过引入letconst关键字来缓解这个问题。使用letconst声明的变量不会被提前初始化,尝试在声明前访问会导致ReferenceError。这使得代码更加可预测且易于维护。

执行上下文与调用栈

JavaScript是单线程的,这意味着它一次只能执行一段代码。为了管理这段代码的执行顺序,JavaScript引擎使用了调用栈(Call Stack)。每当一个函数被执行时,一个新的执行上下文(Execution Context)就会被创建并压入调用栈。全局代码也有自己的执行上下文,位于调用栈的底部。当函数完成执行后,它的执行上下文会从栈中弹出,控制权返回给上一级的执行上下文。

执行上下文主要包含以下三个部分:

  • 变量环境:存储变量声明的地方。
  • 词法环境:用于解析标识符引用的结构。
  • this:当前执行上下文的上下文对象。
console.log(a, func)
console.log(b)     // 词法环境中的变量/常量 , 在声明之前不可以访问  这种就叫暂时性死区  TDZ
var a = 1;
function func() {

}
let b = 2
b++  //在词法环境查找b
变量提升 (Hoisting)
  • var a 声明会被提升到其作用域的顶部,但赋值不会被提升。因此,在执行 console.log(a, func) 时,a 的值为 undefined
  • 函数声明 func 也会被完全提升,这意味着在执行任何代码之前,func 已经是一个可用的函数。
暂时性死区 (Temporal Dead Zone, TDZ)
  • let b 和 const 一样,它们的声明也会被提升,但是与 var 不同的是,在实际到达声明位置前尝试访问这些变量会导致引用错误(ReferenceError),因为在这段区域中变量处于暂时性死区。

词法作用域与作用域链

在分析这段代码之前,让我们先回顾一下JavaScript中的词法作用域(Lexical Scoping)和作用域链(Scope Chain)的概念:

  • 词法作用域:变量的作用域是由其声明的位置决定的。在编写代码时,你就可以知道一个标识符在哪个块中是可见的。
  • 作用域链:当一个函数被创建时,它会记住其创建时所在的词法环境。当该函数被执行时,它将创建一个新的词法环境,这个新的环境会通过作用域链链接到其外部环境,从而可以访问外部环境中的变量。

现在我们来逐步解析你的代码:

function foo() {
  var a = 1; // 全局于foo函数内部
  let b = 2; // 局部于foo函数内部,但在内层块外
  {
    let b = 3; // 局部于这个块
    var c = 4; // 虽然声明在块内,但实际上是全局于foo函数内部
    let d = 5; // 局部于这个块
    console.log(a); // 输出 1
    console.log(b); // 输出 3
  }
  {
    let b = 5; // 局部于这个块
  }
  console.log(b); // 输出 2
  console.log(c); // 输出 4
  console.log(d); // 抛出 ReferenceError: d is not defined
}
foo();

7e551a66e0b4610cf37e710964c3e602.png

词法作用域分析

  • a 和 c 是用 var 声明的,因此它们的作用域是整个 foo 函数。尽管 c 在一个块中声明,但由于 var 的特性,它的作用域仍然是整个函数。

  • b 有三个不同的声明:

    • 外层的 let b = 2 定义了 b 在整个 foo 函数中除了内层块之外的作用域。
    • 第一个内层块 { let b = 3; ... } 中的 b 遮蔽了外层的 b,仅在这个块内有效。
    • 第二个内层块 { let b = 5; } 中的 b 同样遮蔽了外层的 b,仅在这个块内有效。
  • d 是用 let 声明的,并且只在第一个内层块中有效。

作用域链分析

  • 当执行 console.log(a) 和 console.log(b) 时,它们是在第一个内层块内,因此首先查找当前块内的变量。由于 b 在当前块内被重新定义为 3,所以输出 3
  • 当执行 console.log(b)(在所有块之后)时,此时处于 foo 函数的顶层,而最近一层的 b 是外层的 let b = 2,所以输出 2
  • console.log(c) 查找 c 的值,由于 c 是用 var 声明的,它在整个 foo 函数中都是可见的,因此输出 4
  • console.log(d) 尝试访问 d,但是 d 只在第一个内层块中有定义,所以在该块外尝试访问会导致 ReferenceError

但你看到下面这个函数运行的结果由会和你想的不一样 外部作用域(outer scope)

function bar() {
  console.log(myname)     //lisi
}
function foo() {
  var myname = 'zhangsan'
  bar()
  console.log(myname)      //zhangsan
}
var myname = 'lisi'
foo()

在这我们可能根据之前掌握的直觉认为两个输出值都是zhangsan但在bar()中它输出的是全局作用域中的lisi

你可以观察下面这张图,它有一个outer类似指针

829317bd0d0d03f3b100434fa07fe66d.png

由于bar是在全局作用域当中定义的,所以即便它是在foo中被使用,但他的outer指向的还是全局作用域,他便不会经过foo

结论

通过对JavaScript中变量提升、词法作用域和作用域链的详细分析,我们能够更加清晰地理解为什么某些代码片段的行为会与我们的直觉不符。例如,在使用var声明的变量时,尽管它们会被提升到其作用域的顶部,但赋值操作不会被提前执行;而letconst则引入了暂时性死区(TDZ),进一步增强了代码的安全性和可预测性。

此外,函数的作用域由定义位置决定,而不是调用位置,这使得函数可以保持其创建时的环境,从而确保了跨函数调用的一致性。理解这些机制不仅有助于避免常见的陷阱,还能提高代码的质量和可读性,为构建更健壮的应用程序打下坚实的基础。通过本文的讨论,我们希望开发者能对JavaScript的内部工作有更深的认识,并能够利用这些知识来优化自己的代码实践。

20200229174423_bzukt.jpg