前言
本文翻译自scope-chai
概要
通过第变量对象的学习我们知道,执行上下文的数据(变量、函数声明、函数形参)都是以属性的方式储存在变量对象中
我们还知道,变量对象是在进入执行上下文阶段被创建和初始化,随后在执行代码阶段会对属性值进行更新
本文将深入讨论与执行上下文密切相关的另外一个重要的概念 —— 作用域链(Scope Chain)
定义
如果简单扼要地讲,那么作用域链就是与内部函数息息相关的一个概念
众所周知,ECMAScript
允许创建内部函数,甚至可以将这些内部函数作为父函数的返回值
var x = 10;
function foo() {
var y = 20;
function bar() {
alert(x + y);
}
return bar;
}
foo()(); // 30
每个上下文都有自己的变量对象;对于全局变量,其变量对象就是全局对象自己本身;对于函数而言,其变量对象就是活动对象
作用域链是所以内部上下文和变量对象的列表,用于变量查询。比如,在上述例子中,bar上下文的作用域链包含了AO(bar)、AO(foo)、VO(global)
作用域链是一条变量对应的链,它和执行上下文有关,用于处理标识符时候进行变量查询
作用域链在函数调用时被创建,它包含了活动对象(AO)和该函数的内部属性[[scope]]
.关于[[scope]]
会在后面做详细介绍
activeExecutionContext = {
VO: {...}, // 或者 AO
this: thisValue,
Scope: [ // 作用域链
// 所有变量对象的列表
// 用于标识符查找
]
};
上述代码中Scope
定义如下:
Scope = AO + [[Scope]]
针对我们的例子,我们可以将Scope
和[[scope]]
用普通的ECMAScript数组来表示:
var Scope = [VO1, VO2, ...., VOn] //作用域链
除此之外,还可以用多级的对象链的数据结构来表示,链中每一个链接都有对父作用域(上层变量对象)的引用
var VO1 = {__parent__: null, ... other data}; -->
var VO2 = {__parent__: VO1, ... other data}; -->
然而,使用数组来表示作用域链会更方便,因此,我们这里就采用数组的表示方式。 除此之外,不论在实现层是否采用包含__parent__
特性的分层对象链的数据结构,规范对其做了抽象的定义“作用域链是一个对象列表”。数组就是实现列表这一概念最好的选择。
下面将要介绍的AO+[[Scope]]
以及标识符的处理方式,都和函数的生命周期有关。
函数生命周期
函数的生命分为创建和激活(调用)阶段,下面分别详细介绍
创建阶段
我们知道,进入上下文阶段时函数声明被储存在变量对象/活动对象中(VO/AO)。让我们看看在全局上下文中的变量和函数声明的例子(这里变量对象是全局对象自身,还记得,是吧?)
var x = 10;
function foo() {
var y = 20;
alert(x + y);
}
foo(); // 30
在函数激活(调用)后,我们得到了正确(预期)的结果——30。不过,这里有个非常重要的特性
此前,我们仅仅谈到当前上下文的变量对象。这里,变量y
在函数foo
中定义(意味着它在foo
上下文的AO
中),但是变量x
并未在foo
上下文中定义,自然不会被添加到foo
的AO中。乍一看,变量 x 相对于函数 foo 根本就不存在。
fooContext.AO = {
y: undefined // undefined – 在进入上下文时, 20 – 在激活阶段
};
那么,foo函数是如何访问到x
变量的?一个顺其自然的想法是:函数应当有访问更高层上下文变量对象的权限。而事实也恰是如此,就是通过函数的内部属性 [[Scope]]来实现这一机制的。
[[Scope]] 是一个包含了所有上层变量对象的分层链,它属于当前函数上下文,并在函数创建的时候,保存在函数中。
这里要注意的很重要的一点是:[[Scope]]是在函数创建的时候保存起来的——静态的(不变的),永远永远——直到函数销毁。也就是说,哪怕函数永远都不能被调用到,[[Scope]]属性也已经保存在函数对象上了
另外要注意的一点是:[[Scope]] 与 Scope (作用域链)是不同的,前者是函数的属性,后者是上下文的属性。 以上述例子来说,foo 函数的 [[Scope]] 如下所示:
foo.[[Scope]] = [
globalContext.VO // === Global
];
当函数被调用的时候,就进入函数执行上下文,此时活动对象呗创建,this
和作用域(作用域链
被确定。下面我们详细讨论这个时刻。
激活阶段
正如上面定义的那样,在进入上下文,AO/VO 创建之后,上下文的Scope 属性(作用域链,用于变量查询)会定义为如下所示:
Scope = AO|VO + [[Scope]]
特别注意的是活动对象是Scope数组元素的第一个元素,添加在作用域的最前端
Scope = [AO].concat([[Scope]]);
这个特性对处理标识符非常重要
处理标识符其实就是一个确定变量(或者函数声明)属于作用域链中哪个变量对象的过程。
此算法返回的总是一个引用类型的值,其base
属性就是对应的变量对象(或者变量对象不存在的时候则返回null),其propertyname
属性的名字就是要查询的标识符。
标识符处理过程包括了对应的变量名的属性查询,即在作用域链中会进行一系列的变量对象的检测,从作用域链的最底层上下文一直到最上层上下文
因此,在查询过程中上下文中的局部变量比上层上下文的变量会优先被查询到,换句话说,如果两个相同名字的变量存在于不同的上下文中时,处于底层上下文的变量会优先被找到
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
alert(x + y + z);
}
bar();
}
foo(); // 60
全局上下文的变量对象如下所示:
globalContext.VO === Global = {
x: 10
foo:
};
全局上下文的变量对象如下所示:
globalContext.VO === Global = {
x: 10
foo:
};
在 foo 函数创建的时候,其 [[Scope]] 属性如下所示:
foo.[[Scope]] = [
globalContext.VO
];
在 foo 函数激活的时候(进入上下文时),foo 函数上下文的活跃对象如下所示:
fooContext.AO = {
y: 20,
bar:
};
同时,foo 函数上下文的作用域链如下所示:
fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
fooContext.Scope = [
fooContext.AO,
globalContext.VO
];
在内部bar
函数创建的时候,其 [[Scope]] 属性如下所示:
bar.[[Scope]] = [
fooContext.AO,
globalContext.VO
];
在 bar 函数激活的时候,其对应的活跃对象如下所示:
barContext.AO = {
z: 30
};
同时,bar 函数上下文的作用域链如下所示:
barContext.Scope = barContext.AO + bar.[[Scope]] // i.e.:
barContext.Scope = [
barContext.AO,
fooContext.AO,
globalContext.VO
];
如下是 x,y 和 z 标识符的查询过程:
- "x"
-- barContext.AO // not found
-- fooContext.AO // not found
-- globalContext.VO // found - 10
- "y"
-- barContext.AO // not found
-- fooContext.AO // found - 20
- "z"
-- barContext.AO // found - 30
作用域的特性
下面让我们看看与作用域链和函数[[scope]]属性相关的一些重要特征。
闭包
在 ECMAScript 中,闭包和函数的[[Scope]] 属性息息相关。正如此前介绍的,[[Scope]]是在函数创建的时候就保存在函数对象上了,并且直到函数销毁的时候才消失。事实上,闭包就是函数代码和其 [[Scope]] 属性的组合。因此,[[Scope]] 包含了函数创建所在的词法环境(上层变量对象)。上层上下文中的变量,可以在函数激活的时候,通过变量对象的词法链(函数创建的时候就保存起来了)查询到
var x = 10;
function foo() {
alert(x);
}
(function () {
var x = 20;
foo(); // 10, but not 20
})();
变量 x 是在 foo 函数的 [[Scope]] 中找到的。对于变量查询而言,词法链是在函数创建的时候就定义的,而不是在调用函数时动态确定的(这个时候,变量 x 才会是 20)。
下面是另一个典型的闭包的例子:
function foo() {
var x = 10;
var y = 20;
return function () {
alert([x, y]);
};
}
var x = 30;
var bar = foo(); // 返回一个匿名函数
bar(); // [10, 20]
上述例子再一次证明了处理标识符的时候,词法作用域链是在函数创建的时候定义的 —— 变量x的值是10,而不是30。并且,上述例子清楚的展示了函数(上述例子中指的是函数 foo 返回的匿名函数)的[[Scope]] 属性,即使在创建该函数的上下文结束的时候依然存在
通过 Function
构造器创建的函数的 [[Scope]]属性
**属性,并且通过该属性可以获取所有上层上下文中的变量。然而,这里有个例外,就是当函数通过Function
构造器创建的时候
var x = 10;
function foo() {
var y = 20;
function barFD() { // FunctionDeclaration
alert(x);
alert(y);
}
var barFE = function () { // FunctionExpression
alert(x);
alert(y);
};
var barFn = Function('alert(x); alert(y);');
barFD(); // 10, 20
barFE(); // 10, 20
barFn(); // 10, "y" is not defined
}
foo();
上述例子中,函数barFn
就是通过Fuction
构造器来创建的,这个时候变量y 就无法访问到了。但这并不意味着函数barFn
就没有内部的[[Scope]]属性(否则它连变量 x 都无法访问到)。问题就在于当函数通过Function
构造器来创建的时候,其[[Scope]]属性永远都只包含全局对象。哪怕在上层上下文中(非全局上下文)创建一个闭包都是无济于事的
二维作用域链查找
在作用域链查找的时候还有很重要的一点:需要考虑变量对象的原型(如果存在的话) -- 源于原型链的特性:如果一个属性在对象中没有直接找到,查询将在原型链中继续。即常说的二维链查找。(1)作用域链环节;(2)每个作用域链 -- 深入到原型链环节。如果在 Object.prototype 中定义了属性,我们能看到这种效果。
function foo() {
alert(x);
}
Object.prototype.x = 10;
foo(); // 10
活动对象是没有原型的,我们可以在下面的例子中看出:
function foo() {
var x = 20;
function bar() {
alert(x);
}
bar();
}
Object.prototype.x = 10;
foo(); // 20
试想下,如果 bar 函数的活动对象有原型的话,属性 x 则应当在Object.prototype
中找到,因为它在 AO 中根本不存在。然而,上面第一个例子中,在标识符处理阶段遍历了整个作用域链,到了全局对象(部分实现是这样的),它继承自 Object.prototype
,因此,最终变量 x 的值就变成了 10。
执行代码阶段对作用域的影响
在代码执行阶段有两个语句能修改作用域链,那就是 with 声明和 catch 语句。在标识符查询阶段,这两者都会被添加到作用域链的最前面。也就是说,当有 with 或 catch 的时候,作用域链就会被修改如下形式:
Scope = withObject|catchObject + AO|VO + [[Scope]]
如下例子中,with 语句添加了 foo 对象,使得它的属性可以不需要前缀直接访问。
var foo = {x: 10, y: 20};
with (foo) {
alert(x); // 10
alert(y); // 20
}
对应的作用域链修改为如下所示:
Scope = foo + AO|VO + [[Scope]]
再看下面例子,with 对象被添加到作用域链的最前端:
var x = 10, y = 10;
with ({x: 20}) {
var x = 30, y = 30;
alert(x); // 30
alert(y); // 30
}
alert(x); // 10
alert(y); // 30
这里发生了什么?在进入上下文阶段,x和y被添加到变量对象中,在代码执行阶段,发生了如下修改:
x = 10, y = 10 {x: 20} 被添加到作用域链的最前端
在with内部,遇到了var声明,当然什么也没创建,因为在进入上下文时,所有变量已被解析添加
这里只修改了x的值,此时的x被解析后是第二步中添加到作用域链最前的的那个对象中的 x,x的值由20变为30
这里也修改了 y 的值,y 是上层作用域变量对象的属性,相应地,由 10 修改为 30
当 with 语句结束后,这个特殊对象从作用域链中移除(被修改后的 x - 30 也随着对象被移除了),也就是说,作用域链回到执行 with 语句之前的状态
正如在最后两个 alert 中看到的,x 的值恢复到了原先的 10,而 y 的值因为在 with 语句的时候被修改过了,因此变为了 30
同样,catch 语句会创建一个只包含一个属性(异常参数名)的新对象。如下所示:
try {
...
} catch (ex) {
alert(ex);
}
作用域链修改为:
var catchObject = {
ex:
};
Scope = catchObject + AO|VO + [[Scope]]
在 catch 从句结束后,作用域链同样也会恢复到之前的状态