执行上下文在 JavaScript 是非常重要的基础知识,想要理解 JavaScript 的执行过程,执行上下文 是你必须要掌握的知识。否则只能是知其然不知其所以然。
理解执行上下文有什么好处呢?
它可以帮助你更好的理解代码的执行过程,作用域,闭包等关键知识点。特别是闭包它是 JavaScript 中的一个难点,当你理解了执行上下文在回头看闭包时,应该会有豁然开朗的感觉。
这篇文章我们将深入了解 执行上下文,读完文章之后你应该可以清楚的了解到 JavaScript 解释器到底做了什么,为什么可以在一些函数和变量之前使用它,以及它们的值是如何确定的。
什么是执行上下文
在 JavaScript 中运行代码时,代码的执行环境非常重要,通常是下列三种情况:
- Global code:代码第一次执行时的默认环境。
- Function code:函数体中的代码
- Eval code:eval 函数内执行的文本(实际开发中很少使用,所以见到的情况不多)
在网上你可以读到很多关于作用域的文章,为了便于理解本文的内容,我们将 执行上下文 当作代码的 执行环境/作用域。现在就让我们看一个例子:它包括 全局和函数/本地执行上下文。
上面的例子我们看到,紫色的框代表全局上下文,绿色、蓝色、橙色代表三个不同的函数上下文。全局上下文执行有一个,它可以被其他上下文访问到。
你可以有任意数量的函数上下文,每个函数在调用时都会创建一个新的上下文,它是一个私有范围,函数内部声明的所有东西都不能在函数作用域外访问到。
上面的例子中,函数内部可以访问当前上下文之外声明的变量,但是外部却不能访问函数内部的变量/函数。这到底是为什么?其中的代码是如何执行的?
执行上下文栈
浏览器中的 JavaScript 解释器是单线程实现的。这意味着在浏览器中一次只能做一件事情。而其他的行为或事件都会在执行栈中排队等待。如图:
我们知道,当浏览器第一次加载脚本时,默认情况下,它会进入全局上下文。如果在全局代码中调用了一个函数,则代码的执行会进入函数中,此时会创建一个新的执行上下文,它会被推到执行上下文栈中。
如果在这个过程中函数内部调用了另一个函数,会发生同样的事情,代码的执行会进入函数中,然后创建一个新的执行上下文,它会被推到上下文栈 的顶部。浏览器始终执行栈顶部的执行上下文。
一旦函数完成执行,当前的执行上下文将从栈的顶部弹出,然后继续执行下面的,下面程序演示了一个递归函数的执行上下文情况。
(function foo(i) {
if (i === 3) {
return;
}
else {
foo(++i);
}
}(0));
自己调用自己三次,每次将 i 递增 1,每次函数 foo 被调用的时候,就会创建一个新的执行上下文。一旦当前上下文执行完毕之后,它就会从栈中弹出并转移到下面的上下文中,直到全局上下。
执行上下文栈的 5 个关键点:
- 单线程
- 同步执行
- 只有一个全局上下文
- 任意数量的函数上下文
- 每个函数调用都会创建一个新的执行上下文,包括自己调用自己
详解执行上下文
到此,我们知道每次调用一个函数时,都会创建一个新的执行上下文。但是在 JavaScript 解释器中,每次调用执行上下文会有两个阶段:
创建阶段
- 创建作用域链
- 创建变量,函数,arguments列表。
- 确定 this 的指向
执行阶段
- 赋值,寻找函数引用,解释/执行代码
执行上下文可以抽象为一个对象它具备三个属性:
executionContextObj = {
'scopeChain': { /* variableObject + all parent execution context's variableObject */ },
'variableObject': { /* function arguments / parameters, inner variable and function declarations */},
'this': {}
}
活动/变量对象[AO/VO]
executionContextObj
对象在函数调用时创建,但它是在函数真正执行之前就创建的,这就是我们所说的第一个阶段 创建阶段,此时解释器通过扫描函数的传入参数,arguments
,本地函数声明,局部变量声明来创建executionContextObj
对象。将结果变成 variableObject
放入 executionContextObj
中。
解释器执行代码时的大致描述:
- 调用函数
- 在执行代码时,创建执行上下文
- 进入创建阶段:
初始化作用域链
创建变量对象(variableObject)
创建参数对象(arguments object),检查参数的上下文,初始化名称和值,并创建引用副本
扫描上下文中的函数声明
每发现一个函数,就会在 variableObject 中创建一个名称,保存函数的引用
如果名称已经存在,则覆盖引用
扫描上下文中的变量声明
每发现一个变量,就在 variableObject 中创建一个名称,并初始化值为 undefined
如果变量名已经存在,什么都不做,继续扫描
确定上下文中的 this 指向
- 执行代码阶段:
在上下文中执行/解释代码,在代码逐行执行时进行变量复赋值
让我们看一个例子:
function foo(i) {
var a = 'hello';
var b = function privateB() {};
function c() {}
}
foo(22);
foo(22)
函数执行的时候,创建阶段如下:
fooExecutionContext = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: undefined,
b: undefined
},
this: { ... }
}
如上所述,除了形参 i 和 arguments外,在创建阶段我们只把变量进行声明而不进行赋值。
在创建阶段完成后,程序会进入函数中执行代码,如下所示:
fooExecutionContext = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: 'hello',
b: pointer to function privateB()
},
this: { ... }
}
声明提前
网上很多关于声明提前的内容,它是用来解释变量和函数在声明时会被提前到作用域的顶部。但是并没有人详细解释为什么会发生这种情况,有了刚才关于解释器如何创建活动对象(AO)的认知,我们将很容易看出原因。例如:
(function() {
console.log(typeof foo); // function pointer
console.log(typeof bar); // undefined
var foo = 'hello',
bar = function() {
return 'world';
};
function foo() {
return 'hello';
}
}())
我们现在可以回答如下问题:
为什么我们可以在声明之前访问foo
?
在执行阶段之前,我们已经完成了创建阶段,此时变量/函数已经被创建,所以当函数执行的时候 foo
可以被访问到。
foo
被声明了两次,为什么 foo
显示的是 function
而不是 undefined
或者 string
?
虽然 foo
被声明了两次,但是我们在创建阶段中说到,函数是在变量之前创建在变量对象中,当变量对象中名称已经存在时,变量声明什么也不做。
因此 foo
会被先创建为函数 function foo()
的引用,当执行到 var foo
时发现变量对象中已将存在了,所以此时什么也不做,而是继续扫描。
为什么 bar
是 undefined
?
bar
实际上是一个变量只不过它的值是函数,而变量在创建阶段的值为 undefined
。
总结
我们再来梳理下重要的知识点:
- 首先在程序执行时会创建一个全局的执行上下文,有且只有一个。
- 函数在每次调用时就会创建一个函数上下文,可以有很多。
- 函数上下文可以访问全局上下文的内容,反之则不行。
- 创建的上下文会被推入到上下文栈中,然后从顶部开始依次执行。
- 执行上下文会分为两个阶段:创建阶段和执行阶段。
- 创建阶段会先进行函数声明和变量声明提前。
- 创建阶段会先进行函数声明,然后进行变量声明,同时会被放入变量对象中,如果变量对象中已经存在:函数则进行引用的覆盖,变量则什么都不做。
- 执行阶段才会进行赋值和运行。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。