深入JavaScript执行上下文
作者:Seiya
时间:2019年06月04日
前言
上一篇文章 《理解 JavaScript 编译执行流程》的示例中,我们可以大概了解到 JavaScript 代码编译执行的过程。对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。
JavaScript 引擎在编译 JavaScript 代码时,除了前面《理解 JavaScript 编译执行流程》提到的编译器的工作外,还会创建执行上下文,下面我们会具体进行解释。
执行上下文
执行上下文(简称-EC)是ECMA-262标准里的一个抽象概念,用于同可执行代码(executable code)概念进行区分。标准规范没有从技术实现的角度定义 EC 的准确类型和结构,这应该是具体实现 ECMAScript 引擎时要考虑的问题。
执行上下文栈
一个执行上下文可以激活另一个上下文,就好比一个函数调用了另一个函数,然后一层一层调用下去。逻辑上来说,这种实现方式是栈,我们可以称之为上下文堆栈(ECStack)。堆栈底部永远都是全局上下文(global context),而顶部就是当前(活动的)执行上下文。堆栈在EC类型进入和退出上下文的时候被修改(推入或弹出)。
激活其它上下文的某个上下文被称为调用者(caller) 。被激活的上下文被称为被调用者(callee) ,被调用者同时也可能是调用者。
当一段程序开始时,会先进入全局执行上下文环境 [global execution context]
, 这个也是堆栈中最底部的元素。此全局程序会开始初始化,初始化生成必要的对象 [objects]
和函数 [functions]
。在此全局上下文执行的过程中,它可能会激活一些方法(当然是已经初始化过的),然后进入他们的上下文环境,然后将新的元素压入堆栈。在这些初始化都结束之后,这个系统会等待一些事件(例如用户的鼠标点击等),会触发一些方法,然后进入一个新的上下文环境。
可执行代码类型
可执行代码的类型这个概念与执行上下文的抽象概念是有关系的。在某些时刻,可执行代码与执行上下文完全有可能是等价的。下面我们举例介绍三种可执行代码类型:
类型一
:全局代码(global)这种类型的代码是在"程序"级处理的:例如加载外部的js文件或者本地
<script>
标签内的代码。全局代码不包括任何function体内的代码。在初始化(程序启动)阶段,ECStack是这样的:
ECStack = [ globalContext ]
类型二
:函数代码(function)当进入 funtion 函数代码(所有类型的funtions)的时候,ECStack 被压入新元素。需要注意的是,具体的函数代码不包括内部函数(inner functions)代码。如下所示,我们使函数自己调自己的方式递归一次:
(function foo(bar) { if (bar) { return; } foo(true); })();
那么,ECStack以如下方式被改变:
// 第一次foo的激活调用 ECStack = [ <foo> functionContext globalContext ]; // foo的递归激活调用 ECStack = [ <foo> functionContext – recursively <foo> functionContext globalContext ];
每次 return 的时候,都会退出当前执行上下文的,相应地ECStack就会弹出,栈指针会自动移动位置。一个抛出的异常如果没被截获的话也有可能从一个或多个执行上下文退出。相关代码执行完以后,ECStack只会包含全局上下文(global context),一直到整个应用程序结束。
类型三
:eval代码(eval)eval 有一个概念:调用上下文(calling context)。
eval('var x = 10'); (function foo() { eval('var y = 20'); alert(y); })(); alert(x); // 10 alert(y); // "y" 提示没有声明
ECStack的变化过程:
ECStack = [ globalContext ]; // eval('var x = 10'); ECStack.push( evalContext, callingContext: globalContext ); // eval exited context ECStack.pop(); // foo funciton call ECStack.push(<foo> functionContext); // eval('var y = 20'); ECStack.push( evalContext, callingContext: <foo> functionContext ); // eval执行完毕 ECStack.pop(); // foo执行完毕 ECStack.pop();
每一种代码的执行都需要依赖自身的上下文。当然global的上下文可能涵盖了很多的function和eval的实例。函数的每一次调用,都会进入函数执行中的上下文,并且来计算函数中变量等的值。eval函数的每一次执行,也会进入eval执行中的上下文,判断应该从何处获取变量的值。
执行上下文结构
一个执行的上下文可以抽象的理解为object。每一个执行的上下文都有一系列的属性(我们称为上下文状态),他们用来追踪关联代码的执行进度。下图就是一个 context 的结构:
实际上,在创建执行上下文的过程中,执行了以下三个过程:
创建变量对象
创建作用域链
确定 this 指向
下面我们就大概解释这三个过程:
变量对象
变量对象
(Variable Object) 是与执行上下文相关的 数据作用域
。它是与上下文关联的特殊对象,用于存储被定义在上下文中的 变量
和 函数声明
。
注意:
函数表达式[function expression]是不包含在VO[variable object]里面的。
变量对象是一个抽象的概念,不同的上下文中,它表示使用不同的 object。例如,在 global 全局上下文中,变量对象也是全局对象自身[global object](这就是我们可以通过全局对象的属性来指向全局变量)。举例如下:
var foo = 10;
function bar() {} // 函数声明
(function baz() {}); // 函数表达式
console.log(
this.foo == foo, // true
window.bar == bar // true
);
console.log(baz); // 引用错误,baz没有被定义
此时,全局上下文中的变量对象(VO)会有如下属性:
如上所示,函数 baz
如果作为函数表达式则不被不被包含于变量对象。这就是在函数外部尝试访问产生引用错误(ReferenceError) 的原因。
注意:
JavaScript 和其他语言相比(比如C/C++),仅有函数能够创建新的作用域。在函数内部定义的变量与内部函数,在外部非直接可见并且不污染全局对象。使用 eval 的时候,我们同样会使用一个新的(eval创建)执行上下文。eval 会使用全局变量对象或调用者的变量对象(eval的调用来源)。
那函数以及自身的变量对象又是怎样的呢?在一个函数上下文中,变量对象被表示为活动对象。
活动对象
活动对象
(activation object) 在函数被调用者调用激活后被创建,它包含普通参数(formal parameters) 与特殊参数(arguments)对象(具有索引属性的参数映射表)。
提示:
活动对象在函数上下文中作为变量对象使用。除了函数的变量对象(存储变量与函数声明)保持不变之外,还包含以及特殊对象 arguments
。
下面我们举一个例子:
function foo(x, y) {
var z = 30;
function bar() {} // 函数声明
(function baz() {}); // 函数表达式
}
foo(10, 20);
foo
函数上下文的下一个激活对象(AO)如下图所示:
作用域链
作用域链
是一个对象列表(list of objects) ,用以检索上下文代码中出现的标识符(identifiers) 。
作用域链的原理和原型链很类似,如果这个变量在自己的作用域中没有,那么它会寻找父级的,直到最顶层。标示符[Identifiers]可以理解为变量名称、函数声明和普通参数。
在一般情况下,一个作用域链包括父级变量对象(作用域链的顶部)、函数自身变量VO和活动对象。不过,有些情况下也会包含其它的对象,例如在执行期间,动态加入作用域链中的,例如:with 或者 catch 语句。
注:
with-objects 指的是 with 语句,产生的临时作用域对象;catch-clauses 指的是 catch 语句,如catch(e),这会产生异常对象,导致作用域变更。
我们来看一个例子:
var x = 10;
(function foo() {
var y = 20;
(function bar() {
var z = 30;
// "x"和"y"是自由变量
// 会在作用域链的下一个对象中找到(函数”bar”的互动对象之后)
console.log(x + y + z);
})();
})();
然后假设一个__parent__的属性,它是指向作用域链的下一个对象,使用__parent__的概念,我们可以把上面的代码演示成如下的情况:
在代码执行过程中,如果使用 with 或者 catch 语句就会改变作用域链。而这些对象都是一些简单对象,他们也会有原型链。这样的话,作用域链会从两个维度来搜寻:
原本的作用域链;
每一个链接点的作用域的链(如果这个链接点是有prototype的话);
举例说明:
Object.prototype.x = 10;
var w = 20;
var y = 30;
// 在 SpiderMonkey 全局对象里
// 例如,全局上下文的变量对象是从"Object.prototype"继承到的
// 所以我们可以得到“没有声明的全局变量”
// 因为可以从原型链中获取
console.log(x); // 10
(function foo() {
// "foo" 是局部变量
var w = 40;
var x = 100;
// "x" 可以从"Object.prototype"得到,注意值是10哦
// 因为{z: 50}是从它那里继承的
with ({z: 50}) {
console.log(w, x, y , z); // 40, 10, 30, 50
}
// 在"with"对象从作用域链删除之后
// x 又可以从 foo 的上下文中得到了,注意这次值又回到了100
// w 也是局部变量
console.log(x, w); // 100, 40
// 在浏览器里
// 我们可以通过如下语句来得到全局的w值
console.log(window.w); // 20
})();
结构如下图所示。这表示,在我们去搜寻__parent__之前,首先会去__proto__的链接中:
注意
不是所有的全局对象都是由Object.prototype继承而来的。
当一个上下文终止之后,其状态与自身将会被销毁(destroyed) ,同时内部函数将会从外部函数中返回。此外,这个返回的函数之后可能会在其他的上下文中被激活,这就涉及到闭包相关的知识点了,后续文章我们会介绍到。
this指向
this 是和执行的上下文环境息息相关的一个特殊对象。因此,它也可以称为上下文对象[context object]。
重要
任何对象都可以作为上下文的this值。通常,this 被错误地,描述为变量对象的属性。实际上,this 是执行上下文环境的一个属性,而不是某个变量对象的属性
这个特点很重要,因为和变量不同,this是没有一个类似搜寻变量的过程。当你在代码中使用了this,这个 this的值就直接从执行的上下文中获取了,而不会从作用域链中搜寻。this的值只取决中进入上下文时的情况。
在global context(全局上下文)中,this 的值就是指全局这个对象,这就意味着,this值就是这个变量本身。在函数上下文中,this 会根据每次的函数调用而成为不同的值。例如:
// "foo"函数里的alert没有改变
// 但每次激活调用的时候this是不同的
function foo() { alert(this) }
// 调用者激活 "foo"这个callee,并且提供"this"给这个callee
foo(); // this 指向全局对象
foo.prototype.constructor(); // foo.prototype
var bar = { baz: foo };
bar.baz(); // this 指向bar
(bar.baz)(); // this 指向bar
(bar.baz = bar.baz)(); // this 指向全局对象
(bar.baz, bar.baz)(); // 也是全局对象
(false || bar.baz)(); // 也是全局对象
var otherFoo = bar.baz;
otherFoo(); // 还是全局对象
如果要深入思考每一次函数调用中,this值的变化,后续文章会进行详细讨论。
示例
文章的最后,我们来看一个示例,了解整个执行上下文在创建以及执行过程中的全过程:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
具体执行分析:
第一步
:进入预编译阶段,创建全局执行上下文,全局上下文被压入执行上下文栈;ECStack = [ globalContext ];
第二步
:初始化全局上下文环境(初始化变量对象、作用域链,确定 this 指向),将函数体赋值给变量 checkscope;globalContext = { VO[global]: { scope: undefined, checkscope: function () { var scope = "local scope"; function f(){ return scope; } return f(); } }, Scope: [globalContext.VO], this: globalContext.VO }
第三步
:进入执行阶段,将 "global scope" 赋值给变量 scope,并开始执行 checkscope();globalContext = { VO[global]: { scope: "global scope", checkscope: function () { var scope = "local scope"; function f(){ return scope; } return f(); } }, Scope: [globalContext.VO], this: globalContext.VO }
第四步
:执行 checkscope 函数前,先进入预编译阶段,开始创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈;ECStack = [ checkscopeContext, globalContext ];
第五步
:初始化 checkscope 函数执行上下文环境(初始化活动对象、作用域链,确定 this 指向),将函数体赋值给变量 f;checkscopeContext = { AO: { arguments: { length: 0 }, scope: undefined, f: function f(){ return scope } }, Scope: [AO, globalContext.VO], this: global }
第六步
:进入执行阶段,开始执行 checkscope 函数,将 "local scope" 赋值给变量 scope,并开始执行 f();checkscopeContext = { AO: { arguments: { length: 0 }, scope: "local scope", f: function f(){ return scope } }, Scope: [AO, globalContext.VO], this: global }
第七步
:执行 f 函数前,先进入预编译阶段,开始创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈;ECStack = [ fContext, checkscopeContext, globalContext ];
第八步
:和之前的步骤相同,初始化 f 函数执行上下文环境;fContext = { AO: { arguments: { length: 0 } }, Scope: [AO, checkscopeContext.AO, globalContext.VO], this: global }
第九步
:开始执行 f 函数,沿着作用域链查找 scope 值,返回 scope 值;ECStack = [ fContext, checkscopeContext, globalContext ];
第十步
:f 函数执行完毕,f 函数上下文从执行上下文栈中弹出;ECStack = [ checkscopeContext, globalContext ];
第十一步
:checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出;ECStack = [ globalContext ];