引言
在JavaScript中,理解词法作用域以及作用域链、执行上下文、变量提升是掌握这门语言的关键。这些代码深刻影响着代码的执行流程,还决定了变量的和函数的可见性与查找方式。通过深入探讨这些概念,我们可以更好地编写清晰、可维护且高效的代码。
词法作用域与作用域链
词法作用域是指变量的作用域在编写时就已经由其位置决定了,函数可以访问其定义时所在作用域及其外部作用域中的变量。
作用域链是通过一系列嵌套的词法环境形成的链式结构,用于在当前作用域中查找变量,如果找不到则沿着链向上一级作用域继续查找,直到全局作用域。
执行上下文
1. 什么是执行上下文
执行上下文(Execution Context)是JavaScript中用于管理代码执行过程中的环境和状态的一个抽象概念。每个执行上下文都包含了一系列的信息,包括变量、函数声明、作用域链以及this
的值等。
2. 如何存储执行上下文
具体的执行上下文实例会被放置在一个称为调用栈(Call Stack)的数据结构中。调用栈负责跟踪当前正在执行的函数及其调用顺序。每当一个函数被调用时,其对应的执行上下文就会被推入调用栈;当函数执行完毕后,相应的执行上下文会从调用栈中弹出。
3. 执行上下文的类型
执行上下文有三种类型:全局执行上下文、函数执行上下文、eval函数执行上下文。
全局执行上下文: 这是JavaScript程序启动时创建的第一个执行上下文,它代表了全局作用域。
函数执行上下文: 每当一个函数被调用时,就会为该函数创建一个新的执行上下文。这个上下文包含了函数内部的所有局部变量、参数及其它信息。
Eval执行上下文: 当使用eval()
函数执行字符串形式的代码时,会创建一个eval执行上下文。不是很常用
4. 执行上下文的组成
每个执行上下文主要由以下三部分组成:
变量环境: 它是一个记录了在该执行上下文中声明的所有变量和函数的数据结构。
词法环境: 用于存储变量、函数声明及其对应值的数据结构,并维护着对外部环境的引用,从而形成作用域链,决定了代码在执行时如何查找和访问这些标识符。
this关键字: 指定了当前执行上下文中的this
所指向的对象。
变量提升(hoisting): 一把隐形的双刃剑
在JavaScript中,变量提升是一种行为,无论你在何时声明一个变量和函数,他都会将它们移动到作用域的顶部,这一过程叫做变量提升
JavaScript
console.log(a);
console.log(func);
console.log(b); // 词法环境中的变量或常量,在申明之前不可访问
// 暂时性死区 TDZ
var a = 1;
function func() {
}
let b = 2;
var a
声明会被提升到其作用域的顶部,但赋值不会被提升。因此,在执行 console.log(a)
时,a
的值为 undefined
。
函数声明 func
也会被提升,这意味着在执行任何代码之前,func
已经是一个可用的函数。
为了避免变量提升对代码可读性的影响,es6引入了let
和const
,使用let
和const
声明的变量也会被添加到变量环境中,但是它们不会立即初始化(即处于暂时性死区 TDZ, Temporal Dead Zone)。这意味着在声明之前尝试访问这些变量会导致一个引用错误(ReferenceError)。此外,let
和const
声明的变量具有块级作用域(block scope),只在声明它们的块内有效。
代码分析
1. 词法作用域与作用域链
JavaScript
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a);
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo();
变量分析
-
a
和c
是用var
声明的,所以它们的作用域为整个foo
函数。并将它们记录在变量环境中。 -
b
有两次声明let b = 2;
是在函数的外层作用域声明的,所以它的作用域为整个foo
函数中除了内层块之外的所有作用域。并将它记录在词法环境的底层。let b = 3;
是在块级作用域内重新声明的,并赋予了新值3
。所以它的作用域为这个块级作用域,并将它添加到这个内部块的词法环境中。
-
d
是用let
声明的,所以它的作用域为这个块级作用域,并将它添加到这个内部块的词法环境中。 -
根据以上分析,我们可以将foo执行上下文画出来
根据分析,我们可以将foo执行上下文画出来
输出分析
- 当执行
console.log(a)
和console.log(b)
时,它们是在内层块内,由于a
是用var
声明的,所以直接就输出1
。而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
。
2. 外部作用域(outer scope)
JavaScript
function bar() {
console.log(myname);
}
function foo() {
var myname = 'john'
bar()
console.log(myname);
}
var myname = 'lisa'
foo();
执行上下文分析
如果根据直觉我们可能会认为两个输出值都为john
,但在bar()
中它输出的是lisa
。
仔细观察上面这张图,它有一个outer
指针,简单作用域可以直接从内到外查找,但如果在函数中,还要考虑outer
指向的外部作用域是是什么
由于bar()
是在全局作用域中定义的,所以即使在foo()
中被使用,但它的outer
指向的还是全局作用域,所以它查找的会是全局作用域中定义的myname = lisa
,而不会经过foo()
。
总结
通过分析具体的代码示例,我们看到了变量提升(hoisting)的行为,以及var
、let
和const
声明的不同特性。特别是ES6引入的let
和const
,它们具有块级作用域,并且在声明之前访问会导致引用错误,从而避免了var
带来的变量提升问题。
最后,通过一个复杂的例子,我们讨论了外部作用域的概念,即函数可以访问其外部作用域中的变量。这个例子展示了即使在函数内部调用另一个函数,该函数的作用域链仍然指向其定义时的外部作用域,而不是调用时的作用域。
通过这些概念的理解和实践,开发者可以更好地控制变量的作用域,避免常见的编程错误,并编写出更加健壮和易于维护的代码。