深入理解JavaScript系列(16):闭包(Closures)

Posted ljbguanli

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解JavaScript系列(16):闭包(Closures)相关的知识,希望对你有一定的参考价值。

介绍

本章我们将介绍在javascript里大家常常来讨论的话题 —— 闭包(closure)。闭包事实上大家都已经谈烂了。虽然如此,这里还是要试着从理论角度来讨论下闭包,看看ECMAScript中的闭包内部到底是怎样工作的。

正如在前面的文章中提到的。这些文章都是系列文章,相互之间都是有关联的。因此。为了更好的理解本文要介绍的内容,建议先去阅读第14章作用域链第12章变量对象

英文原文:http://dmitrysoshnikov.com/ecmascript/chapter-6-closures/

概论

在直接讨论ECMAScript闭包之前,还是有必要来看一下函数式编程中一些基本定义。

众所周知,在函数式语言中(ECMAScript也支持这样的风格),函数即是数据。就例如说,函数能够赋值给变量,能够当參数传递给其它函数。还能够从函数里返回等等。这类函数有特殊的名字和结构。

定义

A functional argument (“Funarg”) — is an argument which value is a function.
函数式參数(“Funarg”) —— 是指值为函数的參数

样例:

function exampleFunc(funArg) {
  funArg();
}

