深入學習JavaScript之閉包

  接下來需要對之前我們學的作用域原理來一個清晰的認識。

此例中,外部函數指的是包括了內部函數的函數

1.1   閉包的是什麼?

  閉包是基於詞法作用域書寫代碼時所產生的自然結果,閉包是一個晦澀難懂的概念。它準確點來說是,函數和創建該函數的詞法作用域的組合,這個環境包括了這個閉包創建時所能訪問的所有局部變量。

下面我們通過幾個例子來理解閉包的概念。

 function foo() {
         var a=2;
         function bar() {
             console.log(a);
         }
         return bar();
     }
     var baz=foo();
     baz;    //2

  在此函數中,我們定義了一個函數foo(),它有一個局部變量a,以及一個隱藏(局部)函數bar(),此函數的返回值爲隱藏函數

  我們在全局作用域中創建了一個對象,讓它引用foo(),在return的作用下,它會間接的引用bar(),這樣在外部我們也能夠使用內部函數,打破了之前說的函數作用域調用規則-----------內部函數在自己定義的詞法域之外執行

  在foo()函數執行完畢後,按道理要被引擎當做垃圾回收,但是事實並沒有,因爲在調用foo()函數完畢後,還要調用內部函數bar(),bar()引用foo()中的函數a,所以在foo()函數調用結束後,bar()還需要foo(),所以bar()產生的閉包(包括了bar()函數以及所產生創建的局部變量等)阻止了引擎對foo()的回收-----------------bar()保持着對foo()的引用,這個引用就是閉包

  這個函數在定義時的詞法域以外的地方被調用,閉包使得它可以繼續訪問定義時的詞法作用域

    

    我們可以這樣理解:

   在大倉庫中有若干個小倉庫以及貨物,公司派人來採購物品(函數調用)需要用到小倉庫裏面的貨物,於是大倉庫通知小倉庫管理員(內部函數)與採購人員在外進行協商(函數在定義時的詞法域以外被調用),這時小倉庫管理員明確表示採購用到的商品一部分在大倉庫中(內部函數對外部函數中變量的引用),於是大倉庫管理員也被迫留在此進行協商(阻止內部函數引用的外部函數銷燬),達成協議(閉包)。所以閉包更像是一種協議,內部函數與引用的外部函數以及引擎之間的協議。

 

從上我們可以看出形成閉包必要的特徵

  • 內部函數發生調用
  • 內部函數引用外部函數的變量
  • 內部函數在定義以外的詞法作用域執行

 

無論使用何種方式對函數的值進行傳遞,當函數在別處被調用時總能觀察到閉包

例如

 function foo() {
       var a=2;
       function baz() {
           console.log(a);
       }
       bar(baz);
   }
   function bar(fn) {
       fn();
   }
   foo();

  把內部函數baz傳給bar,當調用這個內部函數(fn)時,它涵蓋的foo(),內部作用域的閉包就可以觀察到了,因爲它會輸出a。

  

  間接的傳遞函數也是可以的

 var fn;
  function foo() {
      var a=2;
      function bar() {
          console.log(a);
      }
      fn=bar();    //傳遞值給全局變量fn
  }
  function baz() {
      fn();
  }
  foo();
  baz();

  在foo(),中我們將內部函數bar()賦值給了全局變量fn,在另一個函數中引用了fn,在這裏面引用的是fn,實際上引用的卻是bar(),因而會出現閉包-------------間接調用出現閉包。

 

無論通過何種手段將內部函數傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函數都會使用閉包。

 

在我們通常寫代碼時經常有接觸到閉包。只是你以前並沒有發現而已。比如我們經常使用的JavaScript內置函數工具。

 function wait(message) {
        setTimeout(function timer() {
            console.log(message);
        },1000);
    };
    wait("Hello Word");

  setTimeout函數是JavaScript中的延時處理函數,它的聲明位置並不在wait函數中。但是它在wait()中,調用了內部函數timer()。timer()擁有包括整個wait()的閉包。因此能夠引用wait()中的message。

  我們現在來討論一下,引擎實現此代碼的原理。內置的setTimeout(...)持有對一個參數的引用,這個參數沒有固定的名稱,在這裏它的名稱是  timer  。引擎會在wait(...)執行完畢後再開始處理setTimeout(....)函數,而詞法作用域只在閉包的作用下保持完整---------這就是閉包。

 

