ECMAScript3核心概念-作用域链

352 阅读13分钟

前言

在前文我们已经知道了变量对象的概念,变量对象是执行上下文的一个属性,变量对象存放着包括变量声明、函数声明和函数的形参。

解析和执行代码的时候,会用到执行上下文这个概念,执行上下文又分为进入执行上下文阶段和代码执行阶段,在进入执行上下文阶段,变量对象会被创建和被初始化值填满,而那些值在代码执行阶段会被更新。

在前面介绍了执行上下文的两个属性:变量对象和this,本文继续介绍一个执行上下文非常重要的属性:作用域链。

请注意:本系列文章基于ES3标准展开,有些内容在ES5已经被修改了

定义

我们都知道JavaScript允许在函数的内创建函数,甚至还可以返回内函数(高阶函数的定义之一)。作用域链就跟内部函数有很大的关系。

来复习一下:每一个执行上下文都有他自己的VO,全局执行上下文的VO是它本身,函数执行上下文的VO是AO。

来看一下作用域链的定义:

Scope chain is related with an execution context a chain of variable objects which is used for variables lookup at identifier resolution.

大概意思就是:作用域链与一个执行上下文相关,是一条由变量对象组成的链,用于标识符解析过程中查找变量。

所以一个内部函数的执行上下文的作用域链是它的AO加上它所有父上下文的VO组成的列表,作用域链是用来查找变量的。

全局执行上下文的作用域链只有一个全局对象。

函数执行上下文的作用域链在函数调用时创建,由AO和函数的内部属性[[scope]]组成。可以具象为这样的结构:

activeExecutionContext = {
    VO: {...}, // or AO
    this: thisValue,
    Scope: [ // 作用域链
    ] 
};

注意,这里的Scope指的是作用域链,而不是[[scope]]属性,通过下面的代码可以看得更清楚:

Scope = AO + [[Scope]]

我们可以将作用域链看为一个数组的结构:

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

我们也可以将它看为是链表的结构,通过VO的__parent__属性连接起来。

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

但用数组更加便于我们的理解,因为使用什么样的数据结构是技术实现层面的事情,ECMAScript只是一个规范,每一个厂商对这份规范的实现都不尽相同, 所以这是一个抽象的概念,我们只需要用最简单的方式去理解这个概念就行,但要注意的是,无论是哪种阶段,作用域链都是有层级的,就好像执行上下文一样具有优先级的。

说回作用域链,AO + [[Scope]]这个过程的细节,将会在下面讲到。

函数生命周期

函数的生命周期分为创建和激活(调用)两个阶段。

函数创建阶段

前文讲过,在进入执行上下文阶段,函数的声明会被放进VO/AO中。

var x = 10;
  
function foo() {
  var y = 20;
  alert(x + y);
}
  
foo(); // 30

上面的例子中,foo被调用的时候,能得到我们预期中的答案:30。但有没有仔细想过为什么会出现这种结果?

在此之前我们都是谈论当前执行上下文的VO,经过简单的分析我们知道:y是存放在foo函数执行上下文AO中的。但x没有在foo函数执行上下文定义,所以不会被添加到AO中。 foo的AO这可以这么表示:

fooContext.AO = {
  y: undefined // undefined // 执行代码阶段,y会变为20
};

y在进入foo函数执行上下文阶段是undefined,而在执行代码阶段,y会变为20。但在这两个阶段中,x并没有出现在AO中,所以foo是怎样获取到x的值的? x存放在全局执行上下文的VO中,也就是全局对象,所以唯一的可能是foo有办法去访问到更高一层的执行上下文的VO。实际上就是如此,这种机制是通过函数内部的 [[scope]]属性实现的。

[[Scope]] is a hierarchical chain of all parent variable objects, which are above the current function context; the chain is saved to the function at its creation.

大概就是:[[scope]]属性是由当前函数执行上下文之上的所有父执行上下文的变量对象组成的层级链,在函数的创建阶段被保存在函数之中。

好像很拗口的样子,但我已经尽量翻译得通俗了,没关系下面我们一步一步来分析。

定义告诉我们,[[scope]]在函数的创建阶段保存在函数之中, 从创建开始知道函数被销毁,都是静态的(不变的)。也就是函数可以不被调用,但[[scope]]属性在函数创建的时候就被写入函数对象里面了。

