Javascript閉包真經

來源:互聯網
上載者:User

繼前陣子寫完對象真經後,這篇文章我嘗試儘力的去講透Js中的閉包。這裡要感謝愛民,愛民的書寫得很好,我從中獲益良多。不過這次我打算換一種思路來寫這篇真經,就是採用提問回答的方式,我下面先提出我要回答的問題,如果讀者你都很自信的能夠回答上,那麼就可以考慮幹別的事情去了。如果感覺自己有點把握不準就請給我一步步的定址吧。:)我保證最後你就會豁然開朗,明白閉包的真諦。問題集:

  1. 什麼是函數執行個體?
  2. 什麼是函數引用?
  3. 什麼是閉包?
  4. 閉包裡有什麼玩意?
  5. 函數執行個體、函數引用和閉包有什麼聯絡?
  6. 閉包的產生的情形?
  7. 閉包中的標識符的優先順序是什麼樣的?
  8. 閉包帶來的可見度問題。

 

什麼是函數執行個體呢?其實在我們平常書寫代碼的過程中,寫的函數就是一段文本,只是對於編譯型語言來說會把它編譯為確定的二進位代碼,並放到確定的記憶體位置執行,而對於Js這樣的解釋型語言,就會在程式啟動並執行時候把這段文本翻譯為電腦能懂的話。那麼這裡函數代碼的文本其實就是這個函數的類,而Js引擎解釋後的記憶體中的資料就是這個函數的執行個體了。所以函數的執行個體就是Js引擎讀過這段代碼後在記憶體中產生的一段資料

什麼是函數引用呢?函數引用就是指向剛才所說的那段記憶體資料的指標。也就是指向某個函數執行個體的指標。同一份執行個體可以有多個引用,只要該執行個體還有指向它的引用,佔用的記憶體就不會被釋放。

什麼是閉包呢?閉包就是函數執行個體執行過程中動態產生的一塊新的記憶體裡的資料集。這句話有可以表達兩層意思:

  • 最初閉包必須由函數執行個體被調用(也就是函數執行個體執行)時才會由Js引擎動態產生;
  • 既然閉包也是一段記憶體地區,當沒有依賴於這個記憶體中資料的引用時,才會被釋放,也就是閉包理論上才會被銷毀。

記住:閉包是執行期的概念!

那閉包裡有什麼玩意呢?這裡我要引入Js引擎在文法分析期就構造的兩類結構。執行內容結構(Context)和調用對象結構(CallObject)。我們還是先看一幅圖片:

從上面的圖片我們容易看出,這是描述一個函數執行個體資訊的圖。Context結構包含了myFunc函數的類型(FUNCTION)、名稱(myFunc)、形式參數(x,y,z)和最關鍵的CallObject結構的引用(body為CallObject)。而CallObject結構就是很詳細的記錄了這個函數的全部文法分析結構:內部聲明的變數表(localDeclVars)、內部聲明的具名函數表(localDeclFuncs)以及除去以上內容之外的其它所有代碼文本的字串表示(source)。

好,現在有了這個文法分析期就產生的CallObject結構,就好分析閉包裡面會有什麼了。當一個函數執行個體被調用時,實際上是從這個原型複製了一份CallObject結構到閉包那塊空的記憶體中去。同時會給裡面的資料賦值。這裡有兩個規則,大家要記下:

  1. 在函數執行個體開始執行時,localDeclVars中的所有值將被置為undefined;
  2. 在函數執行個體執行結束並退出時,localDeclVars不被重設,這就是Js中函數能夠在內部儲存資料的特性的原因;
  3. 這個閉包中的CallObject結構中的資料能保留多久取決於是否還有對其的引用存在,所以這裡就引出了一個推論,閉包的生存周期不依賴於函數執行個體