本質上無論何時何地,如果將函數(訪問它們各自的詞法作用域)當作第一級的值類型併到處傳遞,你就會看到閉包在這些函數中的應用,。在定時器,監聽器、Ajax請求、跨窗口通信、Web Workers或者任何其他的異步或者同步任務中,只要使用了回調函數,實際上就是在使用閉包

  

總結:閉包在函數調用時發生,無論函數是如何調用,只要它在自己的詞法作用域外發生了調用,且它本身引用了外部函數的變量,那麼在閉包的作用下,外部函數不會被銷燬,它還能訪問之前的詞法作用域------如此便達到了,在全局作用域下,使用外部函數的變量和數據

  

 1.2   循環中的閉包

  在for循環中配合着JavaScript工具函數會生成閉包。這樣恰好說明了JavaScript中的工具函數執行是在其聲明處。

 for(var i=1;i<=5;i++){
      setTimeout(function foo() {
          console.log(i);
      },i*1000);
  };

 單單按照以前學的知識來看,這裏會輸出1-5的數字,每次出現的時間是當前數字乘以1000毫秒。

  但是,我們現在學了閉包,採用閉包理解的話,你就不這麼認爲了。結果是,會輸出5次6。

   爲什麼會這樣呢???

   首先得解釋6從哪裏來,這個循環的終止條件是i不在<=5,條件首次成立時  i  的值是6。因此,輸出顯示的是循環結束時  i  的最終值。

   仔細想想的話,確實是這麼一回事,延遲函數的回調會在循環結束的時執行。事實上,當定時器運行時即使每個迭代中執行的是setTimeout(....,0),所有的回調函數依然是在循環結束後纔會被執行,因此每次輸出一個6出來。

 

   我們還要理解setTimeout(...)函數,它是異步執行的,什麼是異步執行的呢?就是當主程序執行完畢以後纔會執行,JavaScript是單線程運行的,每一次的循環都得等到for(....)中的i循環完畢了以後,setTimeout(...)纔會開始執行。

   那麼爲什麼會打印5次呢???

答案是在JavaScript中維護着一個setTimeout(...)隊列,在第一次i=1時,便啓動了setTimeout(....),但是它必須等待for(...)執行完畢纔會執行,所以會加入setTimeout(...)隊列中,當i=2時,又執行了新的setTimeout(...)隊列,於是又加入了setTimeout(....)隊列中,一直到  i=6  ,此時有五個setTimeout(....)等待執行。更因爲這些setTimeout(...)共用的是一個 i  所以當它們準備輸出結果時,i早已變成6。因此輸出五個6

   值得一提的是在setTimeout(...,0);中的數字可以改變優先級,數字越小優先級越高執行越早。

 

  那麼我們想要讓之前的代碼運行起來每一次都會setTimeout(....)都會輸出當前的  i  值。

 

  接下來進行思考,既然setTimeout(....)是得當for(...)循環結束了以後才執行的,那麼我們能否用IIFE立即執行函數表達式讓它立即執行呢?

  根據之前學習的作用域,儘管五個函數是分別在五個迭代中定義的,但是,他們使用的卻是共享的i,所以我們需要五個函數都有自己的閉包作用域這樣才能記錄下  i  的值。

  使用IIFE剛好可以創建一個全新的詞法作用域。

 for(var i=1;i<=5;i++)
  {
      (function IIFE() {
          setTimeout(function foo() {
              console.log(i);
          },i*1000);
      })();
  };

測試一下

     依舊輸出5次6,那麼是什麼原因呢?

  IIFE創建了新的作用域,但是此作用域中沒有任何實質內容,我們在裏面創建一個變量記錄下  i  的值。

 for(var i=1;i<=5;i++)
  {
      (function IIFE() {
    var  j=i;
          setTimeout(function foo() {
              console.log(i);
          },i*1000);
      })();
  };

輸出結果:

接下來根據我們之前學習的IIFE的作用之一,括號傳遞參數,來對上面式子進行改進。

 for(var i=1;i<=5;i++)
  {
      (function IIFE(j) {
          setTimeout(function foo() {
              console.log(j);
          },j*1000);
      })(i);    //括號傳遞參數
  };

  在迭代內使用IIFE會爲每一個迭代生成一個全新的作用域,使得延遲函數的回調,可以將新的作用域封閉在每個迭代中,每個迭代中將會有一個正確的變量供我們使用。

  

