標籤:第四章 師傅 書籍 副作用 重要 檢索 img this blog
javascript學習-閉包1.什麼是閉包
大多數書本中對閉包的定義是:“閉包是指有權訪問另一個函數範圍中的變數的函數。”。這個概念過於抽象了,對初學者而言沒啥協助。好在《Javascript忍者秘籍》5.1中給了一個例子來進一步的解釋了什麼是閉包:
var outerValue= ‘ninja‘; var later; function outerFunction() { var innerValue = "samurai"; function innerFunction(paramValue) { assert(outerValue == "ninja", "I can see the outerValue."); assert(innerValue == "samurai", "I can see the innerValue."); assert(paramValue == "wakizashi", "I can see the paramValue."); assert(tooLater == "ronin", "Inner can see the tooLater."); } later = innerFunction; } assert(tooLater, "Outer can‘t see the tooLater."); var tooLater = "ronin"; outerFunction(); later("wakizashi");
測試結果是:
看,這個later指向的就是一個閉包,它實際指向了一個外部函數outerFunction中的一個內建函式innerFunction。當outerFunction函數被調用通過全域變數later將innerFunction函數從outerFunction函數這個封閉的監獄裡放出來後,innerFunction函數就一下子變得超級厲害了,成為了閉包,一旦調用閉包,它既能看見全域的outerValue,監獄裡的innerValue,自己隨身攜帶的paramValue,還能看見以前根本不認識的tooLater。
當然了,我認為這裡例子並不完整,為了形式的完整性,我給它加強一下:
asserts(); test("函數閉包", function() { var before_outerFunction = "before_outerFunction"; function outerFunction(outerParam) { var before_innerFunction = "before_innerFunction"; function innerFunction(innerParam) { return { before_outerFunction : before_outerFunction, after_outerFunction : after_outerFunction, before_innerFunction : before_innerFunction, after_innerFunction : after_innerFunction, outerParam : outerParam, innerParam : innerParam, before_callClosure : before_callClosure, after_callClosure : after_callClosure, }; } var after_innerFunction = "after_innerFunction"; return innerFunction; } var after_outerFunction = "after_outerFunction"; var closure = outerFunction("outerParam"); var before_callClosure = "before_callClosure"; var ret = closure("innerParam"); assert(ret.before_outerFunction, "before_outerFunction"); assert(ret.after_outerFunction, "after_outerFunction"); assert(ret.before_innerFunction, "before_innerFunction"); assert(ret.after_innerFunction, "after_innerFunction"); assert(ret.outerParam, "outerParam"); assert(ret.innerParam, "innerParam"); assert(ret.before_callClosure, "before_callClosure"); assert(ret.after_callClosure, "after_callClosure"); var after_callClosure = "after_callClosure"; });
測試結果是:
結論就是,當閉包被調用的那一刻,它馬上就立地成佛了,既能看到眼前看到的,也能看到曾今看到的,前世今生,形形色色,全都曆曆在目啊。只有尚未發生的after_callClosure,那個實在是看不到。
難怪不止一本書中提到,只有理解了閉包才能真正的理解Javascript,這玩意就是一個反直覺的異類啊。
2.函數範圍鏈
如果只是認識一下什麼是閉包,那上面的一段就夠了,但是這遠遠稱不上理解閉包。《Javascript忍者秘籍》在這裡及其不負責任的開始大講特講怎麼使用閉包:
- 用閉包實現私人變數
- 在回呼函數中使用閉包
- 在定時器中使用閉包
- 用閉包實現函數的bind
- 用閉包實現函數的curry化
- 用閉包實現函數結果的緩衝
- 用閉包實現函數的封裝
這個忍者師傅估計當時是喝多了,簡單露了兩手後就扔給我們一堆的掌法、步法、劍法、刀法。唯獨把最關鍵的內功心法給忘了。好了,不指望它了,還是自己接著找師傅吧,有錢就是任性,請了一堆的師傅在桌上擺著,所以才這麼有底氣。於是我看到了《Javascript進階程式設計》這位牛逼的不行的師傅。高手就是高手,一上來就告訴我,要理解閉包,先翻到第四章看看什麼是範圍鏈。立馬翻過去看啊,4.2關於執行環境及範圍,只有短短的兩頁,大體意思是:通過執行環境、範圍鏈、使用中的物件,我們實現了變數的一層層尋找。得了,我智商不夠,繼續換老師,於是我又找到了《高效能Javascript》,第二章中的一小段,題目是“範圍鏈和標識符解析”,同樣是短短的兩頁,字字珠璣,圖文並茂,立馬有種醍醐灌頂的感覺。
每一個Javascript函數都表示為一個對象,更確切的說,是Function對象的一個執行個體。
當編譯器看到下面的全域函數:
function add(num1, num2) { var sum = num1 + num2; return sum; }
它通過類似下面的代碼來建立函數對象:
var add = new Function("num1", "num2", "var sum = num1 + num2;\nreturn sum;");
Function函數中要自動完成範圍鏈的構造:
- 建立add函數對象的範圍鏈對象,並把引用儲存在add函數對象的[[Scope]]屬性中
- 把範圍鏈對象的第一項指向全域範圍對象
聽起來是蠻複雜,用下面的圖形象化一下:
當add被調用時,例如通過下面的代碼:
var total = add(5, 10);
這次輪到引擎來幹活了,它要完成下面的工作:
- 執行此函數會建立一個稱為執行環境(execution context)的內部對象。一個執行環境定義了一個函數執行時的環境。函數每次執行時都對應的執行環境都是臨時的,所以多次調用同一個函數就會導致建立多個執行環境。當函數執行完畢,執行環境就被銷毀。
- 每個執行環境都有自己的範圍鏈,用於解析標識符。當執行環境被建立時,它的範圍鏈初始化為當前運行函數的[[Scope]]屬性中的對象。這些值按照它們出現在函數中的順序,被複製到執行環境的範圍鏈中。
- 這個過程一旦完成,一個被稱為“使用中的物件”(activation object)的新對象就為執行環境建立好了。使用中的物件作為函數運行時的變數對象,包含了此函數的所有局部變數,具名引數,參數集合以及this。
- 然後使用中的物件被推入到執行環境範圍鏈的最前端。
- 函數執行過程中每次解析標識符,就在執行環境的範圍鏈中從前往後的尋找
- 函數執行完畢,執行環境被銷毀,使用中的物件也隨之被銷毀。
再用書上的圖形象化一下:
3.閉包與函數範圍鏈
仍然是《高效能Javascript》,第二章其中的一小段,題目是“閉包、範圍和記憶體”。給出的範例程式碼如下:
function saveDocument(id) { } function assignEvents() { var id = "xdi9592"; document.getElementById("save-btn").onclick = function(event) { saveDocument(id); } }
assignEvents()函數給一個DOM元素設定事件處理函數。這個事件處理函數就是一個閉包,它在assignEvents()執行時建立,並且能訪問所屬範圍的id變數。為了讓這個閉包訪問id,必須建立一個特定的範圍鏈。
當assignEvents()函數執行時,一個包含了變數id以及其他資料的使用中的物件被建立。它成為執行環境範圍鏈中的第一個對象,而全域對象緊隨其後。當閉包被建立時,它的[[Scope]]屬性被初始化為這些對象。
由於閉包的[[Scope]]屬性包含了與執行環境範圍鏈相同的的對象的引用,因此會產生副作用。通常來說,函數的使用中的物件會隨著執行環境一同銷毀。但引入閉包時,由於引用仍然存在於閉包的[[Scope]]屬性中,因此啟用物件無法被銷毀。
當閉包被執行時,會建立一個執行環境,它的範圍鏈與屬性[[Scope]]中所引用的兩個相同的範圍鏈對象一起被初始化,然後一個使用中的物件為閉包自身所建立。
《Javascript進階程式設計》也有一個類似的例子,範例程式碼如下:
function createComparisonFunction(propertyName) { return function(object1, object2) { var value1 = object1[propertyName]; var value2 = object2[propertyName]; if (value1 < value2) { return -1; } else if (value1 > value2) { return 1; } else { return 0; } }; } //建立函數 var compareNames = createComparisonFunction("name"); //調用函數 var result = compareNames({ name: "Nicholas" }, { name: "Greg" }); //解除對匿名函數的引用(以便釋放記憶體) compareNames = null;
函數compareNames被調用時的執行環境如:
這張圖其實很容易引起誤解,很多人以為這張圖是函數compareNames被調用時的一個快照,其實不是,當createComparisonFunction函數執行時,createComparisonFunction函數的執行環境是沒錯的。但是它返回的那個匿名函數是一個閉包,因此該匿名函數的範圍鏈會複製createComparisonFunction函數的執行環境的範圍鏈,然後createComparisonFunction函數結束,createComparisonFunction函數的執行環境被銷毀,但是createComparisonFunction函數的使用中的物件因為被閉包引用了,所以無法銷毀。
當compareNames執行時,它依然遵循著普通函數的執行流程:
- 建立函數的執行環境;
- 建立函數的執行環境的範圍鏈,並複製函數的範圍鏈;
- 建立函數的使用中的物件,並加入到函數的執行環境的範圍鏈的第一條
4.全域對象是什麼
這個問題提的似乎很沒有水平啊,學習Javascript的初學者哪個不知道全域對象的重要性呢。但是如果換一個角度來看,如果把所有Javascript代碼認為是寫在一個最外層的超級函數裡的。那麼當這個超級函數執行時,它應該也會繼續函數調用的三板斧:
- 建立超級函數的執行環境;
- 建立超級函數的執行環境的範圍鏈,並複製超級函數的範圍鏈,此時為空白;
- 建立超級函數的使用中的物件,並加入到超級函數的執行環境的範圍鏈的第一條
根據函數範圍鏈的檢索機制和全域對象的用法,我們似乎可以得到一個推論:全域對象其實就是超級函數的使用中的物件。
再進一步,我們定義的所有全域函數其實都是閉包,因為它們都把超級函數的使用中的物件,也就是全域函數複製到了自己的函數範圍鏈中。
再次回到我們開頭對閉包的測試案例,如果我們不是直接返回內建函式,而是直接在外部函數裡調用內建函式呢?
test("函數閉包", function() { var before_outerFunction = "before_outerFunction"; function outerFunction(outerParam) { var before_innerFunction = "before_innerFunction"; function innerFunction(innerParam) { return { before_outerFunction : before_outerFunction, after_outerFunction : after_outerFunction, before_innerFunction : before_innerFunction, after_innerFunction : after_innerFunction, outerParam : outerParam, innerParam : innerParam, before_callClosure : before_callClosure, after_callClosure : after_callClosure, }; } var after_innerFunction = "after_innerFunction"; var ret = innerFunction("xxx"); assert(ret.before_outerFunction, "before_outerFunction"); assert(ret.after_outerFunction, "after_outerFunction"); assert(ret.before_innerFunction, "before_innerFunction"); assert(ret.after_innerFunction, "after_innerFunction"); assert(ret.outerParam, "outerParam"); assert(ret.innerParam, "innerParam"); assert(ret.before_callClosure, "before_callClosure"); assert(ret.after_callClosure, "after_callClosure"); return innerFunction; } var after_outerFunction = "after_outerFunction"; var closure = outerFunction("outerParam"); //log(closure); var before_callClosure = "before_callClosure"; var ret = closure("innerParam"); assert(ret.before_outerFunction, "before_outerFunction"); assert(ret.after_outerFunction, "after_outerFunction"); assert(ret.before_innerFunction, "before_innerFunction"); assert(ret.after_innerFunction, "after_innerFunction"); assert(ret.outerParam, "outerParam"); assert(ret.innerParam, "innerParam"); assert(ret.before_callClosure, "before_callClosure"); assert(ret.after_callClosure, "after_callClosure"); var after_callClosure = "after_callClosure"; });
測試結果是:
一切和預想的一樣,所謂的閉包並不是return是才發生的,而是在內建函式被編譯器建立函數對象的那一刻就決定的。為函數的範圍鏈複製當前函數執行環境的範圍鏈。用形象化一下:
最關鍵的是這套函數定義、函數調用機制是可以無限嵌套下去的,而且函數定義和函數調用的時機也是分離的。每個函數執行時都有自己的使用中的物件負責管理自己的範圍,執行環境範圍鏈的職責不過是把這些嵌套的函數的各自的使用中的物件串聯起來。函數的範圍鏈的職責不過是相當於一個中間變數,負責儲存上一級函數執行環境的範圍鏈。
所以內建函式直接在外部函數內調用的話也能訪問到內建函式的相關變數,不是因為內建函式調用時真的可以看見外部函數,而是因為內建函式的執行環境的範圍鏈中已經複製了外部函數的執行環境的範圍鏈。函數的執行環境的範圍鏈是自完備的,函數調用時只會在自己的函數的執行環境的範圍鏈中尋找,其實它根本就不知道外部函數或者全域函數什麼的。
如果內建函式不return出去的話,一切都會隨著外部函數調用完成,外部函數的執行環境對象被銷毀,導致外部函數的執行環境範圍鏈的被銷毀,導致外部函式活動對象被銷毀,與此同時內建函式對象作為局部變數也會被銷毀。這一系列的銷毀過程將內建函式的範圍鏈複製了外部函數的執行環境的範圍鏈的“罪證”被掩蓋得天衣無縫。臨時的外部函數的使用中的物件也絕對不會跑到籠子外面去。
但是一旦內建函式被return出去的話,內建函式的範圍鏈複製了外部函數的執行環境的範圍鏈的“罪證”被暴露了,本該被銷毀的外部函數的使用中的物件也意外地活了下來,並隨時等待著隨著內建函式被調用而繼續呼風喚雨。其實不僅僅是外部函數的使用中的物件,外部函數的執行環境的範圍鏈上的所有使用中的物件都意外的活了下來,如果我們構造一個二層以上的函數嵌套,不斷地進行函數的定義和函數的調用,最後返回一個最內層的函數,你就會發現一組本該死去的使用中的物件都意外的活了下來。
閉包執行後直接設定為null可以保證那些意外活下來的使用中的物件被清除嗎?按道理應該是這樣的,不過很不確定,這取決於引擎的記憶體回收機制怎麼玩的,如果按照引用計數的垃圾收集方式,這個推論應該是真的,但是目前大多數引擎採用的卻是標記清除的垃圾收集方式,這就很難保證這個推論是真的了。或者更簡單的,隨著閉包引用變數的自動清除,也能讓那些使用中的物件壽終正寢,也是說得過去的。這或許也正好解釋了看到的Javascript代碼中很少有對閉包主動設定為null的。
在Javascript的世界裡,其設計思想果然還是一如既往的單純質樸。
如何管理函數?Javascript回答說用函數對象。
如何管理函數的範圍?Javascript回答說用使用中的物件。
如果函數調用有嵌套呢?Javascript回答說用範圍鏈,把使用中的物件串起來。
如果一個外部函數返回了一個內建函式,導致外部函數的使用中的物件泄露了怎麼辦?Javascript回答說那就叫做閉包吧。
5.後記
本文的一切功勞屬於那些經典書籍的作者們,我不生產知識,我只是知識的搬運工。本文的一切錯誤屬於我個人,誰讓我是初學者呢,有時候搬錯了也是難免的,那就在不斷地錯誤不斷地改正中不斷地成長吧。
javascript學習-閉包