引言
在JavaScript中,理解作用域、变量提升(hoisting)以及执行上下文的概念对于编写高效且可维护的代码至关重要。特别是刚接触代码的同学们看完这篇文章后,可以更好地掌握JavaScript的工作原理。本文将详细介绍这些概念,并通过示例代码来加深理解。
1. 执行上下文与调用栈
-
执行上下文:当JavaScript代码执行时,它会创建一个执行上下文。每个函数调用都会创建一个新的执行上下文。
执行上下文是JavaScript代码执行时的环境。每个执行上下文都有三个主要组成部分:- 变量环境 (Variable Environment) : 用于存储该上下文中声明的所有变量和函数。
- 词法环境 (Lexical Environment) : 用于确定当前上下文中可以访问哪些外部变量。
this
值: 指向当前上下文中的this
关键字所代表的对象。
-
调用栈:调用栈是一种数据结构,用于管理执行上下文。全局代码在最底层,每次函数调用都会将新的执行上下文推入栈顶,函数执行完毕后,该上下文被弹出栈。
function first() {
console.log("Inside first function");
second();
}
function second() {
console.log("Inside second function");
}
first();
在这个例子中:
- 创建全局执行上下文并压入调用栈。
first()
被调用,创建一个新的执行上下文并将其压入调用栈顶部。first()
内部调用了second()
,于是再为second()
创建一个新的执行上下文并压入调用栈顶部。second()
执行完毕后,它的执行上下文从调用栈弹出。first()
继续执行直到完成,然后其执行上下文也从调用栈弹出。- 最终,只剩下全局执行上下文在调用栈中,直到整个脚本执行结束。
2. 变量提升(hoisting)与暂时性死区 (Temporal Dead Zone,TDZ)
-
变量提升:在JavaScript中,使用
var
关键字声明的变量会经历一个称为“变量提升”(Variable Hoisting)的过程。这意味着无论你在代码中的哪个位置使用var
声明了变量,这个声明实际上会被移动到当前作用域的最顶部。然而,需要注意的是只有变量的声明部分被提升了,而赋值(初始化)则不会被提升。因此,如果你在声明之前访问该变量,它将返回undefined
。下面通过具体的例子来说明这一点:
console.log(x); // 输出: undefined
var x = 10;
console.log(x); // 输出: 10
在这个例子中,尽管x
的声明和赋值是在console.log(x)
之后进行的,但由于变量提升的原因,实际执行过程等同于如下代码:
var x; // 变量声明被提升到了这里
console.log(x); // 此时x还未被赋值,所以输出为undefined
x = 10; // 这是赋值操作,并没有被提升
console.log(x); // 现在x已经被赋值为10,所以输出10
这解释了为什么第一次调用console.log(x)
时输出undefined
:因为此时x
已经作为变量存在,但还没有被赋予任何值。第二次调用console.log(x)
时,x
已经被赋值为10,所以输出10。
此外,对于函数内部的变量提升同样适用。例如:
function testHoisting() {
console.log(y); // 输出: undefined
var y = 5;
console.log(y); // 输出: 5
}
testHoisting();
这段代码中,虽然y
的声明和赋值看起来是在console.log(y)
之后,但实际上var y;
已经被提升到了函数的顶部,使得首次打印y
时其值为undefined
。
-
函数提升:在JavaScript中,函数提升(Function Hoisting)是指无论函数声明出现在代码的哪个位置,解释器都会将其移动到当前作用域的最开始部分。这意味着您可以在实际声明函数之前就调用它。这是JavaScript的一个特性,适用于函数声明,但不直接适用于函数表达式。
下面通过一个例子来展示函数提升的工作方式:
// 调用sayHello函数
sayHello(); // 输出: Hello, world!
// 函数声明
function sayHello() {
console.log('Hello, world!');
}
在这个例子中,即使sayHello
函数是在调用之后定义的,但由于函数提升的原因,这段代码能够正常运行而不会抛出错误。JavaScript引擎会将sayHello
函数的声明提升至其所在作用域的顶部,因此等同于以下代码:
// 函数声明被提升到这里
function sayHello() {
console.log('Hello, world!');
}
// 然后是函数调用
sayHello(); // 输出: Hello, world!
但是,请注意,如果使用的是函数表达式而不是函数声明,则情况不同。例如:
// 尝试调用myFunction
myFunction(); // TypeError: myFunction is not a function
// 函数表达式
var myFunction = function() {
console.log('This is a function expression.');
};
在这个例子中,由于myFunction
是一个函数表达式,并且赋值给变量myFunction
的动作没有被提升,所以尝试在赋值前调用会导致错误。这是因为只有变量声明var myFunction;
被提升了,但初始值undefined
并不指向任何函数。因此,在使用函数时,理解函数声明和函数表达式的区别是很重要的。
-
暂时性死区 (TDZ) :ES6引入了
let
和const
,当使用let
或const
声明变量时,在声明之前访问这些变量会导致引用错误(ReferenceError)
。这是因为从块的开始到变量声明之间的区域被称为“暂时性死区”。在这一区域内,尝试访问变量会导致错误。这与var
不同,var
声明的变量会被提升到其作用域的顶部,并且在声明前可以访问,尽管此时它的值为undefined
。下面是一些示例来说明
let
和const
的暂时性死区:
使用 let
{
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 10;
}
在这个例子中,即使let a = 10
;是在console.log(a)
;之后声明的,但在let
声明之前的任何地方尝试访问a
都会导致一个引用错误,因为这个区域是暂时性死区。
对于const
也有相同的行为:
{
console.log(b); // ReferenceError: Cannot access 'b' before initialization
const b = 20;
}
同样地,尝试在const
声明之前访问b会引发引用错误。
3. 块级作用域
- 块级作用域:
let
和const
声明的变量具有块级作用域,即它们只在声明它们的块(如if
语句、for
循环等)内有效。
function foo() {
var a = 1;
let b = 2; // 局部于foo函数内部,但在内层块外
{
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
console.log(d); // ReferenceError: d is not defined
}
foo();
- 块级作用域的生命周期 :
- 创建:当程序执行到一个带有let或const声明的代码块时,该块级作用域被创建。
- 活动:只要代码仍在该块内执行,这个作用域就处于活动状态,其中声明的变量可以被访问。
- 销毁:一旦控制流离开该代码块(即执行了最后一个花括号}之后),该块级作用域就被认为是“结束”了。此时,与该作用域相关的变量将不再可以直接访问。
4. 词法作用域(outer属性)与作用域链
- 词法作用域:函数的作用域是在定义时确定的,而不是在调用时。这意味着函数内部可以访问其外部作用域中的变量,但外部不能访问函数内部的变量。
- 外层作用域:函数可以通过
outer
属性访问其外层作用域。 - 作用域链:作用域链描述了当一个函数被执行时,它是如何查找变量的过程。每个执行上下文都有自己的变量对象,而这个变量对象会链接起来形成一个链条,从当前执行上下文开始向上追溯至全局执行上下文。如果在当前上下文中找不到某个变量,则会继续向上一级寻找,直到找到该变量或到达全局执行上下文为止。
function bar() {
console.log(myname); // lisi
}
function foo() {
var myname = 'zhangsan';
bar();
console.log(myname); // 肖总
}
var myname = 'lisi';
foo();
这段代码输出值是什么?
有些人可能觉得输出值都是zhangsan
,实际上这段代码输出的是lisi
和zhangsan
。
观察这张图,有一个outer
属性指向了外部作用域。
由于bar
是在全局作用域当中定义的,即使在foo
中被调用,outer
指向的还是全局作用域,不会经过foo
。意味着如果在全局中没有定义var myname = 'lisi'
,会输出myname is undefined
。
总结
- 执行上下文:管理代码执行的环境。
- 词法环境与变量环境:分别存储变量和函数的标识符及其对应的值。
- 调用栈:管理执行上下文的数据结构。
- 变量提升:
var
声明的变量会被提升到其所在作用域的顶部。 - 暂时性死区 (TDZ) :
let
和const
声明的变量在声明之前不可访问。 - 块级作用域:
let
和const
声明的变量具有块级作用域。 - 词法作用域:函数的作用域在定义时确定。
- 作用域链:变量查找从当前作用域逐级向上查找。
通过理解这些概念,你可以更好地编写和调试JavaScript代码,避免常见的陷阱,并提高代码的质量。