【轉】學習JavaScript閉包

------------------------------------------------------------------------------------------------------------
閉包是JavaScript中一個基礎概念,這是每一個嚴格意思上的程序員都應該十分熟悉的。
 
網絡上有不少文章介紹閉包是什麼,可是不多有文章深刻講解爲何是這樣的。
 
我發覺從根本上去理解一種技術,可以使開發人員去熟練地使用他們所掌握的工具,因此這篇文章致力於從細節上去講解閉包內部原理是怎麼樣的,以及爲何是這樣的。
 
但願在你之後的平常工做中,可以更好的運用閉包的優點。那咱們開始吧!
 

什麼是閉包?

閉包是JavaScript(和大多數編程語言)中一個強大的特性。MDN對閉包的定義是:
閉包是指向獨立(自由)變量的函數,換句話說,定義在閉包裏的函數「記住」了它建立時的環境。
 
注:自由變量是指那些既不是局部變量,也不是做爲參數傳遞的變量。
 
咱們看幾個例子:
 
例1:
複製代碼
 1 function numberGenerator() {
 2   // 閉包裏的局部「自由」變量
 3   var num = 1;
 4   function checkNumber() { 
 5     console.log(num);
 6   }
 7   num++;
 8   return checkNumber;
 9 }
10 
11 var number = numberGenerator();
12 number(); // 2
複製代碼

 


在上面的例子中, numberGenerator函數建立了一個局部的「自由」變量 num (數字變量)和 checkNumber (把 num打印在命令窗口的函數)。 checkNumber 函數中沒有定義局部變量——然而,它能夠訪問父函數( numberGenerator)裏定義的變量,這就是由於閉包。所以,它可使用定義在 numberGenerator函數裏的 num變量,併成功地把它輸出在命令窗口,即使是在 numberGenerator函數返回以後依然如此。 
 
例2:
在這個例子中,咱們將演示一個閉包裏包含了全部定義在其父函數內部定義的局部變量。
複製代碼
1 function sayHello() {
2   var say = function() { console.log(hello); }
3   // Local variable that ends up within the closure 
4   var hello = 'Hello, world!';
5   return say;
6 }
7 var sayHelloClosure = sayHello(); 
8 sayHelloClosure(); // ‘Hello, world!’
複製代碼

 


注意變量 hello是如何在匿名函數後定義的,但這個匿名函數依然能夠訪問 hello變量,這是由於 hello變量被建立時已經定義在函數「做用域」裏了,這使得當匿名函數最終執行時, hello變量依然可用。(別急,我將隨後在這篇文章中解釋什麼是「做用域」,如今,就讓咱們來看看) 
 

高層次的理解

 
這兩個例子從一個高層次來闡述了「什麼」閉包。通常的主題是這樣的——咱們能夠訪問定義在封閉函數裏的變量,即便這些封閉函數定義在變量返回以後。顯然,在這背後確定作了一些其它的事情,這些事使得這些變量即便在其後的封閉函數返回以後還能夠訪問。
 
爲了理解這是怎麼實現的,咱們須要去接觸一些相關的概念——咱們將從更高的層次一步步走向閉包。讓咱們從一個函數運行的全局上下文開始,即所謂的「執行上下文」。
 

執行上下文

 
執行上下文是ECMAScript規範使用的抽象概念,用於跟蹤代碼的運行時評估。這能夠是你代碼首先執行時的全局上下文,或者是當執行到一個函數體時的上下文。
 
 
在任意一個時間點,都只能運行一個執行上下文,這就是爲何JavaScript是「單線程」的,這就意味着每次只能有一條命令被處理。一般,瀏覽器使用「棧」來維護這個執行上下文,棧是後進先出的數據結構,這意味着最後壓進棧的是最早被彈出來的(這是由於咱們只能在棧頂插入或刪除元素)。當前或「正在運行的」執行上下文老是在棧頂的,當運行執行上下文的代碼被徹底執行後,它就從棧頂彈出,這就容許下一個頂部項接管運行執行上下文。
 
