执行上下文、执行栈、作用域链、闭包,这其实是一整套相关的东西,之前转载的文章也有讲到这些。下面两篇文章会更加详细地解释这些概念。

1.前言

首先引用下winter大的原话:

ES3中,执行上下文包含三个部分:
1.scope: 作用域,也常常被叫做作用域链。
2.variable object:变量对象,用于存储变量的对象。
3.this value: this值。
ES5中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子:
1.lexical environment:词法环境,当获取变量时使用。
2.variable environment:变量环境,当声明变量时使用。
3.this value: this值。
ES2018中,this值被归入lexical environment,同时增加了不少内容:
1.lexical environment:词法环境,当获取变量或者this值时使用。
2.variable environment:变量环境,当声明变量时使用。
3…

我们在这里介绍执行上下文的各个版本定义,是考虑到你可能会从各种网上的文章中接触这些概念,如果不把它们理清楚,我们就很难分辨对错。如果是我们自己使用,我建议统一使用最新的ES2018中规定的术语定义。

所以,你会看到本文讲解的部分与另一篇文章有出入(例如变量对象VS环境记录),只需要知道是不同时期的不同规范就行了,没必要深究。

2.执行上下文

每个执行上下文都有三个重要的属性:变量对象、作用域链、this。在执行上下文压栈后,将进行初始化,这个过程具体来说就是:

  • 创建变量对象
  • 创建作用域链
  • 确定 this 指向

用代码表示如下:

ExecutionContext = {
    VO: {...}, // 或者 AO
    this: thisValue,
    Scope: [ // 所用域链
      // 所有变量对象的列表
      // 用于标识符查询
    ]
};

下面我们针对这三个东西一一进行解释。

3.变量对象

3.1 定义

变量对象即Variable object/VO,它是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

  • 在全局执行上下文中,变量对象即全局对象(在浏览器中是window对象),它是可以访问的。
  • 在函数执行上下文中,变量对象是不能直接访问的,此时由活动对象(Activation Object/AO)扮演变量对象的角色,也就是所谓的VO–>AO。

总而言之,变量对象和活动对象的区别就是:
1、变量对象是规范上或者是JS引擎上实现的,并不能在JS环境中直接访问。
2、当进入到一个函数执行上下文后,这个变量对象才会被激活,成为活动对象,这时候活动对象上的各种属性才能被访问。

3.2 从执行上下文看变量对象

首先,执行上下文分为两个阶段:
1.进入执行上下文
2.代码执行

3.2.1 进入执行上下文

很明显,这个时候还没有执行代码。
此时的变量对象将包含(按照如下顺序初始化):

1.一个指向arguments对象的arguments变量(如果是函数执行上下文):具体地说,在变量对象内部将创建局部变量arguments和arguments对象,并使该变量指向该对象。arguments对象包括下列属性:

  • callee:指向当前函数的引用
  • length: 真正传递的参数的个数
  • properties-indexes:就是函数的参数值(按参数列表从左到右排列)

2.函数的所有形参(如果是函数执行上下文):有实参则赋值,无实参则为undefined。
3.函数声明:如果声明的函数跟已经声明的形参在名称上是相同的,则完全替换这个形参变量。
4.变量声明:如果声明的变量跟已经声明的形参/函数在名称上是相同的,则变量声明不会干扰它们,仅赋值部分是生效的。

同时明确,变量对象将不包含:

  • 函数表达式(与函数声明相对)
  • 没有使用var声明的变量(这属于“全局式”的声明方式,只是给全局添加了一个属性,并不在变量对象中)

拿下面代码作为例子:

function foo(a){
  var b = 2;
  function c(){}
  var d = function(){};
  b = 3;
}

foo(1);

在调用函数 foo 后,将进入其对应的函数执行上下文,此时的变量对象(实际上是活动对象)根据上面的说法,应为:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

可以从 arguments 对象的properties-indexes属性或者 a 看出,形参此时已经赋值了,但是变量仍是undefined

3.2.2 代码执行

这个阶段会顺序执行代码,修改变量对象的值,执行完成后变量对象如下:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}

前面说过,函数表达式和没有使用var声明的变量将不会包含在变量对象里,所以如果添加如下代码:

(function x(){});
e = 1

变量对象是不会变的

4.作用域链

4.1 定义

作用域链其实就是所有执行上下文的变量对象的列表。我们可以将其看作数组,并表示为:

var Scope = [VO1, VO2, ..., VOn];

具体来说,函数执行上下文的作用域链包括该上下文的活动对象和该上下文对应函数的内部[[Scope]]属性。表示为:

Scope = AO + [[Scope]]

4.2 作用:

作用域链的作用是:在处理标识符的时候进行变量查询。当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。

4.3 Scope[[Scope]]

