JSinDeep1:探索执行环境
(Execution Context)-ES3篇
声明
JSinDeep系列文章主要内容为作者对ECMA-262标准中一些概念的理解和探究,同时意在帮助大家快速理解。本着严谨的态度,同时又需避免API式的枯燥细节罗列。文章会以适当插图、例子去诠释概念,致力于通俗易懂。更具体、严谨、完整的描述建议阅读ECMA262文档。碍于作者水平有限,文中若有错误,欢迎大家批评指正。
*其它版本: English-Blog, English-PDF, Chinese-Blog, Chinese-PDF
概要
在我们写JavaScript代码时会定义一些变量、函数等。解释器在执行这些代码时是如何处理并找到我们定义的这些数据的?在程序执行时,引用这些变量等操作的背后都发生了什么?本文主要探讨ECMA-262-3标准中的执行环境(Execution Context)及与之相关的一些内部机制和模型。
定义
当程序执行的控制权转移至ECMAScript可执行代码时,会进入到一个执行环境中(Execution Context,缩写为EC)。在一个EC内也可能进入到一个新的EC,这些EC逻辑上1会形成一个栈(Stack)。
EC是程序运行时动态创建的。例如:每一个函数在被调用时都会创建一个EC,重复调用函数(包含递归调用的情形)会重新创建新的EC,而后放置在逻辑栈中。逻辑栈会在程序运行时随着新的函数调用、函数return、未处理的异常抛出等情况动态变化,但逻辑栈的最顶部总是当前正运行的EC,它的最底部总是全局EC(Global Context)。
图:运行时的逻辑EC栈
1这里的“逻辑上”是因ECMA262标准避免限制实现者的思路,具体实现在遵循标准的前提下不受其它限制。因此这里所说的逻辑上的栈在具体实现时未必是通常意义上的栈。后面我们简称为:“逻辑栈”。
可执行代码的分类
由定义部分可知,“每一段可执行代码都有对应的EC”,为方便按不同情况讨论,先了解几种可执行代码的类型。
a). 全局代码
全局代码是指在任何被解析为函数体的代码以外的最外层的代码。
var i = 0; |
// 全局代码 |
function foo() { |
// foo函数定义部分为全局代码 |
var j = 1; |
// foo函数体内为函数代码 |
} |
|
var k = 2; |
// 全局代码 |
*字符串中的动态被eval执行的代码除外,在下一类Eval代码中介绍
在程序执行前会初始化全局EC, 逻辑栈(Logical Stack,简称为LS)的结构类似于:
[伪代码]
LS = {
globalContext
}
b). 函数代码
函数代码是指函数体中的代码。某一个函数体内的函数代码并不包含其内联的其它函数的函数体中的代码。
var i = 0; |
// 全局代码 |
function foo() { |
// 全局代码 |
var j = 0; |
// foo的函数代码 |
function inner() { |
// foo的函数代码 |
var k = 0; |
// inner的函数代码 |
} |
|
if (i++ == 0) foo(); |
// foo的函数代码,递归调用一次foo |
} |
|
foo(); |
// 调用一次foo |
|
|
逻辑栈的结构类似于: |
|
[伪代码] |
|
|
|
LS = { |
// 第一次调用foo |
<foo>functionContext, |
// 当前激活的EC |
globalContext |
|
} |
|
|
|
LS = { |
// 第二次递归调用foo |
<foo>functionContextRecursively |
// 当前激活的EC |
<foo>functionContext, |
// 等待<foo>functionContextRecursively |
|
// 返回并退出LS后激活 |
globalContext |
|
} |
|
c). Eval代码
当调用内置的eval方法且传入参数为字符串(而不是函数对象)时,该字符串中的代码为Eval代码。
eval(“function foo2() {/* doSth(); */}”); |
// 字符串内为eval代码 |
eval(function foo3() {/* 函数代码 */}); |
// 多行注释外为全局代码 |
*ECMA262原文中有精确的术语去定义该部分,本文重在辅助理解,文字表达在严谨和容易理解上可能有所折中。
对于Eval代码,根据eval语句所在位置有所区分,首先我们引入一个概念:调用环境(Calling Execution),调用环境是指Eval函数调用位置所在的EC。比如在全局EC中调用了eval(),则该Eval代码的调用环境为该全局EC,如果在某函数<func>functionContext中调用了eval(),则该Eval代码的调用环境为该函数<func>functionContext。
eval(‘var x = 10’); |
// 影响globalContext |
(function foo() { |
|
eval(‘var y = 20’); |
//影响<foo>functionContext, |
}()); |
|
alert(x); |
// 10 |
alert(y); |
// 运行时错误,y未定义 |
|
|
[伪代码] |
|
LS = { |
// 调用eval('var x = 10'); |
evalCallingContext : globalContext, |
|
globalContext |
|
} |
|
|
|
LS = { |
// eval('var x = 10');结束调用 |
globalContext |
// evalCallingContext出栈 |
} |
|
|
|
LS = { |
// 调用foo, <foo>functionContext入栈 |
evalCallingContext : <foo>funcitonContext, |
// 调用eval('var y = 20'); // evalCallingContext入栈 |
globalContext |
|
} |
|
*Eval代码中定义的变量属性不添加任何标签,可用delete删除。区别于正常的使用var关键字定义的变量会有{dontdelete}标签,delete具有{dontdelete}标签的属性将返回false且不会修改该属性。由于Firebug、Chrome Console都采用eval的方式执行控制台代码,因此输入:var a = 1;console.log(delete window.a); 会返回true,但正常写在全局代码里的该语句会返回false。动手试一试吧。
变量初始化
每个EC都对应一个变量对象(Variable Object,缩写为VO),在EC的初始化阶段,该EC范围内定义的变量、函数会作为属性添加到VO中。对于函数代码的情形,函数的参数也将作为属性添加到VO中。
*在ES5中VO会被新的词法环境(Lexical Environment)取代,在下节《2:EC in ES5》中我们再详细讨论。
更具体的,VO的初始化按顺序依照如下步骤进行:
a). 参数处理
对于函数代码,每个函数传入的参数都会添加到VO中,name为函数名,value为传入的值。未传入的参数依然会在VO中,name依然为参数名,value为undefined。
function foo(x, y, z) { } foo(1, 2); |
[伪代码] VO(<foo>functionContext) = { x : 1, y : 2, z : undefined } |
b). 函数定义处理
对于每一个函数定义(注意不是函数表达式),在VO中创建一个属性,name是函数名,value为该函数对象。
function foo(x, y, z) { function f() { } } foo(1, 2); |
[伪代码] VO(<foo>functionContext) = { x : 1, y : 2, z : undefined, f : <ref to function f object> } |
c). 变量定义处理
对于每一个变量定义,在VO中创建一个属性,name是变量名,value是undefined。注意该阶段不包含变量赋值,如:var i = 0;拆分成定义和赋值两个部分。VO初始化阶段处理的是定义部分,不包含i = 0赋值部分。
d). 命名冲突的解决 △
*标记△的章节对概念理解无明显作用,主要是对边界、命名冲突、具体算法的一些讨论。可选择性忽略。
解析参数时:
如定义的参数列表中有重名参数,该属性(重名的参数名作为属性)值为最右边的参数传入的值。
例:!function(x, y, y){alert(y);}(1, 2, 3) // alert 3
即使第二个y未传入,结果依然取自最后的y,即:undefined
例:!function(x, y, y){alert(y);}(1, 2) // alert undefined
*function左边的!作用是为了将function...作为函数表达式来解析,而不是函数定义。等同于(function(){}()),当然,笔者喜欢用!更简洁一些…
解析函数定义时:
如函数名已在VO中存在,则采用替换的解决方式。
例:!function(x) {function x() {};alert(x);}(1) // alert function
解析变量定义时:
如变量名已在VO中存在,则采用忽略的解决方式。
例:!function(x) {function x() {};var x; alert(x);}(1) // still alert function
注意VO初始化时的变量定义解析并不包括变量赋值。VO初始化之后的EC变化过程在后面继续介绍。
例:!function(x) {function x() {};var x = 1; alert(x);}(1) // alert 1
*正常情况下我们一般不会在定义函数时写两个相同的参数名(如function(x, y, y)),也不会在定义一个函数后马上再定义一个重名的函数或变量。讨论该话题要么纯属好奇,要么寂寞空虚冷,极少数人为了实现JS引擎。
作用域链(Scope Chain)
每个EC在初始化时除了添加VO属性,还会初始化作用域链(scope chain)和this指针(本文主要内容非探讨this,不作细讲)。作用域链是一个对象列表,在决定标识符含义时会通过作用域链进行变量查找。在EC初始化完成后,通过with语句和catch块可在运行时修改作用域链。
*例如with语句开始时,with关键字紧接的括号中的对象会被放入作用域链中,影响with块内的变量查找,在with块结束时作用域链中的with对象会被移除,作用域链恢复到with语句执行前。
[伪代码]
activeExecutionContext = { |
|
VO : { }, |
// 参数、函数定义、变量定义… |
scopeChains : [VO_1, VO_2, …, VO_n ] |
// 作用域链 |
this : thisValue |
|
} ) |
|
对于全局EC,作用域链中只有全局对象(Global Object,在下面详细说明)。对于函数EC,作用域链类似于一个包含逻辑栈中每个VO对象的列表1,用来从逻辑栈的栈顶(当前激活的EC)到栈底(全局的EC)递归的进行变量查找。
1在ES3文档中并没有明确指出作用域链就是VO对象列表,但作用域链查找过程中每个对象都用于环境(context)间的变量查找,根据JS引擎具体实现可能有所不同。
全局对象(Global Object)
在进入任何EC前,会创建一个全局的、唯一的全局对象(Global Object),其中包含内置的属性,如:Math, String, Date, parseInt等等。另外,宿主(host)定义的一些属性也会初始化在全局对象中;例如,在HTML DOM中,会添加一个window属性,值为全局对象本身。
全局对象是内部机制的对象,但在HTML DOM中存在window这个特殊的自引用属性,使得全局对象可在程序中引用。
[伪代码]
globalObject = { |
|
Math : {…}, |
|
String : {…}, |
|
parseInt : <function>, |
|
… |
|
window : globalObject |
|
} |
|
激活对象(Activation Object)
对于函数代码的EC,在VO初始化之前会创建一个激活对象(Activation Object,缩写为AO),该激活对象会被初始化一个arguments属性。随后,该AO对象被用作VO对象并完成前面的变量初始化阶段。
|
|
*在ES5中AO和VO都会统一的在词法环境(Lexical Environment)中定义,ES3中的AO和VO是同一个对象,只是在不同阶段(AO初始化->变量初始化)、特定环境下(只在函数EC中)的不同叫法。在下节《2:EC in ES5》中我们会详细讨论ES5词法环境。
执行环境运行
在EC的准备阶段(ES3中叫进入阶段,即:entering an execution context),VO、作用域链和this被初始化,然后开始执行EC中的代码片段。
function test(a, b) { var c = 10; function d() {} var e = function _e() {}; (function x() {}); b = 20; } test(10); |
[伪代码] – <test>作用域链 ScopeChain(<test>functionContext) = { VO(<test>functionContext), globalObject }
[伪代码] – VO初始化:准备阶段 VO(<test>functionContext) = { a : 10, b : undefined, d : <ref to function d object>, c : undefined, e : undefined } [伪代码] – test调用过程中的VO变化 VO(<test>functionContext) = { a : 10, b : 20, d : <ref to function d object>, c : 10, e : <ref to function _e object> } |
*由于篇幅有限,不对作用域链作更深入的探讨。
小结
ES3中的执行环境按照全局代码、函数代码、Eval代码三种情况进行区分。
1. 1. 对于全局环境,先初始化全局对象(Global Object),再初始化变量对象(Variable Object),然后从全局代码开始执行。
2. 2. 对于函数的执行环境,先初始化激活对象(Activation Object),而后该激活对象将被用作变量对象进行变量初始化过程,最后执行函数代码。
3. 3. 对于Eval环境,执行环境取决于调用环境(Calling Object),若在全局环境中调用eval(),则Eval调用环境为该全局环境;若在函数的执行环境中调用eval(),则Eval调用环境为该函数的执行环境。
在下一节《JSinDeep2:探索执行环境(Execution Context)-ES5篇》,我们将共同探讨ES5中的词法环境(Lexical Environment),相对于ES3,在对性能的影响、VO&AO等模型的统一、with和catch语句的处理等多方面都会有所差异。
关于
作 者:@Bosn (花名:霍雍)
简 介:一淘数据部前端工程师
邮 箱: bosnma@live.cn
GitHub:https://github.com/bosnma/JsInDeep
版 权:欢迎转载,但该“关于”部分不得改动或删除。
参考
ECMA262-3文档:
ECMA-262-3 in detail. Chapter 1. Execution Contexts
http://dmitrysoshnikov.com/ecmascript/chapter-1-execution-contexts/
ECMA-262-3 in detail. Chapter 2. Variable object.
http://dmitrysoshnikov.com/ecmascript/chapter-2-variable-object/