那麼對於全域對象呢?是什麼樣的結構呢?其實和上面的函數執行個體的基本一樣,唯一不同的是,沒有Context,因為它在最頂層了。全域對象裡面也有一個CallObject,只是這個CallObject有點特殊,就是localDeclVars裡面的資料只會初始化一次,且整個CallObject裡的資料總不被銷毀。

除了我講的複製了一份CallObject,再往裡賦值,閉包其實還包括了一個upvalue數組,這裡數組裡裝載的是閉包鏈上一級閉包中的標識符(localDeclVars和localDeclFuncs及它自身的upvalue數組)的引用

函數執行個體、函數引用和閉包有什麼聯絡呢?要回答這樣的問題,就要好好分別理解上面說的這個三個分別指的是什麼。然後我想在回答這個問題前,通過代碼來感知一些東西。其實有時候人的思維需要一個載體去依靠,也就是我們常說的實踐才能出真知。

[javascript]function myFunc() {    this.doFunc = function() {}}var obj = {};//進入myFunc,取得doFunc()的一個執行個體myFunc.call(obj);//套取函數執行個體的一個引用並賦值給funcvar func = obj.doFunc;//再次進入myFunc,又取得了doFunc()的一個新執行個體myFunc.call(obj);//比較兩次取得的函數執行個體,結果顯示false,表明是不同的執行個體print(func === obj.doFunc); //顯示false//顯示true,表明兩個函數執行個體的代碼文本完全一樣print(func.toString() === obj.doFunc.toString()); //顯示true[/javascript]

我兩次調用了myFunc,卻發現對於相同的代碼文本產生了不同的執行個體(func和obj.doFunc)。這是什麼原因呢?事實上是我們每次調用myFunc函數時,Js引擎進入myFunc函數,完成賦值操作時必然要解釋那段匿名函數代碼文本,所以以它為藍本產生了一個函數執行個體。此後我們讓obj對象的doFunc成員指向這個執行個體。多次調用,就多次的進入myFunc做賦值,就多次的產生新的同樣的代碼文本的執行個體,所以兩次obj.doFunc成員所指的函數執行個體是不一樣的(但函數執行個體的靜態代碼文本完全相同,這裡都是一個匿名空函數)。再看一個執行個體與引用的例子:

[javascript]function MyObject() {}MyObject.prototype.method = function() {};var obj1 = new MyObject();var obj2 = new MyObject();print(obj1.method === obj2.method); //顯示true[/javascript]

這裡的obj1和obj2的方法其實也是對一個函數執行個體的引用,但怎麼就相同呢?其實這樣回顧我在Js對象真經裡說的,prototype原型是一個對象執行個體,裡面維護了自己的成員表,而MyObject的對象執行個體會有一個指標指向這個成員表,而不是複製一份。所以obj1.method和obj2.method實質都是執行同一個函數執行個體的兩個引用。再來:

