ECMA-262-3深入解析第四章:作用域链

从来没有深入了解ECMA,网上找了一下,发现早在2010年就有大佬 Dmitry Soshnikov 总结了ECMA中的核心内容,我这里只是翻译记录,加深自己的印象。文章原文来自ECMA-262-3 in detail. Chapter 4. Scope chain.

介绍

第二章我们已经了解了变量对象执行上下文中的数据(变量,函数声明,函数形参)作为这个变量对象的属性被存起来了。

同时,我们也知道,变量对象是在每一次进入上下文的时候被创建并且赋予初始值的,并且在代码执行阶段更新。

本章致力于讨论与执行上下文紧密相关的更多细节;这一次,我们会提到一个作用域链的概念。

定义

如果要简短描述并且展现出重点,那么,作用域链主要与内部函数息息相关。

我们知道,ECMAScript允许在函数内部创建函数,并且,我们甚至可以从父级函数中返回这些函数。

1
2
3
4
5
6
7
8
9
10
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]]*属性构成。下面我们详细讨论一下函数的 [[Scrope]] 属性。

在上下文中的示意图:

1
2
3
4
5
6
7
activeExecutionContext = {
VO: {...}, // or AO
this: thisValue,
Scope: [
// 为了标识符查找的变量对象作用域链列表
]
};

Scope 定义的范围是:

1
Scope = AO + [[Scope]]

在我我们的例子中,可以使用ECMAScript的普通函数来表示 Scope,和 [[SCope]]

1
var Scope = [VO1, VO2, ..., VOn]; // scope chain

可以将替代结构视图表示为分层对象链,并在链的每个链接上都引用父作用域(父变量对象)。对于此视图,

对应某些实现的 parent 概念,这个我们在第二章变量对象中有讨论过。

1
2
3
var VO1 = {__parent__: null, ... other data}; -->
var VO2 = {__parent__: VO1, ... other data}; -->
// etc.

但是使用一个数字来表示一个作用域链更方便,所以我们将使用这种方法。除此之外,规范中声明的“一个作用域链就是一个对象列表”本身就是抽象的。不管可以在实现级别上使用带有__parent__功能的层次链的方法。数组抽象表示法是列表概念的理想选择。

我们接下来要讨论的AO + [[Scope]]的组合和标识符解析过程都与函数生命周期有关

函数声明周期

函数的声明周期分为创建和执行两个阶段。接下来详细看看。

函数创建

我们知道,在进入上下文阶段的时候,函数声明会放入变量/活动对象(VO/AO)。来看看这个例子,在全局上下文(即变量对象就是全局对象自身,还记得吗?)中声明一个变量和一个函数:

1
2
3
4
5
6
7
8
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’ 。

1
2
3
fooContext.AO = {
y: undefined // 进入上下文的时候是 undefined , 执行的时候才是 20
}

那’foo’函数又是如何访问’x’变量的呢?我们设想函数可以访问更高层上下文的变量对象,那这一切就说得通了。事实上,的确如此,并且实际上,这种机制是通过函数内部的[[Scope]]属性实现的。

[[Scope]]是所有父变量对象的层级链,这些父变量对象位于当前函数上下文之上。这个层次连在(函数)创建时候被保存到函数。

注意重要的一点 — [[Scope]]在函数创建是被保存 — 永久的保存 — 直到函数销毁。函数可以不被调用,但是 [[Scope]] 属性却写入并保存到函数对象中了。

另一个值得思考的,与作用域(作用域链)相比, [[Scope]] 是函数的属性,而不是上下文。上面的那个例子中, ‘foo’ 函数的 [[Scope]] 可以这样表示:

1
2
3
foo.[[Scope]] = [
globalContext.VO // === global
]

更远的说,真如我们知道的,函数调用的时候,进入函数上下文并且活动对象(AO)被创建,同时, this 值和 Scope (作用域链)被决定。我们来详解看看这种情况。

函数激活

在定义中以及说过了,在进入上下文和确定AO/VO后,上下文(供变量查找的作用域链)的 Scope 属性被定义为这样:

1
Scope = AO|VO = [[Scope]]

这里的重点是,Scope数组的第一个元素是活动对象,我们他把添加到作用域链中:

1
Scope = [AO].concat([[Scope]]);

这一点对于标识符解析的过程非常重要。