另外一个需要注意到的地方:[[scope]]跟作用域链(Scope)相比,前者是函数的一个属性,后者而是执行上下文的一个属性,要搞清楚这个区别。

foo.[[Scope]] = [
  globalContext.VO // === Global
];

从上面这个例子具象一下上面那些抽象的🌈屁。。。因为foo执行上下文的父执行上下文就只有全局执行上下文(可以理解为执行栈的顺序决定父子关系), 所以foo函数的[[scope]]属性中包含了全局执行上下文(globalContext)中的VO(Global),而且[[scope]]属性是定义在函数对象上的。 (当然我们一般是访问不到这个内部属性的,这个就类似于暴露出来的__proto__属性,它也是一个内部属性)

我们知道在函数被调用的时候会进入函数的执行上下文,这个时候VO会被创建,this和作用域链的值会被确定,下面来分析这个过程。

函数调用阶段

文章一开头讲到:函数执行上下文的作用域链在函数调用时创建,由AO和函数的内部属性[[scope]]组成,这个过程可以理解为:

Scope = AO|VO + [[Scope]]

可以这么理解:当前函数执行上下文的AO是作用域链的最前端,如果把作用域链看成一个数组结构,可以这么表示:

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

这个特点对标识符解析的过程非常重要,解析标识符的定义:

Identifier resolution is a process of determination to which variable object in scope chain the variable (or the function declaration) belongs.

翻译一下:标识符解析是一个确定变量(或者变量声明)属于哪个变量对象的过程

标识符解析的过程包含变量名的查找,变量名的查找这个过程是从作用域链的VO开始查找的,而且是一个连续的过程,从最深的执行上下文的VO一直到作用域链顶部的VO。 (如果看成数组结构,就是从作用域链的前端往后找)

因此,在查找变量的过程中,一个执行上下文的局部变量比它的父级执行上下文中定义的变量有更高的优先级,如果有两个相同名称但来自不同上下文(也叫作用域)的变量, 处于更深层次上下文的那个变量会被先发现。

var x = 10;
  
function foo() {
  
  var y = 20;
  
  function bar() {
    var z = 30;
    alert(x +  y + z);
  }
  
  bar();
}
  
foo(); // 60

上面的例子中,我们可以知道全局执行上下文的VO长这样子


globalContext.VO === Global = {
  x: 10
  foo: <reference to function>
};

在foo函数的创建阶段,[[scope]]属性会被创建:

foo.[[Scope]] = [
  globalContext.VO
];

在foo函数的调用阶段,foo的AO(foo函数执行上下文的VO):

foo.AO = [
  y: 20,
  bar: <reference to function>
];

foo函数执行上下文的作用域链:

fooContext.Scope = fooContext.AO + foo.[[Scope]] 
 
fooContext.Scope = [fooContext.AO, globalContext.VO];

bar函数的[[scope]]属性;

bar.[[Scope]] = [fooContext.AO, globalContext.VO];

当bar函数被调用的时候,bar函数执行上下文的AO;

bar.AO = {
	z: 30
}

bar函数执行上下文的作用域链:

barContext.Scope = barContext.AO + bar.[[Scope]] 
 
barContext.Scope = [barContext.AO, fooContext.AO, globalContext.VO];

bar函数执行过程中对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

可以看到,查找xyz变量的过程是从bar作用域链的底层开始往上查找的(如果看成数组,是从前后往后找的,其实更像一个队列的结构)。

作用域链的一些特点

与作用域相关的一些重要的特色功能有很多,比如闭包,但闭包有必要再开一篇文章专门来讲,这里先不讨论。

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

在上面的一些例子中,我们看到,在函数创建时会创建函数的[Scope]]属性,通过该属性可以访问到所有父级执行上下文的变量。 但是,这个规则有一个重要的例外,它涉及到通过函数构造函数创建的函数。

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(); // 10, "y" is not defined
  
}
  
foo();

从上面的例子中我们可以看到,通过Function构造函数创建的barFn函数,访问不到变量y。但这不意味着barfn函数没有[Scope]]属性,否则它不应该访问到变量x。 问题在于通过Function构造函数创建的的函数的[Scope]]属性只包含了全局对象

二维链查找

在作用域链的查找中一个很重要的点,变量对象的原型对象也应该被考虑其中(如果他们有),这是由于ECMAScript是基于原型模式的决定的。 如果一个对象的属性在作用域链中没有直接被找到,会从原型链中查找,也就是所谓的二维链查找。