[javascript]var OutFunc = function() { //閉包1    var MyFunc = function() {};    return function() { //閉包2        return MyFunc;    }}(); //注意這裡的一個叫用作業var f1 = OutFunc();var f2 = OutFunc();print(f1 === f2); //顯示true[/javascript]

這個例子其實玩了一個視覺陷阱。如果你沒有注意那個函數調用的()號,就會由前面講的知識推匯出f1和f2所指的執行個體不是同一個(儘管它們代碼文本都一樣)。但這個例子我想講的其實是關於upvalue數組的問題,不會就忘記這個數組了吧。這裡其實OutFunc最終成了一個匿名函數(function() {return MyFunc})執行個體的引用,而由於閉包1內的localDeclFuncs裡的資料有被引用,所以閉包1不會被銷毀。然後調用這個OutFunc函數執行個體返回的是MyFunc函數的執行個體,但僅有一個,也就是多次調用都返回同一個,其原因在於匿名函數function() {return MyFunc}執行個體其實是通過它運行時產生的閉包2中的upvalue中來找到MyFunc的(位於閉包1中),而MyFunc在我的那個要你們特別留意的()操作時就產生了,在閉包1的localDeclVars中記錄下來。

前面的函數執行個體都對應的是一個閉包,這裡我想拿出一個函數執行個體可以對應多個閉包的例子來為等下回答函數執行個體、函數引用和閉包有什麼聯絡做鋪墊。

[javascript]var globalFunc;function myFunc() { //閉包1    if (globalFunc) {        globalFunc();    }    print('do myFunc: ' + str);    var str = 'test';    if (!globalFunc) {        globalFunc = function() { //閉包2            print('do globalFunc: ' + str);        }    }    return arguments.callee;}myFunc()();/*輸出結果:do myFunc: undefined //第一次執行時顯示do globalFunc: test //第二次執行時顯示do myFunc: undefined //第二次執行時顯示*/[/javascript]

這個例子有點複雜,請讀者耐著性質冷靜的分析下。這裡的myFunc函數執行個體被調用了兩次,但都是同一個myFunc的執行個體。這是因為第一次調用myFunc執行個體後返回了一個它的引用(通過return arguments.callee)。然後立即被()操作,完成第二次調用。在第一次調用中,str還沒有並賦值,但已經被聲明了並被初始化為了undefined,所以顯示do myFunc: undefined。而接著後面的語句,實現了賦值,這時候str的值為“test”,由於globalFunc為undefined,所以賦值為一個匿名函數執行個體的引用。由於globalFunc是全域變數,屬於不會銷毀的全域閉包。所以這時候在第一次myFunc函數執行個體調用完畢後,全域閉包中的globalFunc所指的匿名函數執行個體引用了閉包1中的str(值為test),導致閉包1不會被銷毀,等第二次myFunc函數執行個體被調用時,產生了新的閉包3,同時也會先聲明變數str,初始化為undefined。由於全域變數globalFunc有值,所以會直接調用對應的閉包1中的那個匿名函數執行個體,產生了閉包2,同時閉包2通過upvalue數組取得了閉包1中的str,閉包1中當時為“test”,所以輸出了do globalFunc: test。注意,這裡就體現了同一個執行個體多次調用都要產生新的閉包,且閉包中的資料不是共用的。

這個例子總的來說反應了幾個問題:

  • Js中同一個執行個體可能擁有多個閉包;
  • Js中函數執行個體與閉包的生存周期是分別管理的;
  • Js中函數執行個體被調用,總是會產生一個新的閉包,但上次調用產生的閉包是否已經銷毀取決於那個閉包中是否有被其他閉包引用的資料。

講到這裡,是時候揭曉謎團了。函數執行個體可以有多個函數引用,而只要存在函數執行個體的引用,該執行個體就不會被銷毀。而閉包是函數執行個體被調用時產生的,但不一定隨著調用結束就銷毀,一個函數執行個體可以同時擁有多個閉包。

閉包有些什麼產生的情形呢?其實細分可以分為:

  1. 全域閉包;
  2. 具名函數執行個體產生的閉包;
  3. 匿名函數執行個體產生的閉包;
  4. 通過new Function(bodystr)產生的函數執行個體的閉包;
  5. 通過with語句所指示的對象的閉包。

關於後面3種大家先不要急,我會在後面的閉包帶來的可見度問題中舉例說明。

閉包中的標識符的優先順序是什麼樣的呢?要瞭解優先順序,就要先說說閉包中有什麼標識符。完整地說,函數執行個體閉包內的標識符系統包括:

  1. this
  2. localDeclVars
  3. 函數執行個體的形式參數
  4. arguments
  5. localDeclFuncs

我們先看一個例子:

[javascript]function foo1(arguments) {    print(typeof arguments);}foo1(1000); //顯示numberfunction arguments() {    print(typeof arguments);}arguments(); //顯示objectfunction foo2(foo2) {    print(foo2);}foo2('hi'); //顯示hi[/javascript]

從上面我們不難分析出,形式參數優先於arguments(由foo1知),內建對象arguments優先於函數名(由arguments()知),最後一個反應了形式參數優於函數名。其實有前面兩個也說明了這點形式參數優於arguments優於函數名。再看一個例子:

[javascript]function foo(str) {    var str;    print(str);}foo('test'); //顯示testfunction foo2(str) {    var str = 'not test';    print(str);}foo2('test'); //顯示not test[/javascript]

這個例子可以得出一個結論:

  1. 當形式參數名與未賦值的局部變數名重複時,取形式參數;
  2. 當形式參數名與有值的局部變數名重複時,取局部變數值。

而this關鍵字,我們不能用它去做函數名,也不能作為形式參數,所以沒有衝突,可以理解為它是優先順序最高的。

閉包帶來的可見度問題,這不是一個提問,而是一個對於閉包理解的實踐。之所以這樣說,是因為其實我們遇到的很多標識符可見度的問題,其實和閉包息息相關。比如內建函式可以訪問外部函數中的標識符,完全是由於內建函式執行個體的閉包通過其upvalue數組來獲得外部函數執行個體閉包中的資料的。可見度覆蓋的實質就是在內建函式執行個體閉包中能找到對應的標識符,就不會去通過upvalue數組尋找上一級閉包裡的標識符了。還有比如我們如果在一個函數裡面不用var關鍵字來聲明一個標識,就會隱式的在全域聲明一個這樣的標識。其實這就是因為在函數執行個體的所有閉包中找不到對應的標識,一直到了全域閉包中,也沒有,所以Js引擎就在全域閉包(這個閉包有點特殊)聲明了一個這樣的標識來作為對於你的代碼的一種容錯,所以最好不要這樣去用,容易汙染全域閉包的標識符系統,要知道全域閉包是不會銷毀的。

這裡我想補充講一個很重要的話題,就是閉包在閉包鏈中的位置怎麼確定?

  1. 全域閉包沒什麼好說的,必然是最頂層的;
  2. 對於具名函數執行個體產生的閉包,其實由該具名函數執行個體的靜態文法範圍決定,也就是Js引擎做文法分析時,它處於什麼文法範圍,那以後產生的閉包,在閉包鏈中也要按這個順序,因為函數執行個體被執行時也會在這個位置去執行。也就是如果它在代碼文本裡表現為一個函數裡的函數,那將來它的閉包就被外面函數執行個體產生的閉包包裹;注意:對於SpiderMonkey有點不同,在with語句裡面的函數閉包鏈隸屬於with語句開啟的閉包。這在後面會特別舉例。
  3. 匿名函數執行個體的閉包位置由匿名函數直接量的建立位置決定,動態加入閉包鏈中,而與它是否執行過無關,因為將來要產生閉包時,匿名函數直接量還是會回到建立位置執行;
  4. 通過new Function(bodystr)產生的函數執行個體的閉包很有意思,它不管在哪裡建立,總是直接緊緊的隸屬於全域閉包,也就是其upvalue數組裡的資料是全域閉包中的資料;
  5. 通過with語句所指示的對象的閉包位置由該with語句執行時的具體位置決定,動態加入閉包鏈中。

我們來看一些例子,用來說明上面的結論。

[javascript]var value = 'global value';function myFunc() {    var value = 'local value';    var foo = new Function('print(value)');    foo();}myFunc(); //顯示global valuevar obj = {};var events = {m1: 'clicked', m2: 'changed'};for (e in events) {    obj[e] = function() {        print(events[e]);    }}obj.m1(); //顯示相同obj.m2(); //顯示相同var obj = {};var events = {m1: 'clicked', m2: 'changed'};for (e in events) {    obj[e] = new Function('print(events["' + e + '"])');}obj.m1(); //顯示clickedobj.m2(); //顯示changed[/javascript]

上面一大段代碼顯然可以分為3部分。第一部分說明了通過new Function(bodystr)產生的函數執行個體的閉包在閉包鏈中的位置總是直接隸屬於全域閉包,而不是myFunc的閉包。第二段顯示相同的原因在於調用m1、m2時,它們各自的閉包都要引用全域閉包中的e,這時e已經是events數組for迭代中的最後一個元素的索引了。第三段只用了new Function代替了原來的function,就發生了變化,那是因為Function構造器傳入的都是字串,不會將來引用全域閉包的e了。

如果我們把閉包的可見度理解為閉包的upvalue數組和閉包內的標識符系統,那一般函數執行個體的閉包和通過with語句所指示的對象的閉包在前者上是一致的,而在後者上處理就不一樣了。原因在於:通過with語句所指示的對象的閉包只有對象成員名可訪問,而沒有this、函數形式參數、自動構建賦值的內建對象arguments以及localDeclFuncs,而對於該閉包中的var聲明變數的效果,就具體的引擎實現會有些差異,我覺得應該避免使用這種代碼的寫法,所以這裡就不具體討論了。看一些與with語句有關的代碼:

[javascript]var x;with(x = {x1: 'x1', x2: 'x2'}) {    x.x3 = 'x3'; //通過全域變數x訪問匿名對象    for (var i in x) {        print(x[i]);    }}function self(x) {    return x.self = x;}with(self({x1: 'x1', x2: 'x2'})) {    self.x3 = 'x3'; //通過匿名對象自身的成員self訪問對象自身    for (var i in self) {        if (i != 'self') print(self[i]);    }}[/javascript]

上面的代碼裡注釋都寫得很明白了,我就不多說了,我們現在來看一個嚴重的問題:

[javascript]var obj = {v: 10};var v = 1000;with(obj) {    function foo() {        v *= 3;    }    foo();}/*with(obj) {    obj.foo = function() {        v *= 3;    }    obj.foo();}alert(obj.v); //顯示30alert(v); //顯示1000*/alert(obj.v); //顯示10alert(v); //顯示3000[/javascript]

這段代碼在ie8、safari3.2、opera9和chrome1.0.154.48中都表示為10 3000,但在Firefox3.0.7中就變為了30 1000,這個就很特別了,也許是bug問題。這裡就違背了我前面說的具名函數執行個體閉包在閉包鏈中位置的一般情況,即文法分析期就決定了,視with語句於透明。其實從代碼的第一印象上說,似乎就是給obj對象的屬性v乘了3,而不是全域變數v。其實在除了Firefox3.0.7(其他版本的FF沒有測試過),foo函數的閉包位置在文法分析期就決定了,直接隸屬於全域閉包,而with語句開啟的obj對象的閉包執行的位置在決定了它也直接隸屬於全域閉包,所以就出現了並列的情況,那麼foo函數裡面的乘3自然就無法訪問with閉包裡的對象obj.v了,所以顯示為10 3000。如果想顯示為30和1000我們完全可以利用匿名函數執行個體閉包位置的動態特性,就是直接建立量的位置決定閉包在閉包鏈中的位置。所以上面我注釋的代碼就可以很好的顯示30和1000,且Firefox也沒有問題。注意匿名函數執行個體閉包位置與其是否執行過無關。下面代碼說明這一點:

[javascript]function foo() {    function foo2() { //foo2的閉包        var msg = 'hello';        m = function(varName) { //直接建立量賦值給m時,就決定了這個匿名函數執行個體【將來】的閉包隸屬於foo2的閉包,所以可以通過upvalue數組訪問msg            return eval(varName);        }    }    foo2();    var aFormatStr = 'the value is: ${msg}';    var m;    var rx = /\$\{(.*?)\}/g;    print(aFormatStr.replace(rx, function($0, varName) {        return m(varName);    }))}foo(); //顯示hello[/javascript]

好了,我要講的講完了,總之,閉包是執行期的概念。如果大家對於閉包還有什麼問題或者對於本文有何高見,都歡迎給我留言、評論或者直接email me。

[轉摘:http://hjp.im/javascript/master-javascript-closure/]

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.