执行环境又称执行上下文,英文缩写是EC(Execution Context),每当执行流转到可执行代码时,即会进入一个执行环境。在JavaScript中,执行环境分三种:
- 全局执行环境 — 这个是最外围的代码执行环境,一旦代码被载入,引擎最先进入的就是这个环境。在浏览器中,全局环境就是window对象,一次所有全局属性和函数都是作为window对象的属性和方法创建的。全局执行环境直到应用程序退出时才会被销毁。
- 函数执行环境 — 当执行一个函数时,JavaScript引擎进入执行环境。某个执行环境中的代码执行完之后,该环境销毁,保存在其中的所有变量和函数定义也随之销毁。
- Eval执行环境 — Eval的执行环境和函数调用的执行环境相同。
活动的执行环境构成一个栈:栈的底部始终是全局环境,顶部是当前活动的执行环境。当执行流进入一个函数时,函数的环境被压入栈中。而在函数执行完之后,栈将其环境弹出,把控制权返回给之前的执行环境。
建立一个执行环境分为两个阶段:
- 进入上下文阶段:发生函数调用,进入执行环境时,此时具体的函数代码还没有执行。
- 执行代码阶段:进行变量赋值,函数引用,以及执行其它代码。
变量对象的英文缩写是VO(Variable Object),每一个执行环境都对应一个变量对象,这个对象存储着环境中定义的以下内容:
1. 函数的形参 2. var声明的变量 3. 函数声明(但不包含函数表达式)
变量对象有两种存在方式,一种就是全局对象(用Global表示),存放着全局属性和函数,我们可以通过this关键字引用到该对象。另外一种是函数执行环境中定义的变量对象,改对象在函数的执行上下文中是不能直接访问的,被称为活动对象,英文缩写为AO(Activation Object)。
接下来我们来看下再不同的执行环境中,变量对象是怎样初始化的?
首先是全局环境中的变量对象,这个对象就是全局对象,全局对象是在进入任何执行环境之前就已经创建了的对象。这个对象只存在一份,它的属性在程序中的任何地方都可以访问,全局对象的生命周期终止于程序退出的那一刻。全局对象的初始化阶段,将Math、String等作为自身属性,初始化如下:
Global = { Math:{...}, String:{...}, ... ... window:Global // 引用自身 };
接下来我们重点研究下函数执行环境中的变量对象,即上文提到的活动对象。活动对象是在进入函数执行环境时创建的,它通过函数的arguments属性初始化:
AO = { arguments: {...} //参数对象,包括callee, length等属性 };
理解了变量对象的初始化之后,接下来就是进入执行环境的代码部分了。上文中提到过,执行环境的建立分为两个阶段,第一个阶段就是进入上下文阶段。在该阶段,变量对象包含以下属性:
- 函数的所有形参:全局环境中没有形参,这里只是针对函数的执行环境而言。此时由形参名称和对应值构成变量对象的属性。如果没有传递相应的形参值,对应值为undefined。
- 所有的函数声明:需要注意的是这里特指函数的声明,函数表达式不算。此时有函数名和对应的函数对象构成变量对象的属性。如果变量对象已经存在同名的属性,则覆盖这个属性。
- 所有的变量声明:由var关键字声明的变量,由变量名和对应值组成,作为变量对象的属性。如果变量名与已经声明的形参或函数名相同,则变量声明不会干扰已经存在的这里属性。
上文中,我们提到过变量声明提前的问题,在这里就反映为在进入上下文阶段,首先将初始化变量声明,构成变量对象的属性,此时该属性的值为undefined。例如下面的例子:
function test(a, b){ console.log(a); // 10 console.log(b); // undefined console.log(c); // undefined console.log(d); // function d(){} console.log(e); // undefined console.log(f); //Reference error var c = 10; function d(){} var e = function _e(){}; (function f(){}); } test(10);
我们考虑进入到带有参数10的test函数的执行环境时,在进入上下文阶段,活动对象初始化如下:
AO(test) = { a: 10, b: undefined, c: undefined, d: 指向函数d, e: undefined };
活动对象不包含属性f,这是因为f是一个函数表达式,而不是函数声明,函数表达式不会影响到变量对象。函数_e同样是函数表达式,但是它分配给了变量e,所以赋值语句执行后,就可以通过e访问到函数表达式_e。
接下来进入到执行环境的第二个阶段,执行代码。在这个阶段开始时,变量对象已经拥有了属性,参考上面的例子,代码执行后变量对象被修改为:
AO(test) = { a: 10, b: undefined, c: 10, d: 指向函数d, e: 指向函数表达式_e };
理解了以上内容后,我们再来看一个例子:
function test2(a){ console.log(a); // function a(){} var a = 3; console.log(a); // 3 function a(){}; } test2(20);
上文中提到的在进入到执行上下文阶段时,变量对象会被初始化,在初始化阶段,变量声明构成的对象属性是最后被执行的,并且如果变量名和已经声明的函数名或形参同名的话,变量声明不会干扰到已经存在的属性,所以在函数执行环境的第一阶段,变量对象为:
AO(test2) = { a: 指向函数a };
不过,在紧接着的代码执行阶段,属性a被重新赋值为3。
另外,需要特别指明的是变量只能通过var关键字来声明,对于类似于a=4这样的赋值语句,如果a没有通过var声明的话,相当于是创建了一个全局对象的属性,而并没有创建新的变量,它之所以可以认为是全局变量对象的属性,仅仅是因为全局对象等同于全局变量对象。参考以下代码:
function test3(){ console.log(a); // undefined console.log(b); // Reference error var a = 3; b = 4; } test3();
所以在函数执行环境的第一阶段,变量对象为:
AO(test2) = { a: undefined };
因为b不是一个变量,所以在这个阶段,根本就不存在b,只有在代码执行阶段,b才会以全局对象的属性出现。但是还未执行到这里之前,就已经出错了。另外一个需要记住的就是,通过var声明的变量不能通过delete删除,而属性则可以,所以上述例子中的a是不可以通过delete删除的,而b则可以。
现在我们已经知道,执行环境中的数据作为属性存储在变量对象中,同时也知道,变量对象在在每次进入执行环境时创建,并初始化,在代码执行时,更新属性的值。接下来,将讨论下作用域链的概念。
作用域链大多数时候和内部函数有关,我们可以创建内部函数,甚至可以从父函数中返回这些函数。示例代码如下:
var x = 10; function foo(){ var y = 20; function bar(){ console.log(x + y); } return bar; } foo(); // 30
每个环境都有自己的变量对象,作用域链正是内部环境所有变量对象(包括父变量对象)的列表。此链用来在标识符解析中查找变量。作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。对于上面的例子,bar执行环境中的作用域链包括:bar变量对象、foo变量对象和全局变量对象。
函数的作用域链是在函数调用时创建,包含这个函数的活动对象和[[scope]]属性。示例如下:
活动的执行环境 = { AO: 变量对象, this:thisValue, Scope: [变量对象列表] // 作用域链 };
其中Scope = 被调用函数的活动对象 + 被调用函数的[[scope]]属性。
这种标识符的解析过程,与函数的生命周期有关。函数的生命周期可以分为创建和激活(调用时)两个阶段。在函数创建时,函数对象的内部存在一个[[scope]]属性,[[scope]]是所有父变量对象的层级链。[[scope]]属性在函数创建时被存储,永远不变,直到函数被销毁。函数可以不被调用,但该属性一直存在。与作用域链相比,作用域链是活动的执行环境的一个属性,而[[scope]]是函数的属性。
参考以上例子,foo函数在进入全局环境后被创建,此时foo函数拥有了[[scope]]属性,如下图所示:
同样的,bar函数在进入到foo函数的执行环境时被创建,此时foo函数的活动对象已经被创建,所以bar函数的[[scope]]属性如下图所示:
然后,在函数调用激活阶段,生成的活动对象和[[scope]]属性共同组成执行环境的作用域链。也就是说将活动对象添加到 [[scope]]链表的最前端,在查找标识符时,首先从自身变量对象开始,逐渐向父变量查找。
另外需要特别注意的是,通过构造函数创建的函数的[[scope]]属性中仅包含全局对象。