Javascript基础系列之作用域链

前言

本文翻译自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: <reference to function>
};

全局上下文的变量对象如下所示:

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

在 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 函数激活的时候,其对应的活跃对象如下所示:

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 从句结束后,作用域链同样也会恢复到之前的状态

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,780评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,424评论 1 305
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,397评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,576评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,997评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,945评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,107评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,850评论 0 208
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,625评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,804评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,285评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,613评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,291评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,164评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,963评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,096评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,886评论 2 278

推荐阅读更多精彩内容