标识符解析是确定变量(或者是函数声明)属于哪一个变量对象的过程。

这个算法的返回此总是一个引用类型的值,基本组成都是相应的变量对象(如果变量没有找到则为 null ),并且属性名称是由查找(解析)标识符的名称组成。在第三章中我们详细讨论了引用类型。

标识符解析过程包括寻找与变量名一致的属性,例如,在作用域链中对变量对象进行了连续检查,从上下文的最底层到作用域链的最顶层。

因此,在查找上变量的优先级上,上下文中的局部变量比父级上下文中的变量更高,即使是在不同上下文中两个变量名字相同的情况下,第一个找到的也是更深层次上下文中的变量(即更接近局部上下文的那一个)。

我们来使上述例子复杂一点,并在更里层添加一些其他内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var x = 10;

function foo() {

var y = 20;

function bar() {
var z = 30;
alert(x + y + z);
}

bar();
}

foo(); // 60

这些上下文中,包含了如下内容:变量对象/活动对象,函数的[[Scope]]属性和作用域链。

全局上下文的变量对象是:

1
2
3
4
globalContext.VO === Global = {
x: 10,
foo: <函数引用>
}

foo创建的时候, foo[[Scope]] 属性是:

1
2
3
foo.[[Scope]] = [
globalContext.VO
]

foo 调用的时候, foo 上下文中的活动对象是:

1
2
3
4
fooContext.AO = {
y: 20,
bar: <函数引用>
}

foo 上下文的作用域链是:

1
2
3
4
5
6
7
fooContext.Scope = fooContext.AO + foo.[[Scope]]

// 即
fooContext.Scope = [
fooContext.AO,
globalContext.VO
]

内部 bar 函数创建时,它的 [[Scope]] 是:

1
2
3
4
bar.[[Scope]] = [
fooContext.AO,
globalContext.VO
];

bar 函数调用时候, bar 上下文的活动对象是:

1
2
3
barContext.AO = {
z: 30
}

bar 上下文的作用域链是:

1
2
3
4
5
6
7
8
barContext.Scope = barContext.AO + bar.[[Scope]]

// 即:
barContext.Scope = [
barContext.AO,
fooContext.AO,
globalContext.VO
];

xyz 的标识符解析:

1
2
3
4
// x
--> barContext.AO // 没有,进入上一层
--> fooContext.AO // 没有,进入上一层
--> globalContext.VO // 找到了 x = 10
1
2
3
// y
--> barContext.AO // 没有,进入上一层
--> fooContext.AO // 找到了 y = 20
1
2
// z
--> barContext.AO // 找到了 z = 30

作用域的特性

我们来看看与作用域链与函数 [[Scope]] 属性更多相关的特性。

闭包

ECMAScript中闭包与函数的 [[Scope]] 属性直接相关。之前说过, [[Scope]] 在函数创建是保存并在函数对象销毁的时候消失。实际上,闭包恰恰就是函数代码与 [[Scope]] 属性的组合。因此,

[[Scope]] 包含了函数创建时的词法环境(即父级变量对象)。在进一步的函数激活中,将在此词汇链(创建时静态保存的)中搜索更高上下文中的变量。

例子:

1
2
3
4
5
6
7
8
9
10
var x = 10;

function foo() {
alert(x);
}

(function () {
var x = 20;
foo(); // 10 ,而不是20
})();

变量 xfoo 函数的 [[Scope]] 属性中找到了,对于变量而言,实在函数创建那一刻的词汇(封闭)链中查找,而不是在函数调用(这时候 x 被赋值为20)时候的动态链。

另一个闭包的典型案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 。而且,这个例子还清楚的展示了,即使是在一个函数已经创建完成上下文后,函数的 [[Scope]] (这个例子中, foo 函数番号的匿名函数)依然存在。

有关闭包理论以及其在ECMAScript中的实现的更多详情,请查阅第六章:闭包

通过Function构造函数创建的函数的[[Scope]]

在上面的例子中,我们了解到函数在创建的时候获得 [[Scope]] 属性,并且通过此属性可以访问所有父级作用域的变量。然而对于一点有一个例外,这关系到通过Function构造函数创建的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var x = 10;

function foo() {
var y = 20;
function barFD() { // 函数声明
alert(x);
alert(y);
}

var barFE = function () { // 函数表达式
alert(x);
alert(y);
}

var barFn = Function('alert(x); alert(y);');

barFD(); // 10, 20
barFE(); // 10, 20
barFn(); // 20, y is not defined
}

