深刻理解JavaScript系列(16):閉包(Closures)

介紹

本章咱們將介紹在JavaScript裏你們經常來討論的話題 —— 閉包(closure)。閉包事實上你們都已經談爛了。雖然如此,這裏仍是要試着從理論角度來討論下閉包,看看ECMAScript中的閉包內部究竟是怎樣工做的。html

正如在前面的文章中提到的。這些文章都是系列文章,相互之間都是有關聯的。所以。爲了更好的理解本文要介紹的內容,建議先去閱讀第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的匿名函數。app

反過來,接受函數式參數的函數稱爲高階函數(high-order function 簡稱:HOF)。還可以稱做:函數式函數或者偏數理或操做符。上述樣例中。exampleFunc 就是這種函數。ecmascript

此前提到的,函數不只可以做爲參數。還可以做爲返回值。這類以函數爲返回值的函數稱爲帶函數值的函數(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);

還有回調函數

//...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // 當數據就緒的時候,纔會調用;
  // 這裏。不管是在哪一個上下文中建立
  // 此時變量「x」的值已經存在了
  alert(x); // 10
};
//...

還可以建立封裝的做用域來隱藏輔助對象:

var foo = {};

// 初始化
(function (object) {

  var x = 10;

  object.getX = function _getX() {
    return x;
  };

})(foo);

alert(foo.getX()); // 得到閉包 "x" – 10

總結

本文介紹了不少其它關於ECMAScript-262-3的理論知識,而我以爲,這些基礎的理論有助於理解ECMAScript中閉包的概念。假設有不論什麼問題。我回在評論裏回覆你們。

相關文章
相關標籤/搜索