javascript
重学JavaScript深入理解系列(六)
JavaScript深入理解—-閉包(Closures)
概要
本文將介紹一個在JavaScript經常會拿來討論的話題 —— 閉包(closure)。閉包其實已經是個老生常談的話題了; 有大量文章都介紹過閉包的內容,盡管如此,這里還是要試著從理論角度來討論下閉包,看看ECMAScript中的閉包內部究竟是如何工作的。
正如在此前文章中提到的,這些文章都是系列文章,相互之間都是有關聯的。因此,為了更好的理解本文要介紹的內容, 建議先去閱讀下第四章 - 作用域鏈和 第二章 - 變量對象。
概論
在討論ECMAScript閉包之前,先來介紹下函數式編程(與ECMA-262-3 標準無關)中一些基本定義。 然而,為了更好的解釋這些定義,這里還是拿ECMAScript來舉例。眾所周知,在函數式語言中(ECMAScript也支持這種風格),函數即是數據。就比方說,函數可以保存在變量中,可以當參數傳遞給其他函數,還可以當返回值返回等等。 這類函數有特殊的名字和結構。
定義
函數式參數(“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 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); // 20funArg(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); // 20foo.__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: 2return 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中,閉包指的是:
- 從理論角度:所有的函數。因為它們都在創建的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變量也是如此,因為函數中訪問全局變量就相當于是在訪問自由變量,這個時候使用最外層的作用域。
- 從實踐角度:以下函數才算是閉包:
- 即使創建它的上下文已經銷毀,它仍然存在(比如,內部函數從父函數中返回)
- 在代碼中引用了自由變量
閉包實踐
實際使用的時候,閉包可以創建出非常優雅的設計,允許對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 復制代碼總結
本文介紹了更多關于ECMAScript-262-3的理論知識,而我認為,這些基礎的理論有助于理解ECMAScript中閉包的概念。原文地址
譯文地址
重學系列傳送門
重學JavaScript深入理解系列(一)
重學JavaScript深入理解系列(二)
重學JavaScript深入理解系列(三)
重學JavaScript深入理解系列(四)
重學JavaScript深入理解系列(五)
轉載于:https://juejin.im/post/5ce78710f265da1bca51b5ef
總結
以上是生活随笔為你收集整理的重学JavaScript深入理解系列(六)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 中国传统节日春节网页HTML代码 学生网
- 下一篇: javascript学习系列(8):数组