[JavaScript] 作用域链 Scope Chain
介绍
在变量对象中已经介绍过,执行上下文(变量,函数声明和函数形式参数)的数据被存储为变量对象的属性
此外,我们知道每次进入上下文时都会创建变量对象并填充初始值,并且它的更新发生在代码执行阶段
举个栗子🌰
function test(a, b) {
console.log(c); // function c() {}
var c = 10;
function c() {};
console.log(c); // 10
c = 1;
console.log(c); // 1
var e = function _e() {};
(function x() {});
}
test(10);
这次我们讨论作用域链 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)
让我们从定义开始,进一步讨论更多的例子
作用域链与执行上下文相关联,是一条变量对象的链,用于在处理标识符时的变量查找
函数上下文的作用域链在函数调用时创建,由该函数的活动对象和内部[[Scope]]
属性组成
用伪代码可以表示为:
activeExecutionContext = {
VO: {...}, // or AO
this: thisValue,
Scope: [ // Scope chain
// list of all variable objects
// for identifiers lookup
]
};
根据定义,Scope可以表示为:
Scope = AO + [[Scope]]
我们可以将Scope和[[Scope]]表示为ECMAScript数组:
var Scope = [VO1, VO2, ..., VOn]; // scope chain
我们下面将讨论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
乍一看,“foo”函数根本不存在“x”变量,正如我们将在下面看到的,“foo”上下文的活动对象只包含一个属性“y”:
fooContext.AO = {
y: undefined // undefined – on entering the context, 20 – at activation
};
函数“foo”如何访问“x”变量呢?函数应该可以访问更高层上下文的变量对象,实际上,确实如此,这个机制是通过函数的内部[[Scope]]属性来实现的
[[Scope]]是包含了所有父级变量对象的层级链,它位于当前函数上下文中,在函数创建时被保存到函数中
[[Scope]]是在创建函数时保存的,静态的(不变的),只有一次并且一直都存在,直到函数销毁
注意一点,[[Scope]]与Scope(作用域链)是不同的,前者是函数的属性,后者是上下文的属性
以上述例子为例,“foo”函数的[[Scope]]如下所示:
foo.[[Scope]] = [
globalContext.VO // === Global
];
之后,函数调用时,会进入一个函数上下文,其中活动对象被创建,并且this
值和Scope(作用域链)被确定
函数激活
正如定义中提到的那样,在进入上下文并且在创建AO / VO之后,上下文的Scope属性(作用域链,用于变量查找)定义为:
Scope = AO|VO + [[Scope]]
这里要强调活动对象是Scope数组的第一个元素,即添加到作用域链的最前面:
Scope = [AO].concat([[Scope]])
这个特征对标识符解析过程非常重要
标识符解析是确定变量(或函数声明)属于作用域链中哪个变量对象的过程
这个算法返回的是一个Reference类型的值,其base属性是相应的变量对象(如果没有找到变量,则为null),其property name属性的名字是查找到的标识符的名称,细节可参考 this
标识符解析的过程包括与变量名称对应的属性查找,即从作用域链的最底层上下文一直到最上层上下文
因此,查找过程中上下文的局部变量比父上下文的变量具有更高的优先级,如果两个相同名字的变量存在于不同的上下文中时,处于底层上下文的变量会优先被找到
让我们看一个稍微复杂的例子:
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
alert(x + y + z);
}
bar();
}
foo(); // 60
上述代码,对应了如下的变量/活动对象,函数的[[Scope]]属性以及上下文的作用域链:
全局上下文的变量对象是:
globalContext.VO === Global = {
x: 10
foo: <reference to function>
};
在创建foo时,foo的[[Scope]]属性为:
foo.[[Scope]] = [
globalContext.VO
];
在foo函数调用中,foo函数上下文的活动对象是:
fooContext.AO = {
y: 20,
bar: <reference to function>
};
foo函数上下文的作用域链是:
fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
fooContext.Scope = [
fooContext.AO,
globalContext.VO
];
在创建内部“bar”函数时[[Scope]]属性是:
bar.[[Scope]] = [
fooContext.AO,
globalContext.VO
];
在bar函数调用中,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(); // anonymous function is returned
bar(); // [10, 20]
我们再次看到,对于标识符解析,使用函数创建时定义的词法作用域链,变量x被解析为10,而不是30
此外,这个例子清楚地表明函数的[[Scope]]属性,即使在函数上下文已经结束,也会继续存在
通过Function构造器创建的函数的[[Scope]]属性
在上面的例子中,我们看到函数创建时就获得[[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();
正如我们所看到的,对于通过Function构造器创建的barFn函数,变量y不可访问,但它并不意味着barFn函数没有内部的[[Scope]]属性(否则它将无法访问变量x)
问题是通过Function构造器创建的函数的[[Scope]]属性始终只包含全局对象
二维作用域链查找
在作用域链查找中的一个重点是变量对象的原型,因为ECMAScript的原型特性:
如果在对象中没有直接找到属性,则查找会在原型链中进行
- 在作用域链的链接上
- 在每个作用域链接上,深入原型链链接
如果在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
全局和eval上下文的作用域链
全局上下文的作用域链中只包含全局对象
“eval”代码的上下文和调用上下文(calling context)有相同的作用域链
globalContext.Scope = [
Global
];
evalContext.Scope === callingContext.Scope;