引言
在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通过引入let和const关键字来缓解这个问题。使用let或const声明的变量不会被提前初始化,尝试在声明前访问会导致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();
词法作用域分析
-
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类似指针
由于bar是在全局作用域当中定义的,所以即便它是在foo中被使用,但他的outer指向的还是全局作用域,他便不会经过foo。
结论
通过对JavaScript中变量提升、词法作用域和作用域链的详细分析,我们能够更加清晰地理解为什么某些代码片段的行为会与我们的直觉不符。例如,在使用var声明的变量时,尽管它们会被提升到其作用域的顶部,但赋值操作不会被提前执行;而let和const则引入了暂时性死区(TDZ),进一步增强了代码的安全性和可预测性。
此外,函数的作用域由定义位置决定,而不是调用位置,这使得函数可以保持其创建时的环境,从而确保了跨函数调用的一致性。理解这些机制不仅有助于避免常见的陷阱,还能提高代码的质量和可读性,为构建更健壮的应用程序打下坚实的基础。通过本文的讨论,我们希望开发者能对JavaScript的内部工作有更深的认识,并能够利用这些知识来优化自己的代码实践。