exampleFunc(function () {
  alert(\'funArg\');
});

上述样例中funarg的实际參数事实上是传递给exampleFunc的匿名函数。

反过来,接受函数式參数的函数称为高阶函数(high-order function 简称:HOF)。还能够称作:函数式函数或者偏数理或操作符。上述样例中。exampleFunc 就是这种函数。

此前提到的,函数不仅能够作为參数。还能够作为返回值。这类以函数为返回值的函数称为带函数值的函数(functions with functional value or function valued functions)。

(function functionValued() {
  return function () {
    alert(\'returned function is called\');
  };
})()();

能够以正常数据形式存在的函数(例如说:当參数传递。接受函数式參数或者以函数值返回)都称作 第一类函数(一般说第一类对象)。在ECMAScript中。全部的函数都是第一类对象。

函数能够作为正常数据存在(比如:当參数传递,接受函数式參数或者以函数值返回)都称作第一类函数(一般说第一类对象)。

在ECMAScript中。全部的函数都是第一类对象。

接受自己作为參数的函数,称为自应用函数(auto-applicative function 或者 self-applicative function):

(function selfApplicative(funArg) {

  if (funArg && funArg === selfApplicative) {
    alert(\'self-applicative\');
    return;
  }

  selfApplicative(selfApplicative);

})();

以自己为返回值的函数称为自复制函数(auto-replicative function 或者 self-replicative function)。

通常,“自复制”这个词用在文学作品中:

(function selfReplicative() {
  return selfReplicative;
})();

自复制函数的当中一个比較有意思的模式是让仅接受集合的一个项作为參数来接受从而取代接受集合本身。

// 接受集合的函数
function registerModes(modes) {
  modes.forEach(registerMode, modes);
}

// 使用方法
registerModes([\'roster\', \'accounts\', \'groups\']);

// 自复制函数的声明
function modes(mode) {
  registerMode(mode); // 注冊一个mode
  return modes; // 返回函数自身
}

// 使用方法,modes链式调用
modes(\'roster\')(\'accounts\')(\'groups\')

//有点类似:jQueryObject.addClass("a").toggle().removClass("b")

但直接传集合用起来相对来说,比較有效而且直观。

在函数式參数中定义的变量,在“funarg”激活时就行訪问了(由于存储上下文数据的变量对象每次在进入上下文的时候就创建出来了):

function testFn(funArg) {
  // funarg激活时, 局部变量localVar能够訪问了
  funArg(10); // 20
  funArg(20); // 30

}

testFn(function (arg) {
  var localVar = 10;
  alert(arg + localVar);
});

然而。我们从第14章知道,在ECMAScript中,函数是能够封装在父函数中的。并能够使用父函数上下文的变量。这个特性会引发funarg问题。

Funarg问题

面向堆栈的编程语言中,函数的局部变量都是保存在栈上的,每当函数激活的时候,这些变量和函数參数都会压入到该堆栈上。

当函数返回的时候,这些參数又会从栈中移除。这样的模型对将函数作为函数式值使用的时候有非常大的限制(例如说,作为返回值从父函数中返回)。绝大部分情况下,问题会出如今当函数有自由变量的时候。

自由变量是指在函数中使用的。但既不是函数參数也不是函数的局部变量的变量

样例:

function testFn() {

  var localVar = 10;

  function innerFn(innerParam) {
    alert(innerParam + localVar);
  }

  return innerFn;
}

var someFn = testFn();
someFn(20); // 30

上述样例中,对于innerFn函数来说,localVar就属于自由变量。

对于採用面向栈模型来存储局部变量的系统而言。就意味着当testFn函数调用结束后,其局部变量都会从堆栈中移除。

这样一来。当从外部对innerFn进行函数调用的时候,就会错误发生(由于localVar变量已经不存在了)。

并且。上述样例在面向栈实现模型中,要想将innerFn以返回值返回根本是不可能的。由于它也是testFn函数的局部变量,也会随着testFn的返回而移除。

另一个问题是当系统採用动态作用域。函数作为函数參数使用的时候有关。

看例如以下样例(伪代码):

var z = 10;

function foo() {
  alert(z);
}

foo(); // 10 – 使用静态和动态作用域的时候

(function () {

  var z = 20;
  foo(); // 10 – 使用静态作用域, 20 – 使用动态作用域

})();

// 将foo作为參数的时候是一样的
(function (funArg) {

  var z = 30;
  funArg(); // 10 – 静态作用域, 30 – 动态作用域

})(foo);

我们看到,採用动态作用域。变量(标识符)的系统是通过变量动态栈来管理的。

因此,自由变量是在当前活跃的动态链中查询的,而不是在函数创建的时候保存起来的静态作用域链中查询的。

这样就会产生冲突。例如说。即使Z仍然存在(与之前从栈中移除变量的样例相反),还是会有这样一个问题: 在不同的函数调用中,Z的值究竟取哪个呢(从哪个上下文,哪个作用域中查询)?

上述描写叙述的就是两类funarg问题 —— 取决于是否将函数以返回值返回(第一类问题)以及是否将函数当函数參数使用(第二类问题)。

为了解决上述问题。就引入了 闭包的概念。

闭包

闭包是代码块和创建该代码块的上下文中数据的结合。

让我们来看以下这个样例(伪代码):

var x = 20;

function foo() {
  alert(x); // 自由变量"x" == 20
}

// 为foo闭包
fooClosure = {
  call: foo // 引用到function
  lexicalEnvironment: {x: 20} // 搜索上下文的上下文
};

上述样例中。“fooClosure”部分是伪代码。相应的,在ECMAScript中,“foo”函数已经有了一个内部属性——创建该函数上下文的作用域链。

“lexical”一般是省略的。上述样例中是为了强调在闭包创建的同一时候,上下文的数据就会保存起来。当下次调用该函数的时候,自由变量就能够在保存的(闭包)上下文中找到了,正如上述代码所看到的,变量“z”的值总是10。

定义中我们使用的比較广义的词 —— “代码块”,然而。通常(在ECMAScript中)会使用我们经经常使用到的函数。当然了,并非全部对闭包的实现都会将闭包和函数绑在一起。例如说,在Ruby语言中,闭包就有可能是: 一个过程对象(procedure object), 一个lambda表达式或者是代码块。

对于要实现将局部变量在上下文销毁后仍然保存下来,基于栈的实现显然是不适用的(由于与基于栈的结构相矛盾)。因此在这样的情况下,上层作用域的闭包数据是通过 动态分配内存的方式来实现的(基于“堆”的实现)。配合使用垃圾回收器(garbage collector简称GC)和 引用计数(reference counting)。

这样的实现方式比基于栈的实现性能要低,然而,不论什么一种实现总是能够优化的: 能够分析函数是否使用了自由变量,函数式參数或者函数式值。然后依据情况来决定 —— 是将数据存放在堆栈中还是堆中。

ECMAScript闭包的实现

讨论完理论部分。接下来让我们来介绍下ECMAScript中闭包到底是怎样实现的。这里还是有必要再次强调下:ECMAScript仅仅使用静态(词法)作用域(而诸如Perl这种语言。既能够使用静态作用域也能够使用动态作用域进行变量声明)。

var x = 10;

function foo() {
  alert(x);
}

(function (funArg) {

  var x = 20;

  // 变量"x"在(lexical)上下文中静态保存的,在该函数创建的时候就保存了
  funArg(); // 10, 而不是20

})(foo);

技术上说,创建该函数的父级上下文的数据是保存在函数的内部属性 [[Scope]]中的。

假设你还不了解什么是[[Scope]],建议你先阅读第14章, 该章节对[[Scope]]作了很具体的介绍。

假设你对[[Scope]]和作用域链的知识全然理解了的话,那对闭包也就全然理解了。

依据函数创建的算法,我们看到 在ECMAScript中,全部的函数都是闭包,由于它们都是在创建的时候就保存了上层上下文的作用域链(除开异常的情况) (无论这个函数兴许是否会激活 —— [[Scope]]在函数创建的时候就有了):

var x = 10;

function foo() {
  alert(x);
}

// foo是闭包
foo: <FunctionObject> = {
  [[Call]]: <code block of foo>,
  [[Scope]]: [
    global: {
      x: 10
    }
  ],
  ... // 其他属性
};

如我们所说,为了优化目的,当一个函数没有使用自由变量的话,实现可能不保存在副作用域链里。只是,在ECMA-262-3规范里不论什么都没说。因此,正常来说,全部的參数都是在创建阶段保存在[[Scope]]属性里的。

有些实现中。同意对闭包作用域直接进行訪问。比方Rhino,针对函数的[[Scope]]属性,相应有一个非标准的 __parent__属性,在第12章中作过介绍:

var global = this;
var x = 10;

var foo = (function () {

  var y = 20;

  return function () {
    alert(y);
  };

})();

foo(); // 20
alert(foo.__parent__.y); // 20

foo.__parent__.y = 30;
foo(); // 30

// 能够通过作用域链移动到顶部
alert(foo.__parent__.__parent__ === global); // true
alert(foo.__parent__.__parent__.x); // 10

全部对象都引用一个[[Scope]]

这里还要注意的是:在ECMAScript中。同一个父上下文中创建的闭包是共用一个[[Scope]]属性的。也就是说,某个闭包对当中[[Scope]]的变量做改动会影响到其它闭包对其变量的读取:

这就是说:全部的内部函数都共享同一个父作用域

var firstClosure;
var secondClosure;

function foo() {

  var x = 1;

  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };

  x = 2; // 影响 AO["x"], 在2个闭包公有的[[Scope]]中

  alert(firstClosure()); // 3, 通过第一个闭包的[[Scope]]
}

foo();

alert(firstClosure()); // 4
alert(secondClosure()); // 3

关于这个功能有一个很普遍的错误认识。开发者在循环语句里创建函数(内部进行计数)的时候常常得不到预期的结果,而期望是每一个函数都有自己的值。

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”是能够非常easy就被改变的。

activeContext.Scope = [
  ... // 其他变量对象
  {data: [...], k: 3} // 活动对象
];

data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;

这样一来,在函数激活的时候,终于使用到的k就已经变成了3了。

例如以下所看到的。创建一个闭包就能够解决问题了:

var data = [];

for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k); // 传入"k"值
}

// 如今结果是正确的了
data[0](); // 0
data[1](); // 1
data[2](); // 2

让我们来看看上述代码都发生了什么?函数“_helper”创建出来之后。通过传入參数“k”激活。

其返回值也是个函数。该函数保存在相应的数组元素中。这样的技术产生了例如以下效果: 在函数激活时,每次“_helper”都会创建一个新的变量对象。当中含有參数“x”,“x”的值就是传递进来的“k”的值。这样一来。返回的函数的[[Scope]]就成了例如以下所看到的:

data[0].[[Scope]] === [
  ... // 其他变量对象
  父级上下文中的活动对象AO: {data: [...], k: 3},
  _helper上下文中的活动对象AO: {x: 0}
];

data[1].[[Scope]] === [
  ... // 其他变量对象
  父级上下文中的活动对象AO: {data: [...], k: 3},
  _helper上下文中的活动对象AO: {x: 1}
];

data[2].[[Scope]] === [
  ... // 其他变量对象
  父级上下文中的活动对象AO: {data: [...], k: 3},
  _helper上下文中的活动对象AO: {x: 2}
];

我们看到。这时函数的[[Scope]]属性就有了真正想要的值了。为了达到这种目的。我们不得不在[[Scope]]中创建额外的变量对象。要注意的是,在返回的函数中。假设要获取“k”的值,那么该值还是会是3。

顺便提下,大量介绍JavaScript的文章都觉得仅仅有额外创建的函数才是闭包,这样的说法是错误的。实践得出,这样的方式是最有效的。然而。从理论角度来说,在ECMAScript中全部的函数都是闭包。

然而,上述提到的方法并非唯一的方法。

通过其它方式也能够获得正确的“k”的值,例如以下所看到的:

var data = [];

for (var k = 0; k < 3; k++) {
  (data[k] = function () {
    alert(arguments.callee.x);
  }).x = k; // 将k作为函数的一个属性
}

// 结果也是对的
data[0](); // 0
data[1](); // 1
data[2](); // 2

Funarg和return

另外一个特性是从闭包中返回。在ECMAScript中,闭包中的返回语句会将控制流返回给调用上下文(调用者)。而在其它语言中。比方,Ruby,有非常多中形式的闭包。对应的处理闭包返回也都不同,以下几种方式都是可能的:可能直接返回给调用者,或者在某些情况下——直接从上下文退出。

ECMAScript标准的退出行为例如以下:

function getElement() {

  [1, 2, 3].forEach(function (element) {

    if (element % 2 == 0) {
      // 返回给函数"forEach"函数
      // 而不是返回给getElement函数
      alert(\'found: \' + element); // found: 2
      return element;
    }

  });

  return null;
}

然而,在ECMAScript中通过try catch能够实现例如以下效果:

var $break = {};

function getElement() {

  try {

    [1, 2, 3].forEach(function (element) {

      if (element % 2 == 0) {
        // // 从getElement中"返回"
        alert(\'found: \' + element); // found: 2
        $break.data = element;
        throw $break;
      }

    });

  } catch (e) {
    if (e == $break) {
      return $break.data;
    }
  }

  return null;
}

alert(getElement()); // 2

理论版本号

这里说明一下,开发者常常错误将闭包简化理解成从父上下文中返回内部函数,甚至理解成仅仅有匿名函数才干是闭包。

再说一下,由于作用域链,使得全部的函数都是闭包(与函数类型无关: 匿名函数,FE,NFE,FD都是闭包)。

这里仅仅有一类函数除外,那就是通过Function构造器创建的函数,由于其[[Scope]]仅仅包括全局对象。

为了更好的澄清该问题。我们对ECMAScript中的闭包给出2个正确的版本号定义:

ECMAScript中,闭包指的是:

  1. 从理论角度:全部的函数。由于它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,由于函数中訪问全局变量就相当于是在訪问自由变量。这个时候使用最外层的作用域。
  2. 从实践角度:下面函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比方,内部函数从父函数中返回)
    2. 在代码中引用了自由变量

闭包使用方法实战

实际使用的时候,闭包能够创建出很优雅的设计。同意对funarg上定义的多种计算方式进行定制。例如以下就是数组排序的样例。它接受一个排序条件函数作为參数:

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

相同的样例还有。数组的map方法是依据函数中定义的条件将原数组映射到一个新的数组中:

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

使用函数式參数,能够非常方便的实现一个搜索方法,而且能够支持无限制的搜索条件:

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

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

[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, after one second
}, 1000);

还有回调函数