二维链查找的过程:

  1. 从一个作用域开始查找
  2. 没有找到,从当前作用域深入到VO/AO的原型链中查找
  3. 没有找到,进入下一个作用域
  4. 重复1-3过程

我们可以通过在Object.prototype上定义属性来观察到这种效果:

function foo() {
  alert(x);
}
  
Object.prototype.x = 10;
  
foo(); // 10

在标识符解析x的过程中,沿着作用域链到达了全局对象,这个时候也没有找到x,但全局对象的构造函数是Object,它的原型对象是Object.prototype(有些实现可能不是), 这个时候开始沿着原型链查找x,最后在Object.prototype上找到了x。再来看一个例子;

function foo() {
  
  var x = 20;
  
  function bar() {
    alert(x);
  }
  
  bar();
}
  
Object.prototype.x = 10;
  
foo(); // 20

这个例子中可以证明AO是没有原型对象的。如果它有原型对象,那么bar中的x应该被解析为10,因为它没有直接定义在bar函数的AO上,根据上面的算法,如果在bar的AO上找不到, 就从AO的原型链上查找,也就是在Object.prototype上找到x为10,但现在x是20,所以证明了AO是没有原型对象的。

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

在ECMAScript中,在代码执行阶段有两个声明能修改作用域链。这就是with声明和catch语句。它们会将一个对象添加到作用域链的最前端,从而影响标识符的解析。 这个过程可以这样描述:

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

下面这个例子用with语句将一个对象添加到作用域链的最前端

var foo = {x: 10, y: 20};
 
with (foo) {
  alert(x); // 10
  alert(y); // 20
}

来分析一个复杂一点的例子;

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已经被添加到VO中了,在代码执行阶段,会发生以下过程:

  • x = 10, y = 10
  • {x:20}被添加到作用链的前端
  • with内部,遇到了var声明,当然什么也没创建,因为在进入上下文时,所有变量已被解析添加
  • 这个时候x被解析,获取到最前端的{x:20},此时执行x=30x变为30,注意,改变的是{x:20}这个对象中的x
  • y也被改变。改为30
  • with语句结束后,添加的特定对象会被移除,{x:30}被移除,此时x===10
  • 最后输出x===10y===30

从上面这个复杂的分析过程可以得出一个结论:尽量不要使用with语句,写出来的代码很容易变得难以维护。

同样的,catch语句创建了一个异常对象,这个对象添加到作用域链的前端,运行结束后,恢复到之前的状态。

try {
  ...
} catch (ex) {
  alert(ex);
}
var catchObject = {
  ex: <exception object>
};
  
Scope = catchObject + AO|VO + [[Scope]]

总结

  1. 每一个执行上下文都有一个属性叫作用域链(Scope),是一条由变量对象组成的链,用于标识符解析过程中查找变量。
  2. [[scope]]属性是由当前函数执行上下文之上的所有父执行上下文的变量对象组成的层级链,在函数的创建阶段保存在函数之中。
  3. 函数的作用域链由当前函数执行上下文的AO和函数的[[scope]]属性组成,全局执行上下文的作用域链只有一个VO(全局对象)
  4. 函数的生命周期分为创建和激活(调用)两个阶段,[[scope]]属性在函数创建的时候就被写入函数对象里面,在函数的调用阶段会进入函数的执行上下文,这个时候AO被创建,作用域链被创建。
  5. 标志符解析是二维链查找:作用域链和原型链
  6. with和catch语句可以改变作用域链

多提一嘴,ES5放弃了作用域链模型,而是使用了词法环境模型,但先从ES3开始理解会比较好,因为大部分概念都没有改变,

看英文的效率真的不是很高,可能是我太菜了吧,也有可能是因为作者是个俄罗斯人,英文翻译可能有点问题,有一些词句我需要反复斟酌和参考其他文章才能敲定,所以文章里掺杂着私货,可能是对的,也有可能都是我的愚见,毕竟我还是个初学者,在这个领域只是略懂皮毛而已。所以我很欢迎能够被指出错误,非常期待您的评论。

写完这篇文章,关于执行上下文的大部分东西都讲完了,接下来还会写闭包和ES5的词法环境模型,有兴趣的不妨可以关注一下。

参考

dmitrysoshnikov.com/ecmascript/…

www.cnblogs.com/TomXu/archi…