foo();

可以看到,对于通过 Function 构造函数创建的函数 barFn ,变量 y 是不可访问的。但是这并不意味着函数 barFn 没有内部的 [[Scope]] 属性(即使他没有权限访问变量 x )。原因是因为用过 Function 构造函数创建的函数的 [[Scope]] 的属性总是只包含全局对象。因此,通过这种函数无法创建除了全局以外的(还包含其他)上层上下文的闭包。

二维作用域链查找

在作用域链中查找的很重要的一点就是变量对象的原型(如果有)也要被考虑进去 — 由于ECMAScript的原型性质:如果没有直接在对象中找到属性,则其查找将会在原型链中进行。就像是链的某种2D查找:(1)在原型链连接上,(2)在每一个原型链连接上 — 深入到链链接的原型。如果在 Object.prototype 中定义属性,我们可以观察到这种效果:

1
2
3
4
5
6
7
function foo() {
alert(x);
}

Object.prototype.x = 10;

foo(); // 10

活动对象没有原型链,我们可以在下面的例子中看出来:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
var x = 20;
function bar() {
alert(x);
}

bar();
}

Object.prototype.x = 10;

foo(); // 20

如果 bar 函数上下文的活动对象有原型,那么 x 属性应该在 Object.prototype 中解析,而不是直接在AO中解析。但是在上面的第一个例子中,在解析标识符中遍历作用域链,我们到达了全局对象(在某些视线中而不是全部),它继承自 Object.prototype ,因此解析为10。

类似的情况在某些版本的带有明明函数表达式的SpiderMokey中也有,其中存储函数表达式可选名称的对象是继承自 Object.protoype 的,在某些版本的Balckberry中,活动对象也是继承自 Object.prototype。更多这个特性的详情将在第五章:函数中讨论。

全局上下文与eval上下文的作用域链

这或许不太有趣,但是这很重要。全局上下文的作用域链中只包含了全局对象。“eval”类型代码的上下文与调用上下文有相同的作用域链。

1
2
3
4
5
globalContext.Scope = [
Global
];

evalContext.Scope === callingContext.Scope;

代码执行时对作用域链的影响

在ECMAScript中有两种语句在代码执行时可以修改作用域链。他们是 with 语句和 catch 语句。他们都把出现在这些语句中的查询标识符所需要的对象添加到了作用域链的最前端。如果其中一种情况发生了,那么作用域链就会被修改为如下:

1
Scope = withObject|catchObject + AO/VO + [[Scope]]

下面例子中 with 语句添加一个对象作为他的参数(因此这个对象的属性无需前缀即可访问):

1
2
3
4
5
6
var foo = {x: 10, y: 20};

with(foo) {
alert(x); // 10
alert(y); // 20
}

修改后的作用域:

1
Scope = foo + AO/VO + [[Scope]]

让我们再次展示,标识符是在with语句添加到作用域链前面的对象中解析的:

1
2
3
4
5
6
7
8
9
10
11
12
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“先是20,然后变成了30;
  • ”y“也修改了,因为上面的变量对象造成的,因此,先是10,变成了30;
  • 因此, with 语句完成后,他的特殊对象从作用域链中移除(并且被修改的 ”x“ — 30也随着对象移除被移除),作用域链恢复到 with 语句增强以前的状态;
  • 最后两个alert:当前变量对象中的“ x”值保持不变,并且“ y”的值现在等于30,并且在 with 语句中被更改。

另外,为了能够访问parameter-exception的catch子句会创建一个具有唯一属性(异常参数名称)的中间范围对象,并将此对象置于范围链的前面。从示意图上看,它看起来是这样的:

1
2
3
4
5
try {
...
} catch (ex) {
alert(ex);
}

修改后的作用域:

1
2
3
4
5
var catchObject = {
ex: <exception object>
};

Scope = catchObject + AO|VO + [[Scope]]

catch语句执行完成后,作用域链也会恢复到之前的状态。


所以啊,开发中经常听到老前辈说尽量不要使用 withtry...catch

文章作者: 踏浪
文章链接: https://www.lyt007.cn/技术/ECMA-262-3深入解析第四章:作用域链.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 平凡的生活,不平凡的人生
支付宝
微信打赏