總結:在循環中,調用了JavaScript的工具函數,因爲JavaScript是單線程語言,工具函數就會進入待執行棧,等到for函數執行完畢之後,再執行。在for中每一次循環迭代都會引用一次工具函數,所以也就輸出多個相同的結果。並且它們在每一次迭代中被調用一次,但是它們所用的  i  卻是共享的。

   所以我們需要每次迭代時都有一個自己的閉包,能都記錄下這個i值供我們輸出

     如何解決呢?將工具函數放入IIFE中,記錄下每次變化的i值(j=i),工具函數對記錄下的i值進行調用。

 

1.3  塊作用域與閉包

   仔細思考我們對前面的解決方案的分析。我們使用IIFE在每次迭代時都創建一個新的作用域,目的就是爲了讓setTimeout(...)隊列不再使用同一個共享變量  i   。換句話說,每一次循環迭代我們都需要一個塊作用域。那麼我們能否用  "let"  關鍵字來爲每一次迭代創建一個新的塊作用域呢?

 for(var i=1;i<=5;i++)
  {
      let j=i;
          setTimeout(function foo() {
              console.log(j);
          },j*1000);
  };

輸出結果

事實證明這是可行的,但是在JavaScript中還有更酷的行爲

 for(let i=1;i<=5;i++)
  {
          setTimeout(function foo() {
              console.log(i);
          },i*1000);
  };

  使用  let  關鍵字在for(...)中創建變量有一個特點,每次迭代都將重新聲明一次變量,且此變量的值將是上一次迭代循環的值。那麼我們使用  let  便可實現

 

1.4  模塊

  模塊與閉包息息相關,它是代碼編寫的一種模式。

  接下來我們將學習模塊

   考慮以下代碼:

   function foo(){

    var something="cool";
     var another=[1,2,3];

      function  dosomething(){
          console.log(something);

       }

      function  another(){
           console.log(another.join("!"));
         };


    }

  正如這段代碼所示的,這裏面,只有兩個內部變量dosomething、another。兩個內部函數dosomething(...)、another(...)。它們的詞法作用域(閉包)就是foo函數的內部作用域。

 

接下來考慮以下代碼

 function CoolModule() {
       var something="cool";
       var another=[1,2,3];
       function dosomething() {
           console.log(something);
       };
       function doanother() {
           console.log(another.join("!"));
       };
       return{
           dosomething: dosomething,
           doanother: doanother
       }
   }
   var foo=CoolMudule();
   foo.dosomething();      //cool
   foo.doanother();           //1!2!3

    這個模式在JavaScript叫做模塊,最常見的實現模塊模式的方法叫做模塊暴露。

  

  接下來對這段代碼進行分析,CoolModule是一個函數,必須要通過調用它來創建一個模塊實例。如果不執行外部函數,那麼內部作用域和閉包都無法實現。

   細心點你或許會注意到在CoolModule函數中的返回值類型不同,這是字面量語法(key:value.......)來表示對象,當調用dosomething時調用dosomething,當調用doanother時調用doanother。

   這個返回對象中含有對內部函數而不是內部變量數據的引用。我們保持這個內部數據變量是隱藏且私有的狀態,可以將這個對象的返回值看成是模塊共有的API。要用哪種功能,獲取這個對象,然後調用函數就對了。

  這個對象類型的返回值最終被賦給外部變量foo,然後就可以通過訪問foo來訪問API中的內容和方法了。比如:foo.dosomething();

注意在模塊中返回一個對象{  return  object}不是必須的,你也可以返回一個內部函數,我們經常使用的  jquery  就是這樣。jquery和$符是  jquery  的API,但是它們都是  jquery  的內部函數,因爲函數也是對象,所以它們擁有屬性。

  dosomething()以及doanother()都具有涵蓋模塊實例內部作用域的閉包,不過要注意,在此例中是一定要調用CoolModule(....)函數,當返回一個含有屬性引用(調用外部函數的屬性,此例中dosomething引用something屬性,doanother引用another屬性)的對象的方式來將函數傳遞到詞法作用域外部時,我們已經創造了閉包。

模塊創建的條件

  • 必須有外部的封閉函數,且該外部函數至少調用一次(每調用一次都會創建一次模塊實例)

  • 外部封閉函數的返回值必須是一個內部函數或者內部函數的對象,且內部函數有對外部函數的私有變量的引用(如此才能形成涵蓋外部函數的閉包)