前面我们说的Scope是执行上下文的属性,而[[Scope]]是函数的属性。
具体来说,[[Scope]]是一个包含了所有上层变量对象的分层链,它属于当前函数执行上下文,在函数创建伊始就存在了,并保存在函数中。

这里要注意的很重要的一点是:[[Scope]]是在函数创建的时候保存起来的——静态的(不变的),只有一次并且一直都存在——直到函数销毁。 比方说,哪怕函数永远都不能被调用到,[[Scope]]属性也已经保存在函数对象上了。

4.4 从执行上下文看作用域链:

下面用具体的例子回顾一下在执行上下文中,变量对象和作用域链的创建过程

var x = 10; 

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

foo(); // 60

首先进入全局执行上下文,创建变量对象(全局对象window)

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

之后开始执行代码,变量对象变为:

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

其中,在创建foo函数时,确认它的[[Scope]]属性:

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

之后,调用foo函数,进入其对应的函数执行上下文,此时函数的变量对象激活为活动对象:

fooContext.AO = {
  y: undefined,
  bar: <reference to function>
};

同时确认了foo函数执行上下文的作用域链:

fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
 
fooContext.Scope = [
  fooContext.AO,
  globalContext.VO
];

之后开始执行代码,变量对象变为:

fooContext.AO = {
  y: 20,
  bar: <reference to function>
};

其中,在创建bar函数时,确认它的[[Scope]]属性:

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

之后,调用bar函数,进入bar函数对应的函数执行上下文,此时函数的变量对象激活为活动对象:

barContext.AO = {
  z: undefined
};

同时确认了bar函数执行上下文的作用域链:

barContext.Scope = barContext.AO + bar.[[Scope]] // i.e.:
 
barContext.Scope = [
  barContext.AO,
  fooContext.AO,
  globalContext.VO
];

之后开始执行代码,变量对象变为:

barContext.AO = {
  z: 30
};

在运行alert(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=当前活动对象+所有上层对象 ),所以变量查询也将从该变量对象开始,而全局对象则一直处于末端,是最后被查询的。

bar执行完毕,出栈—>foo执行完毕,出栈—>回到全局执行上下文

5.闭包

5.1 从向下Funarg问题谈静态作用域

“Funarg”即函数式参数,指的是值为函数的参数。如:

function exampleFunc(funArg) {
  funArg();
}

首先看下这段代码:

let x = 10;
function foo() {
  console.log(x);
}
function bar(funArg) {
  let x = 20;
  funArg(); // 10, 而不是20!
}
// 将 `foo` 作为实参传给 `bar`。
bar(foo);

对于函数foo,变量x就是自由变量。当foo函数被调用时,它在哪里解析x绑定呢?是从创建函数的外层作用域,还是从调用函数的外层作用域?
这就是所谓的向下funarg问题(downwards funarg problem),即在判断绑定的环境时的歧义性:它应该是创建时的环境,还是调用时的环境?

这是通过达成约定使用静态作用域来解决的。静态作用域也就是词法作用域(这也是词法环境这个名称的由来),它是通过捕获函数创建所在的环境来实现的,因而会到函数创建时保存起来的静态作用域链中进行变量查询。如果一个语言只通过查找源代码,就可以判断绑定在哪个环境中解析,那么该语言就实现了静态作用域。

与静态作用域相对的是动态作用域。动态作用域是在当前活跃的动态链(而不是在函数创建时保存起来的静态作用域链)中进行变量查询的。对于上面的代码,如果是动态作用域,将输出20而不是10。

5.2 从向上Funarg问题谈闭包

另一种Funarg问题是向上funarg问题(upwards funarg problem)。

function foo() {
  let x = 10;
  // 闭包,捕获`foo`的环境。
  function bar() {
    return x;
  }
  // 向上funarg。
  return bar;
}
let x = 20;
// 调用`foo`来返回`bar`闭包。
let bar = foo();
bar(); // 10,而不是20!

除了判断绑定环境的歧义性,向上funarg问题面临的另一个问题是:如果JavaScript是面向堆栈的,那么foo函数在调用结束后,其执行上下文将带着变量对象销毁,这样一来,在我们调用bar函数时将发生错误(因为bar函数需要用到自由变量x,而该变量已经随着foo函数变量对象的销毁而消失了)。而且,在面向堆栈实现模型中,要想将bar函数返回根本是不可能的,因为它也是foo函数变量对象的一部分,也会随之销毁。

也就是说我们面临两个问题:

  • 绑定环境的歧义性
  • 被引用的自由变量在上下文销毁后无法得到保留

为了解决这两类问题,引入了闭包的概念。

5.3 闭包

5.3.1 定义

ECMAScript 中,闭包指的是:

  • 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

  • 从实践角度:以下函数才算是闭包:
    1.即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2.在代码中引用了自由变量

