在 JavaScript 编程中,执行上下文是一个至关重要却又常被忽视的概念。它如同幕后的导演,掌控着代码的执行流程和变量的管理。理解执行上下文,对于深入掌握 JavaScript 的运行机制、排查代码错误以及优化代码性能都有着不可估量的作用。
一、什么是执行上下文
执行上下文是 JavaScript 引擎在执行代码时创建的一种抽象概念,它定义了变量、函数声明和其他标识符的可访问范围。简单来说,执行上下文就像是一个容器,包含了代码执行时所需的各种信息,如变量环境、词法环境、作用域链、this
值等。
每当 JavaScript 代码开始执行时,就会创建执行上下文,并且在代码执行过程中,根据需要可能会创建多个执行上下文,这些执行上下文共同构成了执行上下文栈,以确保代码按照正确的顺序和规则执行。
二、执行上下文的类型
JavaScript 中有三种主要的执行上下文类型:
(一)全局执行上下文
- 全局执行上下文是最外层的执行上下文,在浏览器环境中,它与
window
对象紧密相关(在 Node.js 环境中是global
对象)。当 JavaScript 代码加载并开始执行时,首先会创建全局执行上下文。 - 全局执行上下文在整个页面或程序的生命周期内始终存在,直到页面卸载或程序结束。
- 在全局执行上下文中,定义的全局变量和函数会成为
window
对象(或global
对象)的属性和方法。例如:
javascript
var globalVar = '全局变量';
function globalFunction() {
console.log('这是一个全局函数');
}
console.log(window.globalVar); // 输出: 全局变量
window.globalFunction(); // 输出: 这是一个全局函数
(二)函数执行上下文
- 每当一个函数被调用时,就会创建一个新的函数执行上下文。函数执行上下文包含了函数执行时的局部变量、参数以及函数内部定义的其他函数。
- 不同的函数调用会创建不同的函数执行上下文,它们相互独立,互不干扰。
- 函数执行上下文的生命周期从函数被调用开始,到函数执行完毕返回结果或者抛出异常结束。例如:
javascript
function outerFunction() {
var outerVar = '外部函数变量';
function innerFunction() {
var innerVar = '内部函数变量';
console.log(outerVar); // 可以访问外部函数的变量
console.log(innerVar);
}
innerFunction();
}
outerFunction();
在上述代码中,outerFunction
被调用时创建了一个函数执行上下文,innerFunction
被调用时又创建了另一个函数执行上下文。innerFunction
的执行上下文可以访问outerFunction
执行上下文中的变量,这是通过作用域链实现的,后面会详细介绍。
(三)Eval 执行上下文
eval
函数是 JavaScript 中的一个特殊函数,它可以将字符串作为 JavaScript 代码进行解析和执行。当eval
函数被调用时,会创建一个eval
执行上下文。不过,由于eval
函数存在安全风险和性能问题,在现代 JavaScript 开发中,一般不推荐使用。例如:
javascript
var code = "var localVar = '通过eval定义的变量'; console.log(localVar);";
eval(code); // 输出: 通过eval定义的变量
在非严格模式下,直接调用 eval
时,它创建的执行上下文与调用它的上下文共享作用域,所以可以访问和修改调用上下文里的变量,这就容易引发意外行为和安全漏洞。
三、执行上下文的创建过程
执行上下文的创建过程可以分为两个阶段:词法环境和变量环境的创建阶段以及执行阶段。在 ES6 引入词法环境和变量环境的概念后,对执行上下文的创建过程有了更清晰、更符合规范的描述。具体如下:
(一)词法环境与变量环境的创建
词法环境
词法环境是一种规范类型,它定义了标识符与变量的关联关系。每个执行上下文都有一个与之关联的词法环境。词法环境由两部分组成:环境记录和外部词法环境引用。
环境记录
环境记录是用来存放变量和函数声明的实际数据结构。在不同的执行上下文中(函数执行上下文或全局执行上下文),存放的东西有所不同。例如:
- 函数执行上下文(以
foo
函数为例)
javascript
function foo(a, b) {
var localVar = '局部变量';
function inner() {
// 此处的词法环境的环境记录包含了inner函数的参数(如果有)、局部变量(无)以及函数声明(无)
// 外部词法环境引用指向foo函数的词法环境
}
// foo函数词法环境的环境记录包含了参数a、b,局部变量localVar以及函数声明inner
// 外部词法环境引用指向全局词法环境
}
-
- 对于
foo
函数来说,当它的执行上下文创建时,它的环境记录里就会放入一些东西:
- 参数 :函数
foo
接收了两个参数a
和b
,它们会被存放在环境记录中。 - 局部变量 :
var localVar = '局部变量';
这行代码声明了一个局部变量localVar
,这个变量也会被放入foo
函数的环境记录中。局部变量就像是foo
函数自己 "私有的物品",只能在foo
函数内部使用。 - 函数声明 :
function inner() {... }
在foo
函数内部声明了一个inner
函数,这个函数声明同样会被存放在foo
函数的环境记录中。
- 对于
-
同时,
foo
函数的词法环境有一个 "外部词法环境引用",它指向全局词法环境。这就好比foo
函数所在的 "房间"(词法环境)有一扇门,这扇门通向了全局的 "大房间"(全局词法环境)。 -
- 对于内部函数
inner
,它也有自己的词法环境和环境记录。在它的环境记录中:
- 由于
inner
函数既未定义参数,也未声明局部变量和其他函数,所以环境记录里没有这些内容。 inner
函数词法环境的 "外部词法环境引用" 指向foo
函数的词法环境,这意味着inner
函数所在的 "房间" 有扇 "门" 通向foo
函数的 "房间"。当inner
函数查找变量或函数时,若在自身环境记录中未找到,便可通过这扇 "门" 去foo
函数的环境记录中查找。
- 对于内部函数
-
全局执行上下文
- 全局词法环境的环境记录里,存放着全局变量(比如在最外层声明的变量)和全局函数声明(在全局作用域下声明的函数)。
- 由于全局词法环境已经是最外层的了,没有 "更外面" 的词法环境可以连接,所以它的 "外部词法环境引用" 为
null
,就好比全局这个 "大房间" 没有向外的门了。
外部词法环境引用
-
在 JavaScript 中,每个词法环境都具备一个指向外部词法环境的引用,这个引用如同纽带,将当前词法环境与包含它的外层词法环境相连,进而形成了词法环境链。
-
在函数嵌套时,词法环境链的结构一目了然。内部函数词法环境的外部引用,直接指向它的外层函数词法环境;外层函数词法环境的外部引用,又指向更外层函数的词法环境。如此层层嵌套,所有词法环境最终都会指向全局词法环境。
-
作用域链在执行变量查找时,会沿着词法环境链,依次在各个词法环境的环境记录中查找变量。当在当前词法环境的环境记录中未找到目标变量时,就会依据当前词法环境的外部词法环境引用,去查找外层词法环境的环境记录,如此这般,作用域链顺着词法环境链逐层深入,直至找到变量或抵达全局词法环境。若到达全局词法环境仍未找到,则会抛出引用错误。
变量环境
变量环境也是一种词法环境,在 ES6 规范中,它专门用于存储使用 var
声明的变量。变量环境的结构和词法环境类似,也包含环境记录和外部词法环境引用。
执行上下文创建时,变量环境的环境记录会将 var
声明的变量进行初始化,把它们的值设为 undefined
,这就是所谓的 "变量提升" 现象。这意味着无论 var
声明的变量在代码中的实际位置如何,在执行上下文创建阶段就已经完成了初始化。例如:
javascript
console.log(x);
var x = 10;
在上述代码中,console.log(x)
语句在 var x = 10;
之前执行,但并不会报错,而是输出 undefined
。这是因为在执行上下文创建时,变量环境的环境记录已经对 x
进行了初始化,将其值设为 undefined
。实际代码的执行顺序就如同下面这样:
javascript
var x; // 变量提升,初始值为 undefined
console.log(x);
x = 10;
除了 var
声明的变量,函数声明也会受到变量环境的影响。在执行上下文创建时,函数声明会被提升到变量环境的环境记录中,这被称为 "函数声明提升"。这使得在函数声明之前就可以调用该函数。例如:
javascript
foo();
function foo() {
console.log('Hello, World!');
}
在执行上下文创建阶段,JavaScript 引擎扫描到函数声明 function foo() {... }
,然后将整个函数定义存储到变量环境的环境记录中。此时,虽然代码还未正式执行,但 foo
已经在当前作用域中存在了,并且指向了函数定义。
执行上下文创建完成后,就进入执行阶段,代码会按照书写顺序逐行执行。当执行到 foo();
时,JavaScript 引擎会在当前作用域的变量环境中查找 foo
。
由于在创建阶段已经将 foo
函数声明存储到了变量环境中,所以引擎能够找到 foo
对应的函数定义,然后调用该函数,进而执行函数内部的代码 console.log('Hello, World!');
,最终输出 Hello, World!
。
(二)执行阶段
在词法环境和变量环境创建完成后,进入执行阶段。在这个阶段,JavaScript 引擎会按照代码的顺序逐行执行,为变量赋值,调用函数等。在执行过程中,如果遇到新的函数调用,会再次创建新的函数执行上下文,并将其压入执行上下文栈中。
四、执行上下文栈
执行上下文栈(Execution Context Stack,简称 ECS),也被称为调用栈(Call Stack),是一个存储执行上下文的栈结构。它负责管理和维护 JavaScript 代码的执行顺序。
(一)执行上下文栈的工作原理
当 JavaScript 代码开始执行时,首先会创建全局执行上下文,并将其压入执行上下文栈的底部。随着代码的执行,每当遇到函数调用,就会创建相应的函数执行上下文,并将其压入栈顶。函数执行完毕后,其对应的执行上下文会从栈顶弹出,控制权交还给调用该函数的执行上下文,继续执行剩余的代码。例如:
javascript
function openHomePage() {
console.log('打开主页');
openArticlePage();
console.log('返回主页');
}
function openArticlePage() {
console.log('打开文章页面');
openCommentPage();
console.log('返回文章页面');
}
function openCommentPage() {
console.log('打开评论页面');
console.log('关闭评论页面');
}
openHomePage();
当上述代码开始执行时,会先创建全局执行上下文并将其压入执行上下文栈的底部。在全局执行上下文中,发现了 openHomePage()
函数调用语句。
调用 openHomePage
函数
openHomePage
函数的执行上下文被压入栈顶。- 执行
console.log('打开主页');
,输出打开主页
。 - 接着调用
openArticlePage()
函数。
调用 openArticlePage
函数
openArticlePage
函数的执行上下文被压入栈顶。- 执行
console.log('打开文章页面');
,输出打开文章页面
。 - 接着调用
openCommentPage()
函数。
调用 openCommentPage
函数
openCommentPage
函数的执行上下文被压入栈顶。- 执行
console.log('打开评论页面');
,输出打开评论页面
。 - 执行
console.log('关闭评论页面');
,输出关闭评论页面
。 openCommentPage
函数执行完毕,其执行上下文从栈顶弹出。
继续执行 openArticlePage
函数
- 此时栈顶是
openArticlePage
函数的执行上下文。 - 执行
console.log('返回文章页面');
,输出返回文章页面
。 openArticlePage
函数执行完毕,其执行上下文从栈顶弹出。
继续执行 openHomePage
函数
- 此时栈顶是
openHomePage
函数的执行上下文。 - 执行
console.log('返回主页');
,输出返回主页
。 openHomePage
函数执行完毕,其执行上下文从栈顶弹出。
最后回到全局执行上下文,全局执行上下文中没有其他代码需要执行,程序结束。
(二)执行上下文栈与递归函数
递归函数是指在函数内部调用自身的函数。执行上下文栈在递归函数的执行过程中起着关键作用。每一次递归调用都会创建一个新的函数执行上下文并压入栈中。如果递归没有正确的终止条件,执行上下文栈会不断增长,最终导致栈溢出错误(Stack Overflow Error)。例如:
javascript
function recursiveFunction() {
recursiveFunction();
}
recursiveFunction(); // 报错: Uncaught RangeError: Maximum call stack size exceeded
在这个例子中,recursiveFunction
没有终止条件,会一直递归调用自身,导致执行上下文栈不断被压入新的执行上下文,最终超出栈的最大容量,抛出栈溢出错误。
五、执行上下文中的作用域链
作用域链是执行上下文的一个重要组成部分,它决定了变量和函数的查找规则。在执行上下文创建时,会根据函数定义的位置构建作用域链。而词法环境在作用域链的构建中扮演着核心角色。
(一)作用域链的构建
作用域链本质上是由当前执行上下文的词法环境以及其所有父级执行上下文的词法环境组成的链表结构。在函数执行上下文中,首先会将自身的词法环境添加到作用域链的前端,然后依次添加父级执行上下文的词法环境。例如:
javascript
var globalVar = '全局变量';
function outerFunction() {
var outerVar = '外部函数变量';
function innerFunction() {
var innerVar = '内部函数变量';
console.log(globalVar); // 从当前作用域链开始查找,找到全局词法环境中的globalVar,输出: 全局变量
console.log(outerVar); // 在外部函数词法环境中找到outerVar,输出: 外部函数变量
console.log(innerVar); // 在当前函数词法环境中找到innerVar,输出: 内部函数变量
}
innerFunction();
}
outerFunction();
在innerFunction
的执行上下文中,其作用域链包含了innerFunction
自身的词法环境(其环境记录包含innerVar
)、outerFunction
的词法环境(其环境记录包含outerVar
)以及全局执行上下文的词法环境(其环境记录包含globalVar
)。
当innerFunction
查找变量时,会从作用域链的前端开始,依次查找每个词法环境的环境记录中的属性,直到找到匹配的变量或者到达作用域链的末尾。
(二)作用域链与闭包
闭包是指一个函数能够访问并记住其外部函数作用域中的变量,即便外部函数已经执行完毕。从实现机制来讲,闭包的形成既依赖于作用域链,更与词法环境紧密相关,它们共同构建了闭包能够维持对外部变量引用的基础 。例如 :
js
function createPerson(name) {
let privateName = name;
return {
getName: function () {
return privateName;
},
setName: function (newName) {
privateName = newName;
}
};
}
const person = createPerson('Alice');
console.log(person.getName()); // 输出: Alice
person.setName('Bob');
console.log(person.getName()); // 输出: Bob
-
当调用
createPerson('Alice')
时,会创建createPerson
函数的执行上下文,该执行上下文包含一个词法环境。这个词法环境的环境记录中会存储参数name
和变量privateName
,并且将name
的值'Alice'
赋给privateName
。 -
每个函数都有自己的作用域链,对于
createPerson
函数,其作用域链包含自身的词法环境和全局执行上下文的词法环境。在其内部定义了getName
和setName
两个方法。这两个方法在定义时,它们的作用域链就已经包含了createPerson
函数的词法环境。 -
当
createPerson
函数执行完毕后,按照常规情况,其执行上下文会从执行栈中弹出,对应的词法环境似乎应该被销毁。但是,由于getName
和setName
方法对privateName
变量存在引用,所以createPerson
函数的词法环境不会被销毁,而是被保留在内存中。这就形成了闭包。 -
getName
方法 :当调用person.getName()
时,JavaScript 引擎会沿着getName
方法的作用域链查找变量。首先在getName
方法自身的词法环境中查找privateName
,由于getName
方法没有自己定义privateName
变量,所以继续沿着作用域链到createPerson
函数的词法环境中查找,最终找到了privateName
变量,并返回其值'Alice'
。 -
setName
方法 :当调用person.setName('Bob')
时,同样沿着setName
方法的作用域链,在createPerson
函数的词法环境中找到privateName
变量,并将其值修改为'Bob'
。 -
由于外部代码无法直接访问
createPerson
函数的词法环境,所以privateName
变量对于外部来说是私有的。外部代码只能通过person
对象的getName
和setName
方法来间接访问和修改privateName
变量,这就实现了数据的封装和保护。