一個帶有函數屬性的對象不一定是模塊,它還得形成涵蓋自己作用域的閉包才行。

 

1.4.1  單例模式

   在上一個我們編寫的CoolModule(...)中,我們每調用一次CoolModule(...)就會創建一個實例,如果我們只需要調用一次CoolModule(...)時,就可以用單例模塊模式。

創建單例模塊模式非常簡單

  • 外部封閉函數在IIFE中編寫
  • IIFE賦值創建模塊實例對象
  • 調用內部函數時,對象.內部函數
 var foo= ( function CoolMudule() {
            var something="cool";
            var another=[1,2,3];
            function dosomething() {
                console.log(something);
            };
            function doanother() {
                console.log(another.join("!"));
            };
            return{
                dosomething: dosomething,
                doanother: doanother
            }
        })();
   foo.dosomething();
   foo.doanother();

  我們將外部函數轉換成了IIFE,並且創建了外部函數的實例對象foo。當我們需要調用內部函數時,只需要對象.內部函數即可。

 

1.4.2  傳遞參數的模塊

  模塊知識形式特殊的函數,所以也具有一般函數的特性,比如傳遞參數

 function CoolModule(id) {
        function identify() {
            console.log(id);
        }
        return{
            identify: identify()
        };
     }
     var foo=CoolModule("boot1");
     var foo1=CoolModule("boot2");
     foo.identify();
     foo1.identify();

  像在此例中,我們需要創建兩個實例,因此就不需要用到單例模塊模式。

 

1.4.3  通過實例更改公共API的方法

   模塊模式一個強大的用法是,通過實例更改公共API內部的函數

 var foo=(function CoolModule(id) {
     var publicAPI={
         change:change,
         identify:identify1
     }
     function change() {     //改變publicAPI中的屬性  identify的值,使它等於identify2
         publicAPI.identify=identify2;
     }
     function identify1() {       //輸出ID
         console.log(id);
     }
     function identify2() {       //改變ID爲大寫
         console.log(id.toUpperCase());
     }
     return publicAPI;
   })("foo module")
        foo.identify();  //foo module
        foo.change();
        foo.identify();   //FOO MODULE

  我們首先  var 了一個變量publicAPI,這個變量中有兩個類型屬性,一個是change,另一個是identify。CoolModule(...)中有三個內部函數,一個是change,它的作用是改變publicAPI中  identify  的值使它的值變爲  identify2;一個是  identify1  輸出id的值,還有一個是  identify2  輸出id的大寫值。

  這裏面我們用到了傳遞參數、單例模式。

  我們通過實例調用內部函數,更改了公共API的方法使之兩個相同的函數,輸出不一樣的結果。

  通過在模塊實例的內部保留對公共API對象的內部引用,可以從內部對模塊實例方法或者屬性進行修改,包括添加、刪除、命名以及重新賦值等等。

 

總結:  閉包並不是一個晦澀難懂的概念,只要是內部函數被包含自己的外部封閉函數以外的對象調用並且它還保持着對原來詞法域的引用,那麼就產生了閉包。

     在for循環中創建函數,那麼很容易因爲閉包而出現問題,原因是:在for中創建函數,這裏的函數並不會執行、而是聲明,等到調用時或者說是主程序完成時纔開始執行,而這時  i  值已經爲結束循環的值了,每一次循環,每一次迭代時都會創建一個函數,這些函數沒有閉包自己的作用域,共用一個i值,所以輸出爲相同的  i。

      解決的方法主要是爲每一個函數創建一個閉包。方法有

  • 將創建的函數放入IIFE中(讓它立刻執行),並記錄下每一次循環的i值

  • 利用  let  創建循環變量,let 在循環中每一次迭代都會創建新的變量,這個變量的值是上一個變量的值,所以如此一來,這些函數就不會共用一個循環變量 i 了
     

  模塊:不暴露私有的數據和函數下,外部函數的返回值爲內部函數或者對象。當想要調用該外部封閉函數的方法時,創建變量並且賦值,再調用方法。

   模塊特徵:

  • 爲創建內部作用域而調用了一個包裝函數(外部封閉函數)

  • 包裝函數的返回值至少包括一個對內部函數的引用,這樣就會創建涵蓋整個包裝函數內部作用域的閉包。

 

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章