JavaScript显然是支持词法作用域的(除了this),所以解决了向下Funarg问题;同时由于闭包的存在,即使创建闭包的执行上下文出栈后被销毁,其变量对象也依然存在,所以闭包函数依然有办法访问到该对象,这就解决了向上Funarg问题。

这个过程具体来说就是:通过某种方式(通常是返回值)调用闭包后,创建闭包对应的执行上下文并压栈,该上下文的属性Scope包括了闭包本身的变量对象和闭包的[[Scope]]属性,后者使得闭包执行时有机会访问到自由变量,因为[[Scope]]在闭包的词法创建阶段便已确定,并在那时候保存了其上层变量对象(上层,也就是闭包的父函数)。

5.3.2 注意

不过这里需要注意,仅变量对象里被引用的自由变量依然存在,不需要用到的变量会被垃圾清除机制清除。可以用下面的代码做个测试:

var bar = function() {
    var hello = "world"
    var unused = "nope"
    return function(s) { 
        console.log(hello)
        debugger 
        return s
    }
}
var g = bar()
g(1)

debugger 查看 closure,发现只有 hello 变量,而找不到 unused 变量:

控制台中打印 hello 发现可以访问,但是打印 unused 变量时,会报错:

另外一个需要注意的地方是:同一个上下文中可能存在多个闭包,而这些闭包是共用同一个[[Scope]]属性的。也就是说,某个闭包对其中的变量做修改会影响到其他闭包对该变量的读取:

var firstClosure;
var secondClosure;
 
function foo() {
 
  var x = 1;
 
  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };
 
  x = 2; // 对AO["x"]产生了影响, 其值在两个闭包的[[Scope]]中
 
  alert(firstClosure()); // 3, 通过 firstClosure.[[Scope]]
}
 
foo();
 
alert(firstClosure()); // 4
alert(secondClosure()); // 3

这也可以解释经典的 for 循环问题:

var data = [];
 
for(var k = 0; k < 3; k++) {
  data[k] = function(){
    alert(k)
  }
}
 
data[0](); // 3, 而不是 0
data[1](); // 3, 而不是 1
data[2](); // 3, 而不是 2

每一个函数都是一个闭包,拥有同一个[[Scope]]属性,这个属性只包含一个变量对象,那就是全局对象,而全局对象包含了变量 k,这个 k 在循环跑完后变成了 3,所以闭包调用的时候统一输出 3。

注意:每次赋值数组元素的时候,只是赋值 function(){alert(k)},k 只有在执行的时候才会求值

5.3.3 闭包的运用

实际使用的时候,闭包可以创建出非常优雅的设计,允许对 funarg 上定义的多种计算方式进行定制。 如下就是数组排序的例子,它接受一个排序条件函数作为参数:

[1, 2, 3].sort(function (a, b) {
  ... // 排序条件
});

同样的例子还有,数组的map方法(并非所有的实现都支持数组map方法,SpiderMonkey从1.6版本开始有支持),该方法根据函数中定义的条件将原数组映射到一个新的数组中:

[1, 2, 3].map(function (element) {
  return element * 2;
}); // [2, 4, 6]

使用函数式参数,可以很方便的实现一个搜索方法,并且可以支持无穷多的搜索条件:

someCollection.find(function (element) {
  return element.someProperty == 'searchCondition';
});

还有应用函数,比如常见的forEach方法,将funarg应用到每个数组元素:

[1, 2, 3].forEach(function (element) {
  if (element % 2 != 0) {
    alert(element);
  }
}); // 1, 3

顺便提下,函数对象的 apply 和 call方法,在函数式编程中也可以用作应用函数。 apply 和 call 已经在讨论 this 的时候介绍过了;这里,我们将它们看作是应用函数 —— 应用到参数中的函数(在 apply 中是参数列表,在 call 中是独立的参数):

(function () {
  alert([].join.call(arguments, ';')); // 1;2;3
}).apply(this, [1, 2, 3]);

闭包还有另外一个非常重要的应用 —— 延迟调用:

var a = 10;
setTimeout(function(){
  alert(a); // 10, 一秒钟后
}, 1000);

也可以用于回调函数:

...
var x = 10;
// only for example
XMLHttpRequestObject.onreadystatechange = function () {
  // 当数据就绪的时候,才会调用;
  // 这里,不论是在哪个上下文中创建,变量“x”的值已经存在了
  alert(x); // 10
};
..

还可以用于封装作用域来隐藏辅助对象:

var foo = {};
 
// initialization
(function (object) {
 
  var x = 10;
 
  object.getX = function _getX() {
    return x;
  };
 
})(foo);
 
alert(foo.getX()); // get closured "x" – 10

参考:
http://dmitrysoshnikov.com/ecmascript
http://goddyzhao.tumblr.com/post/11311499651/closures