JavaScript內部原理系列-閉包(Closures)

原文連接:http://www.faceye.net/search/142545.html

概要

本文將介紹一個在JavaScript常常會拿來討論的話題 —— 閉包(closure)。閉包其實已是個老生常談的話題了; 有大量文章都介紹過閉包的內容(其中不失一些很好的文章,好比,擴展閱讀中Richard Cornford的文章就很是好), 儘管如此,這裏仍是要試着從理論角度來討論下閉包,看看ECMAScript中的閉包內部到底是如何工做的。html

正如在此前文章中提到的,這些文章都是系列文章,相互之間都是有關聯的。所以,爲了更好的理解本文要介紹的內容, 建議先去閱讀下第四章 - 做用域鏈和 第二章 - 變量對象。程序員

概論

在討論ECMAScript閉包以前,先來介紹下函數式編程(與ECMA-262-3 標準無關)中一些基本定義。 然而,爲了更好的解釋這些定義,這裏仍是拿ECMAScript來舉例。算法

衆所周知,在函數式語言中(ECMAScript也支持這種風格),函數便是數據。就比方說,函數能夠保存在變量中,能夠當參數傳遞給其餘函數,還能夠當返回值返回等等。 這類函數有特殊的名字和結構。編程

定義

函數式參數(「Funarg」) —— 是指值爲函數的參數。數組

以下例子:閉包

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

上述例子中funarg的實參是一個傳遞給exampleFunc的匿名函數。app

反過來,接受函數式參數的函數稱爲 高階函數(high-order function 簡稱:HOF)。還能夠稱做:函數式函數 或者 偏數理的叫法:操做符函數。 上述例子中,exampleFunc 就是這樣的函數。編程語言

此前提到的,函數不只能夠做爲參數,還能夠做爲返回值。這類以函數爲返回值的函數稱爲 _帶函數值的函數(functions with functional value or function valued functions)。ide

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

能夠以正常數據形式存在的函數(比方說:當參數傳遞,接受函數式參數或者以函數值返回)都稱做 第一類函數(通常說第一類對象)。 在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;
})();

在函數式參數中定義的變量,在「funarg」激活時就可以訪問了(由於存儲上下文數據的變量對象每次在進入上下文的時候就建立出來了):

function testFn(funArg) {
// 激活funarg, 本地變量localVar可訪問
funArg(10); // 20
funArg(20); // 30
}
testFn(function (arg) {
var localVar = 10;
alert(arg + localVar);
});

然而,咱們知道(特別在第四章中提到的),在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 // 對函數的引用
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;
// funArg的變量 "x" 是靜態保存的,在該函數建立的時候就保存了
funArg(); // 10, 而不是 20
})(foo);

從技術角度來講,建立該函數的上層上下文的數據是保存在函數的內部屬性 [[Scope]]中的。 若是你還不瞭解什麼是[[Scope]],建議你先閱讀第四章, 該章節對[[Scope]]做了很是詳細的介紹。若是你對[[Scope]]和做用域鏈的知識徹底理解了的話,那對閉包也就徹底理解了。

根據函數建立的算法,咱們看到 在ECMAScript中,全部的函數都是閉包,由於它們都是在建立的時候就保存了上層上下文的做用域鏈(除開異常的狀況) (無論這個函數後續是否會激活 —— [[Scope]]在函數建立的時候就有了):

var x = 10;
function foo() {
alert(x);
}
// foo is a closure
foo: <FunctionObject> = {
[[Call]]: <code block of foo>,
[[Scope]]: [
global: {
x: 10
}
],
... // other properties
};

正如此前提到過的,出於優化的目的,當函數不使用自由變量的時候,實現層可能就不會保存上層做用域鏈。 然而,ECMAScript-262-3標準中並未對此做任何說明;所以,嚴格來講 —— 全部函數都會在建立的時候將上層做用域鏈保存在[[Scope]]中。

有些實現中,容許對閉包做用域直接進行訪問。好比Rhino,針對函數的[[Scope]]屬性,對應有一個非標準的 parent屬性,在第二章中做過介紹:

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]]屬性的。 也就是說,某個閉包對其中的變量作修改會影響到其餘閉包對其變量的讀取:

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

正由於這個特性,不少人都會犯一個很是常見的錯誤: 當在循環中建立了函數,而後將循環的索引值和每一個函數綁定的時候,一般獲得的結果不是預期的(預期是但願每一個函數都可以獲取各自對應的索引值)。

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」是能夠很容易就被改變的。

以下所示:

activeContext.Scope = [
... // higher variable objects
{data: [...], k: 3} // activation object
];
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;
}
alert(getElement()); // null, 而不是 2

然而,在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中的閉包做兩個定義(即兩種閉包):

ECMAScript中,閉包指的是:

  • 從理論角度:全部的函數。由於它們都在建立的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變量也是如此,由於函數中訪問全局變量就至關因而在訪問自由變量,這個時候使用最外層的做用域。
  • 從實踐角度:如下函數纔算是閉包:
  1. 即便建立它的上下文已經銷燬,它仍然存在(好比,內部函數從父函數中返回)
  2. 在代碼中引用了自由變量

閉包實踐

實際使用的時候,閉包能夠建立出很是優雅的設計,容許對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://www.faceye.net/search/142545.html

本文介紹了更多關於ECMAScript-262-3的理論知識,而我認爲,這些基礎的理論有助於理解ECMAScript中閉包的概念

相關文章
相關標籤/搜索