並且,僅僅由於執行上下文正在運行,並不意味着它必須在不一樣的執行上下文運行以前完成運行;有時,運行執行上下文被掛起,不一樣的執行上下文成爲運行的執行上下文,被掛起的執行上下文可能會在之後的某個點上從新回到它被掛起的位置,在任什麼時候刻,一個執行上下文就這樣被其它執行上下文替代,一個新的執行上下文被建立,並壓到棧裏,成爲當前執行上下文。
 
 
在瀏覽器裏用實際的例子來講明這個概念,請看下面這個例子:
 
按 Ctrl+C 複製代碼
<gr_block p="0,502">var x = 10;
function foo(a) {
  var b = 20;

  function bar(c) {
  var d = 30;
  return boop(x + a + b + c + d);
  }

  function boop(e) {
  return e * -1;
  }

  return bar;
}

var moar = foo(5); // Closure
/*
  The function below executes the function bar which was returned
  when we executed the function foo in the line above. The function bar
  invokes boop, at which point bar gets suspended and boop gets push
  onto the top of the call stack (see the screenshot below)
*/
moar(15);
 
按 Ctrl+C 複製代碼

 


 
 
 
當boop 返回時,它會彈出棧頂,而bar 復原:
 
 
當咱們有一串執行上下文一個接一個運行時——一般一個執行上下文在中間被暫停,而後又會被恢復——咱們須要一種方式來跟進這個狀態的變化,因此咱們能夠管理這些執行這些上下文的順序,實際上就是這樣的。根據ECMAScript規範,每一個執行上下文有各類狀態組件,用於記錄每一個上下文中的代碼的進展狀況。這包括:
  • 代碼評估狀態:執行、暫停和恢復與此執行上下文相關的代碼的任何狀態。
  • 函數:該執行上下文正在評估的函數對象。(若是被評估的上下文是腳本或模塊,則爲null)
  • 領域:一組內部對象,ECMAScript全局環境,在該全局環境範圍內加載的全部ECMAScript代碼,以及其餘關聯的狀態和資源。
  • 詞法環境: 用來解析該執行上下文中的代碼所做的標識符引用。
  • 變量環境:詞法環境,環境記錄保存由該執行上下文中的變量狀態建立的綁定。
若是這聽起來讓你很迷惑,不要擔憂,全部這些變量,詞法環境變量對咱們來講是最有意思的變量,由於它顯示聲明,它解析該執行上下文中的代碼所做的「標識符引用」。你能夠認爲「標識符」就是變量。由於咱們最初的目的是弄清楚,它是怎麼去訪問那些即便函數(或「上下文」)已經返回的變量,詞法環境看起來就是咱們應該去深究的東西。
 
注意:從技術上說,經過使用變量環境和詞法環境一塊兒來實現閉包,可是爲了簡單起見,咱們將統一用「環境」來表示,對於詞法環境和變量環境間的不一樣處的細節解釋,能夠查看 Alex Rauschmayer’s博士的 article
 

詞法環境

 
定義:詞法環境是一種規範類型,用於根據ECMAScript代碼的詞彙嵌套結構定義標識符與特定變量和函數的關聯。詞彙環境由一個環境記錄和一個指向外部詞彙環境的可能爲空的引用組成。一般,詞彙環境與ECMAScript代碼的某些特定的語法結構相關聯,好比函數聲明、塊語句或異常捕獲語句,以及每次執行這些代碼時,都會建立一個新的詞法環境。
 
