精讀Javascript系列(五) 函數閉包

前言

本文專門介紹閉包,但事實上,閉包的難點並不在概念,而是在詞法環境的嵌套上。只要將詞法環境的嵌套關係整理清楚,閉包就瞬間被克服了。

總之,先不廢話了,正文開始。

閉包

如果一個函數在定義的詞法環境外運行並記住了定義時的詞法環境,這樣的現象就可以稱作函數閉包(Function Closure)
舉個簡單例子:

    function f(x=0){
        var count=x;
        
        function getCount(){
            return count++;
        }
        return getCount
    }
    var func = f(10);
    console.log(func());    // 10
    console.log(func());    // 11
    console.log(func());    // 12
    console.log(func());    // 13
    console.log(func());    // 14

首先要明白上面代碼究竟發生了什麼,梳理一下過程:

  1. 函數f返回了函數getCount的引用, 並將局部變量count設爲了10
  2. 外部詞法環境中, func指向了getCount

然後在執行func時就很神奇的進行了疊加。
爲了直觀表示,我就是用圖示法表示環境綁定了(也可以用執行上下文僞代碼):

在這裏插入圖片描述

注意: funcgetCount沒有在同一個詞法環境。

那麼執行過程就是:

  1. func引用自getCount所指向的函數。
  2. 調用func會進入getCount的詞法環境
  3. 解析標識符count,在當前詞法環境中未找到,進入外部詞法環境
  4. 外部詞法環境找到,返回值後疊加。 --> 保存詞法環境狀態
  5. 再次調用func時,會再次訪問外部詞法環境,訪問count,此時count爲11,然後返回。
  6. ……

可以注意到:形成函數的閉包的關鍵在於:它會保存外部詞法環境的狀態
但是爲什麼會這樣?

閉包實現 I: 執行上下文也會創建外部環境
  1. 函數調用時,會爲該函數創建一個執行上下文
  2. 執行上下文中會創建當前詞法環境環境記錄,記作CurrentEnvRec
  3. 除了會創建CurrentEnvRec,還會創建外部詞法環境的環境記錄, 記作OuterEnvRec
  4. 如果OuterEnvRec還有外部詞法環境,那麼繼續創建OuterEnvRec的外部詞法環境的環境記錄 ……
  5. 上面的過程一直延伸到全局詞法環境截止,上面的詞法環境形成了一個鏈表,就是衆所周知的作用域鏈(Scope Chain)了。

環境記錄中提到過,環境記錄用於記錄當前詞法環境標識符狀態的對象;只要環境記錄中的變量存在,那麼就可以訪問

這個作用域鏈會保存在函數[[Scope]]內部屬性中,
可以在調試中可以看到:

在這裏插入圖片描述

注意:
Chrome中,Google瀏覽器專門對console.log做了優化,因此可以通過func.prototype看到這個屬性值。

閉包實現 II: [[Scope]]不會被刪除。

Javascript另外一個特別的地方是:一切皆對象。在Javascript中採用的是標記清除算法釋放內存的;簡單來說,如果對象可以被訪問到,那麼就會一直帶有一個標記,當對象再也無法被訪問到時,那就去除標記,在下一個遍歷週期中被釋放。

  • 因爲函數也是一個對象,並且保存了[[Scope]]屬性的值。
  • 因爲[[Scope]]一直在引用環境記錄
  • 所以[[Scope]]中的環境記錄會一直被保存。
  • 所以函數總能夠訪問到外部詞法環境的值並且能夠一直更新
  • 所以即便是執行上下文被銷燬,函數的詞法環境也沒有消失。再次創建該函數的執行上下文時,也只是重新指向函數的詞法環境而已。

閉包陷阱

一般情況下,不會用上面那種麻煩的形式,而是直接會返回一個匿名函數:

   function f(x=0){
       var count=x;
       
       return function(){
           return count++;
       }
   }

或是更加精簡:

 let f=  (x=0)=>{
     var count=x;
     return ()=>count++
 }

偶爾,會遇到需要多個函數閉包的情形,即:

  let f = ()=>{
      let func = []
      for(var i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func 
  }

這樣,f1應該輸出0f2輸出1f3輸出4 ……
本應該這樣纔對。
但是結果卻是:


  let [f1,f2,f3]=f();
  console.log(f1());  // 9
  console.log(f2());  // 9
  console.log(f3());  // 9

TMD是又爲何?

道理很簡單,因爲使用了var聲明的變量var聲明的變量是沒有塊級作用域的,上面的代碼在邏輯上,等價於:

  let f = ()=>{
      let func = []
      var i
      for(i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func  /// (*)
  }

所以整理一下就是:

  1. 因爲 func[0] / func[1] / func[2]三個閉包函數的外部詞法環境都是循環體的詞法環境函數f的詞法環境
  2. 在查詢標識符i的過程中, 在循環體的詞法環境中未找到標識符i,所以將使用函數f的詞法環境中的標識符i
  3. 調用f1 / f2 / f3時,循環體已經結束, 所以 標識符 i最終值爲3。所以最終皆返回 9

我們知道,導致最終結果都相同的原因是f1/f2/f3在解析標識符i的過程中使用的都是函數詞法環境的標識符i

一圖勝千言:

在這裏插入圖片描述

所以:
可以在函數外部創建一個立即執行函數表達式,我們可以直接使用這個函數作用域中的標識符x

 let f = ()=>{
     let func = []
     var i
     for(i = 0; i < 3; i++){
         func.push((function(){
             var x=i;
             return ()=>x*x;
         }()))
     }
     return func 
 }

詞法環境嵌套圖:
在這裏插入圖片描述

或是直接用循環體的作用域:

  let f = ()=>{
      let func = []
      var i
      for(i = 0; i < 3; i++){
          let x = i;
          func.push(()=>{
              return x*x;
          })
      }
      return func 
  }

詞法環境嵌套圖……
(自己嘗試下)

這樣每次都會聲明一個x並保留標識符i的值,這時正確輸出:

  let [f1,f2,f3]=f();
  console.log(f1());  // 0
  console.log(f2());  // 1
  console.log(f3());  // 4

知道了前因後果,可以再對上面的代碼加以簡化。
let聲明的計數變量每次都會重新聲明,並以上一次循環結束後的值作爲初始化計數變量。(詳見規範CreatePerIterationEnvironment條目)即:

  let f = ()=>{
      let func = []
      for(let i = 0; i < 3; i++){
          func.push(()=>{
              return i*i;
          })
      }
      return func 
  }

okay.

這時詞法環境嵌套圖是這樣子的:
在這裏插入圖片描述

補充: setTimeout

我記得有個經典面試題,是這樣的:

    for(var i =0;i<6;i++)
       setTimeout(()=>{console.log(i)})
    // 輸出 666666

我覺得這個,理解了上面的示例,這個應該不成問題;
但是要注意,裏面有個坑存在,那就是這樣魔改一下:

    // 
   for(var i =0;i<6;i++)
        setTimeout(console.log, 0, i)
    // 0-5

它是正常輸出的, 因爲setTimeout接收了當前i的值作爲參數後,會在函數內部將i參數傳遞給console.log。:
即:

   setTimeout(F, 0, x) 
// 在其setTimeout內部實現上,會有:
   function setTimeout(F, delay, ...x){
       // ....其他代碼
        F.apply(thisObj, x)
        // ...
   }    
   window.setTimeout=setTimeout;

備註:
setTimeoutdelay,其實是最小延遲時間, 但是HTML標準規定,但凡是小於4ms皆以4ms計算

最後

下一篇,開始異步。
要牢記一句話: 進階要有深度,學習不要總被套路

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