JavaScript执行上下文与调用栈
先来看一段经典的题目:
function Fn() {
getName = function () {
console.log(1, this)
}
console.log(this)
return this
}
Fn.getName = function () {
console.log(2, this)
}
Fn.prototype.getName = function () {
console.log(3, this)
}
var getName = function () {
console.log(4, this)
}
function getName() {
console.log(5, this)
}
// no.1
Fn.getName()
// no.2
getName()
// no.3
Fn().getName()
// no.4
getName()
// no.5
new Fn.getName()
// no.6
new Fn().getName()
// no.7
new new Fn().getName()
乍一看,打印的结果应该是:
// no.1 静态方法,打印 2, f Fn {...}
// no.2 最后一个覆盖全局,打印 5, Window {...}
// no.3 Fn执行打印 Window {...},再调用全局,打印 5, Window {...}
// no.4 再次执行全局,打印 5, window {...}
// no.5 new出实例,调用原型方法,打印 3, Fn {...}
// no.6 new出实例,调用原型方法,打印 3, Fn {...}
// no.7 new出实例,再new实例对象报错
但是,实际打印的结果是:
// no.1 打印 2, f Fn {...}
// no.2 打印 4, window {...}
// no.3 打印 window {...}, 1, window {...}
// no.4 打印 1, window {...}
// no.5 打印 2, Fn.getName {}
// no.6 打印 Fn {}, 3, Fn {}
// no.7 打印 Fn {}, 3, Fn.getName {}
先用断点调试探探路,究竟怎么个事儿?
在no.1
调用处打上断点,从全局
可以看到Fn的静态方法getName和原型方法getName。这不难理解,所以no.1
打印2
和Fn {}
,no.5
和no.6
打印的结果应该都是3
和Fn {}
才对,难道new Fn
和new Fn()
不同?
console.log(new Fn) // Fn {}
console.log(new Fn()) // Fn {}
在没有参数的情况下,看上去没有太大不同,但为什么结果不同呢?在no.5
和no.6
处打上断点,首先断点呈现出现差异:no.6
在new
处和getName
处出现两个可以标记的点。单步调试no.5
,Fn.getName
进入调用堆栈,此时的this
指向Fn.getName
。继续单步调试no.6
,Fn
进入调用堆栈,此时的this
指向Fn
。继续单步调试,Fn.prototype.getName
进入调用堆栈,此时的this
指向Fn
。
no.5
处的new
将Fn.getName()
作为构造函数,而no.6
处的new
将Fn()
作为构造函数。于是no.5
打印2
,no.6
打印3
。假如no.5
处不是函数而是属性,那么就会产生报错,所以在编写代码的时候应尽量加上()
减少歧义来保证预期的效果。接着再看no.7
,就可以得出正确的执行结果。在no.7
处打上断点进行单步调试,先执行了new Fn()
,然后执行了new Fn.prototype.getName()
。因此先后打印Fn
、3
、Fn.getName
,当然这里Fn.getName
的this
指向Fn
。
再看看余下的调用情况,给它们加上断点。Fn.getName()
执行完成,全局中的getName
是var
所声明,于是no.2
打印4
。Fn()
执行完成,全局中的getName
变为Fn()
内部的声明。Fn()
于返回this
,也就是window
,再调用getName()
,no.3
打印1
。再次执行getName
,全局定义未变,no.4
打印1
。
那么问题来了,function
声明的getName
为什么先后被var
和Fn()
的声明所覆盖呢?
执行上下文
简单地说,执行上下文是一个抽象的概念,是一个eval和执行JavaScript代码的环境。每当任何代码在JavaScript中运行时,它都在执行上下文中运行。
JavaScript中有三种类型的执行上下文:
- 全局执行上下文(Global Execution Context,GEC):这是默认或基本执行上下文。不在任何函数内部的代码位于全局执行上下文中。它执行两件事:它创建了一个全局对象(Global Object,GO),它是一个窗口对象(在浏览器的情况下),并将 this 的值设置为等于全局对象。一个程序中只能有一个全局执行上下文。
- 函数执行上下文(Functional Execution Context,FEC):每次调用函数时,都会为该函数创建一个全新的函数对象(Activation Object, AO)。每个函数都有自己的执行上下文,但它是在调用函数时创建的。可以有任意数量的函数执行上下文。每当创建一个新的执行上下文时,它都会按照定义的顺序执行一系列步骤。
- Eval函数执行上下文(Eval Functional Execution Context,EFEC):
eval
函数是一个应该不惜一切代价避免的函数,每当JavaScript引擎遇到eval
函数时,就会构造一个执行上下文并将其推入调用堆栈。由于eval
的恶意性质,它不被推荐使用。
执行上下文的创建分两个阶段:1. 创建阶段,2. 执行阶段。
创建阶段
执行上下文是在创建阶段创建的,在创建阶段会发生以下事情:
词法环境(Lexical Environment)
词法环境组件是基于JavaScript代码的词法嵌套结构的结构,其定义标识符与变量和函数的值的关联。绑定是将标识符与变量和函数的值相关联的过程。
词法环境是一种规范类型,用于基于ECMAScript代码的词法嵌套结构定义标识符与特定变量和函数的关联。词法环境由环境记录和对外部词法环境的可能空引用组成。
简单地说,词法环境是一个包含标识符-变量映射的结构。简单地说,词法环境是保存标识符-变量映射的结构。这里的标识符是指变量、函数的名称,变量是对实际对象(包括函数对象和数组对象)或原语值的引用。每个词法环境有三个组件:
- 环境记录(Environment Record):环境记录是存储词法环境中变量和函数声明的地方。
- 声明性环境记录(Declarative environment record):这主要由在函数执行上下文中创建的词法环境使用。它记录(存储)函数声明和变量声明。声明性环境记录还存储一个 arguments 对象,其中包含传递给函数的参数的长度(数量)以及参数及其索引的映射。
- 对象环境记录(Object environment record):这主要由全局执行上下文中创建的词法环境使用。它存储函数声明、变量声明和全局绑定对象(浏览器中的窗口对象)。
- 全局环境记录(Global Environment Record):虽然全局环境记录在理论上是单个记录,但它被声明为封装了对象和声明性环境记录的组合。它没有外部环境,因为它是
[[OuterEnv]]
为null
。
- 外部引用环境(Reference to the outer environment):对外部环境的引用意味着它可以访问其外部词法环境。这意味着JavaScript引擎可以在外部环境中寻找变量,如果它们在当前词法环境中找不到。
- This绑定(This binding):在该组件中,确定或设置 this 的值。在全局执行上下文中,
this
的值是指全局对象。在函数执行上下文中,this
的值取决于函数的调用方式。如果它被一个对象引用调用,那么this
的值被设置为该对象,否则,this
的值被设置为全局对象或undefined
(在严格模式下)。
变量环境(Variable Environment)
变量环境组件是一个词法环境,它定义了标识符和变量值之间的关系,而不是函数。
ExecutionContext = {
LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>
}
这两者之间的区别在于标识符在变量中是有界的。词法环境存储变量 let
和const
和函数值的标识符绑定,而变量环境仅存储变量 var
值的标识符绑定。
执行阶段
在完成创建步骤之后,将进入执行阶段。JavaScript引擎再次扫描代码中的函数,用变量的值更新变量对象,然后运行代码,这被称为执行阶段。变量绑定初始化、变量赋值、可变性和不变性检查、变量绑定删除、函数调用执行等等都在此阶段发生。
回收阶段
在执行阶段之后,垃圾回收器将运行,并删除不再使用的变量绑定。
调用堆栈
函数调用形成若干帧组成的栈,在 JavaScript 中,调用栈(call stack)是用来存储函数执行的上下文的对象。当 JavaScript 引擎执行函数时,它会将当前函数的执行上下文压入调用栈中,当函数执行完毕后,它会将该函数的执行上下文从调用栈中弹出。
调用栈经常被用于存放子程序的返回地址。在调用任何子程序时,主程序都必须暂存子程序执行完毕后应该返回到的地址。因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身执行完毕后再行取回。在递归程序中,每一层次递归都必须在调用栈上增加一条地址,因此如果程序出现无限递归(或仅仅是过多的递归层次),调用栈就会产生栈溢出。
调用栈的主要功能是存放返回地址。除此之外,调用栈还用于存放:
- 本地变量:子程序的变量可以存入调用栈,这样可以达到不同子程序间变量分离开的作用。
- 参数传递:如果寄存器不足以容纳子程序的参数,可以在调用栈上存入参数。
- 环境传递:有些语言(如Pascal与Ada)支持“多层子程序”,即子程序中可以利用主程序的本地变量。这些变量可以通过调用栈传入子程序。
当JavaScript引擎第一次遇到您的脚本时,它会创建一个全局执行上下文并将其推送到当前执行堆栈。每当引擎找到一个函数调用时,它就为该函数创建一个新的执行上下文,并将其推到堆栈的顶部。引擎执行其执行上下文位于堆栈顶部的函数。当此函数完成时,其执行堆栈将从堆栈中弹出,内存回收。执行将返回到前一个执行上下文,并继续执行下一个,执行完整个代码。
堆栈(stack)又称为栈或堆叠,是计算机科学中的一种抽象资料类型,只允许在有序的线性资料集合的一端(称为堆栈顶端,top)进行加入数据(push)和移除数据(pop)的运算。因而按照后进先出(LIFO, Last In First Out)的原理运作,堆栈常用一维数组或链接串列来实现。常与另一种有序的线性资料集合队列相提并论。
堆栈的基本特点:
- 先入后出,后入先出。
- 除头尾节点之外,每个元素有一个前驱,一个后继。
堆栈是一种遵循后进先出(LIFO)原则的数据结构。然而,执行堆栈是跟踪在代码执行期间创建的所有执行上下文的堆栈。
- 默认情况下,全局执行上下文最初以global()对象的形式添加(推送)到执行堆栈上。
- 当调用或调用函数时,函数执行上下文被添加到堆栈中。
- 被调用的函数被执行并从堆栈中删除(弹出),以及它的执行上下文。
总结
在创建阶段,JS引擎创建一个全局执行上下文来执行全局代码,它应该是这样的:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Fn: <f Fn> {
getName: <f>,
arguments: <null>,
caller: <null>,
length: <length>,
name: 'Fn',
prototype: {
getName: <f>,
constructor: <f Fn>,
[[Prototype]]: <Object>
},
[[FunctionLocation]]: <FunctionLocation String>,
[[Prototype]]: <f>,
[[Scopes]]: <Scopes Array>,
}
}
outerEnv: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
getName: <f>
},
outerEnv: <null>,
ThisBinding: <Global Object>
}
}
在执行阶段,值被分配给变量。执行代码在var
声明的getName
之前,全局保持创建阶段function
声明的函数体。在var
声明的getName
之后,所在的函数体赋值给全局。Fn()
执行,getName
作用域提升为全局,所在的函数体赋值给全局。因此全局getName
在no.2
打印4
,Fn()
执行后no.3
、no.4
打印1
。