讓咱們來分開解釋下:
  • 「用於定義標識符的關聯」:詞法環境的目的是用來管理代碼裏的數據(如標識符),換句話說,它使得標識符有意義。例如,若是咱們有一行代碼「console.log(x / 10)」,若是變量(或「標識符」)x沒有任何含義,那麼這行代碼就沒有任何意義了。詞法環境就是經過它的環境記錄來提供意義(或「關聯」)。
  • 「詞法環境由環境記錄組成」:環境記錄是用一種奇特的方式來描述它是保存了全部標識符和它們在詞法環境裏的綁定的記錄。每一個詞法環境都有各自的環境記錄。
  • 「詞法嵌套結構」:這是最有意思的部分,這個基本上說是它的內部環境引用它的外部環境,而它的外部環境也同樣能夠有它的外部環境,因此,一個環境能夠是多個內部環境的外部環境。全局環境是惟一一個沒有外部環境的詞法環境,這就是JS的棘手之處,咱們能夠用洋蔥的皮層來表示詞法環境:全局環境就是洋蔥最外層的皮層,每個子層都嵌套在它裏面。
 
 
抽象地說,用僞代碼來描述環境它看起來就是這樣的:
複製代碼
1 LexicalEnvironment = {
2   EnvironmentRecord: {
3   // Identifier bindings go here
4   },
5   
6   // Reference to the outer environment
7   outer: < >
8 };
複製代碼

 

 
  •  「每次執行這樣的代碼就會建立一個新的詞法環境」:每次一個封閉的外部函數被調用時,就會建立一個新的詞法環境,這一點很重要——咱們在文章最後將會再說到這點。(邊注:函數不是惟一能夠建立詞法環境的方式,塊語句和catch子句也能夠建立詞法環境,爲了簡單起見,在這篇文章中咱們將只說函數建立的環境。
總之,每個執行上下文都有一個詞法環境,這個詞法環境包含了變量和其相關的值,以及對它外部環境的引用。詞法環境能夠是全局環境、模塊環境(它包含對模塊頂層聲明的綁定),或者函數環境(因爲調用函數建立的環境)
 
 

做用域鏈

 
基於上面的定義,咱們知道一個環境能夠訪問它的父環境,它的父環境也能夠訪問它的父環境,依次類推。每一個環境均可以訪問的這個標識符集稱爲「做用域」。咱們能夠嵌套做用域到一個層次環境鏈裏,這就是咱們所知道的「做用域鏈」。
 
咱們來看一個嵌套結構的例子:
複製代碼
 1 var x = 10;
 2 
 3 function foo() {
 4   var y = 20; // free variable
 5   function bar() {
 6     var z = 15; // free variable
 7     return x + y + z;
 8   }
 9   return bar;
10 }
複製代碼

 


就像你所看到的, bar就是嵌套在 foo裏,爲你幫你視覺化嵌套,請看下圖: 
 
 
咱們在文章後面再回顧一下這個例子。
 
做用域鏈或者一個函數相關的環境鏈,是在建立時保存在這個函數對象。它是由源代碼中的位置靜態定義的。(這就是咱們熟知的「詞法做用域」)
 
讓咱們快速地瞭解一下「動態做用域」和「靜態做用域」的不一樣之處,這將幫助咱們理解爲了實現閉包, 爲何靜態做用域(或者詞法做用域)是必須存在的。
 

動態做用域 VS 靜態做用域

 
動態做用域語言具備「基於棧的實現」,這意味着局部變量和函數參數被存放在堆棧裏,所以,程序堆棧的運行時狀態決定了你所引用的變量。
 
另外一方面,靜態範圍是根據建立的時間來記錄在上下文中,換句話說,程序源代碼的結構決定了你所引用的變量。
 
到此,你可能會想動態做用域和靜態做用域是如何不一樣的。下面有兩個例子來幫你闡述這一點:
 
例1:
 
複製代碼
 1 var x = 10;
 2 
 3 function foo() {
 4   var y = x + 5;
 5   return y;
 6 }
 7  
 8 function bar() {
 9   var x = 2;
10   return foo();
11 }
12  
13 function main() {
14   foo(); // Static scope: 15; Dynamic scope: 15
15   bar(); // Static scope: 15; Dynamic scope: 7
16   return 0;
17 }
複製代碼

 


bar函數被調用時,咱們能夠看到上面的動態做用域和靜態做用域返回了不一樣的值。
 
在靜態做用域裏, bar返回的值是基於 foo函數建立時返回的 x的值,這是由於源代碼的靜態和詞法結構,結果就是 x的值是10,最後返回的結果就是15.
 
另外一方面,動態做用域在運行時爲咱們提供了一組變量定義——這樣咱們具體使用的是哪一個 x就取決於哪一個 x在做用域裏,以及在運行時哪一個 x被動態定義了。運行 bar函數把x=2壓到棧頂,這樣就使得 foo返回7了。
 
例2:
 
複製代碼
var myVar = 100;
 
function foo() {
  console.log(myVar);
}
 
foo(); // Static scope: 100; Dynamic scope: 100
 
(function () {
  var myVar = 50;
  foo(); // Static scope: 100; Dynamic scope: 50
})();

// Higher-order function
(function (arg) {
  var myVar = 1500;
  arg();  // Static scope: 100; Dynamic scope: 1500
})(foo);
複製代碼

 


一樣,在動態做用域的例子,上面的 myVar變量在使用了 myVar變量的函數被調用的地方解析。另外一方面,在靜態做用域裏,將 myVar解析爲在建立兩個IIFE函數的範圍內保存的變量 。
 
就像你所看到的,動態做用域經常致使一些歧義,這不能明確知道自由變量將解析自哪一個做用域。
 

閉包

 
有些可能讓你以爲離題了,可是事實上,咱們已經涵蓋了咱們所須要瞭解閉包的全部東西了:
 
每一個函數都有一個執行上下文,它包含給定函數裏的變量意義的環境,和指向它父環境裏的引用。指向父環境裏的引用使得父做用域裏的全部變量對於其全部內部函數都是可用的,無論內部函數是否在它們建立時的做用域內或外被調用。
 
因此,這就像函數「記住」它的環境(或者做用域),由於函數實際上有一個指向這個環境的引用(以及定義在那個環境裏的變量)
 
回到嵌套結構的例子:
 
複製代碼
var x = 10;

function foo() {
  var y = 20; // free variable
  function bar() {
    var z = 15; // free variable
    return x + y + z;
  }
  return bar;
}

var test = foo();

test(); // 45
複製代碼

 


基於咱們對環境是如何工做的認識,咱們能夠說,上面例子中定義的環境看起來是這樣的(注意,這個徹底是僞代碼): 
 
複製代碼
GlobalEnvironment = {
  EnvironmentRecord: { 
    // built-in identifiers
    Array: '<func>',
    Object: '<func>',
    // etc..
    
    // custom identifiers
    x: 10
  },
  outer: null
};
 
fooEnvironment = {
  EnvironmentRecord: {
    y: 20,
    bar: '<func>'
  }
  outer: GlobalEnvironment
};

barEnvironment = {
  EnvironmentRecord: {
    z: 15
  }
  outer: fooEnvironment
};
複製代碼

 

 
當咱們調用 test函數時,咱們獲得的結果是45,這是從 bar函數被調用時返回的值(由於 foo函數返回 bar函數),即便 foo函數返回後, bar仍是能夠訪問變量 y,由於 bar經過它的外部環境引用 y,它的外部環境就是 foo的環境, bar也能夠訪問全局變量 x,由於 foo的環境能夠訪問全局環境。這稱之爲「沿着做用域鏈查找」 
 
返回咱們討論的動態做用域和靜態做用域:要實現閉包,咱們不能使用動態做用域來存儲咱們的變量。這是由於,這樣作的話,當函數返回時,變量將會從棧裏彈出,並將再也不有效——這就和咱們對閉包最初的定義正好相反。取而代之是閉包中父級上下文中的數據被保存在稱之爲「堆」的東西里,它容許函數調用返回後,它的數據還保存在堆裏(好比 即便執行上下文被彈出執行調用棧)。
 
聽起來頗有道理?很好,咱們如今在抽象層面理解了閉包的內部實現,讓咱們來多看幾個例子:
 
例1:
一個典型的例子/錯誤是當有一個for循環,並且咱們嘗試把for循環中的計數變量與for循環中的一些函數相關聯:
 
複製代碼
var result = [];
 
for (var i = 0; i < 5; i++) {
  result[i] = function () {
    console.log(i);
  };
}

result[0](); // 5, expected 0
result[1](); // 5, expected 1
result[2](); // 5, expected 2
result[3](); // 5, expected 3
result[4](); // 5, expected 4
複製代碼

 


回到咱們剛纔所學的,咱們就能夠垂手可得就發現其中的錯誤所在!絕對,當for循環結束後,它這裏的環境就像下面的同樣: 
 
複製代碼
environment: {
  EnvironmentRecord: {
    result: [...],
    i: 5
  },
  outer: null,
}
複製代碼

 


這裏錯誤的假想在做用域,覺得結果數組中五個函數的做用域是不同的,然而,事實上結果數組中五個函數的環境(或者/上下文/做用域)是同樣的,所以,變量 i每增長一次,它就更新了做用域裏的值——這個做用域裏的值是被全部函數共享的。這就是爲何五個函數中的任意一個去訪問 i時都返回5的緣由(當for循環結束時, i等於5)。 
 
解決這個問題的一種方式,是爲每個函數建立一個附加的封閉上下文,這樣每一個函數都能取得它們本身擁有的執行上下文/做用域:
 
複製代碼
var result = [];
 
for (var i = 0; i < 5; i++) {
  result[i] = (function inner(x) {
    // additional enclosing context
    return function() {
      console.log(x);
    }
  })(i);
}

result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4
複製代碼

 


對!這樣就能夠了:) 
 
另外,更聰明的方法是用let代替var,由於let是塊做用域,因此在for循環中一個新的標識符綁定是在每次迭代時被建立的:
 
複製代碼
var result = [];
 
for (let i = 0; i < 5; i++) {
  result[i] = function () {
    console.log(i);
  };
}

result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4
複製代碼

 


例2:  
在這個例子裏,咱們將展現每次回調函數時是怎麼建立一個新的、獨立的閉包:
 
複製代碼
function iCantThinkOfAName(num, obj) {
  // This array variable, along with the 2 parameters passed in, 
  // are 'captured' by the nested function 'doSomething'
  var array = [1, 2, 3];
  function doSomething(i) {
    num += i;
    array.push(num);
    console.log('num: ' + num);
    console.log('array: ' + array);
    console.log('obj.value: ' + obj.value);
  }
  
  return doSomething;
}

var referenceObject = { value: 10 };
var foo = iCantThinkOfAName(2, referenceObject); // closure #1
var bar = iCantThinkOfAName(6, referenceObject); // closure #2

foo(2); 
/*
  num: 4
  array: 1,2,3,4
  obj.value: 10
*/

bar(2); 
/*
  num: 8
  array: 1,2,3,8
  obj.value: 10
*/

referenceObject.value++;

foo(4);
/*
  num: 8
  array: 1,2,3,4,8
  obj.value: 11
*/

bar(4); 
/*
  num: 12
  array: 1,2,3,8,12
  obj.value: 11
*/
複製代碼

 


在這個例子裏,咱們能夠看到每次調用 iCantThinkOfAName函數時都會建立一個新的閉包,也就是 foobar。後續調用每一個閉包函數都會更新閉包內的變量,這展現了 iCantThinkOfAName函數返回後,每一個閉包裏的變量繼續被 iCantThinkOfAName函數裏的 doSomething函數所使用。
 
例3:
 
複製代碼
function mysteriousCalculator(a, b) {
    var mysteriousVariable = 3;
    return {
        add: function() {
            var result = a + b + mysteriousVariable;
            return toFixedTwoPlaces(result);
        },
        
        subtract: function() {
            var result = a - b - mysteriousVariable;
            return toFixedTwoPlaces(result);
        }
    }
}

function toFixedTwoPlaces(value) {
    return value.toFixed(2);
}

var myCalculator = mysteriousCalculator(10.01, 2.01);
myCalculator.add() // 15.02
myCalculator.subtract() // 5.00
複製代碼

 


咱們可以看到的是 mysteriousCalculator是在全局做用域裏,並且它返回了兩個函數。抽象來看,上面例子中的環境就像是這樣的: 
 
複製代碼
GlobalEnvironment = {
  EnvironmentRecord: { 
    // built-in identifiers
    Array: '<func>',
    Object: '<func>',
    // etc...

    // custom identifiers
    mysteriousCalculator: '<func>',
    toFixedTwoPlaces: '<func>',
  },
  outer: null,
};
 
mysteriousCalculatorEnvironment = {
  EnvironmentRecord: {
    a: 10.01,
    b: 2.01,  
    mysteriousVariable: 3,
  }
  outer: GlobalEnvironment,
};

addEnvironment = {
  EnvironmentRecord: {
    result: 15.02
  }
  outer: mysteriousCalculatorEnvironment,
};

subtractEnvironment = {
  EnvironmentRecord: {
    result: 5.00
  }
  outer: mysteriousCalculatorEnvironment,
};
複製代碼

 


由於咱們的 addsubtract函數都有一個指向 mysteriousCalculator函數環境的引用,它們可使用那個環境裏的變量來計算結果。 
 
例4:
最後這個例子演示了閉包最重要的一個功能:維護一個私有指向外部做用域變量的引用。
 
複製代碼
function secretPassword() {
  var password = 'xh38sk';
  return {
    guessPassword: function(guess) {
      if (guess === password) {
        return true;
      } else {
        return false;
      }
    }
  }
}

var passwordGame = secretPassword();
passwordGame.guessPassword('heyisthisit?'); // false
passwordGame.guessPassword('xh38sk'); // true
複製代碼

 


這是一個很強大的技巧——它使得閉包函數 guessPassword能夠獨佔訪問 password變量,同時讓 password變量不能從外部訪問。 
 

摘要

 
  • 執行上下文是ECMAScript規範用來根據運行時代碼執行的一個抽象概念。在任什麼時候候,在代碼執行時都只有一個執行上下文。
  • 每一個執行上下文都有一個詞法環境,這個詞法環境保留着標識符綁定(如變量及其相關的值),同時還有一個指向它外部環境的引用。
  • 每一個環境均可以訪問的標識符集稱爲「做用域」。咱們能夠嵌套這些做用域到層次環境鏈中,這就是「做用域鏈」。
  • 每一個函數都有一個執行上下文,它由一個給予函數裏的變量意義的詞法環境,和指向父環境的引用組成,這看起來就像是函數「記住」這個環境(或者做用域),由於函數事實上有一個指向這個環境的引用,這就是閉包。
  • 每次一個封閉外部函數被調用時就會建立一個閉包,換句話說,內部函數不須要返回要建立的閉包。
  • JavaScript裏的閉包做用域就是詞法,這意味着它是在源代碼裏的位置靜態定義的。
  • 閉包用許多實際的用處,最重要的一個用處是維護一個私有指向外部環境變量的引用。
 

結束語

 
我但願這篇文章能對你有所幫助,但願它能給你一種心智模式——在JavaScript裏閉包是如何實現的。正如你所見,理解它們是如何工做的,可讓你更好地掌握閉包——更不用說當你調試Bug時爲你省下了不少麻煩。
 
PS:人有失足——若是你發現有任何問題,我但願你能跟我說一聲。
 

延伸閱讀

 
爲了簡單起見,我避開了一些可能對有些讀者感興趣的主題,下面是一些我想分享給大家的連接:  
相關文章
相關標籤/搜索