1. 程式人生 > >(譯)學習JavaScript閉包

(譯)學習JavaScript閉包

獨立 介紹 摘要 不同之處 display dtw 如何 == 頂部

原文地址:https://medium.freecodecamp.org/lets-learn-javascript-closures-66feb44f6a44 閉包是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是“單線程”的,這就意味著每次只能有一條命令被處理。通常,瀏覽器使用“棧”來維護這個執行上下文,棧是後進先出的數據結構,這意味著最後壓進棧的是最先被彈出來的(這是因為我們只能在棧頂插入或刪除元素)。當前或“正在運行的”執行上下文總是在棧頂的,當運行執行上下文的代碼被完全執行後,它就從棧頂彈出,這就允許下一個頂部項接管運行執行上下文。 而且,僅僅因為執行上下文正在運行,並不意味著它必須在不同的執行上下文運行之前完成運行;有時,運行執行上下文被掛起,不同的執行上下文成為運行的執行上下文,被掛起的執行上下文可能會在以後的某個點上重新回到它被掛起的位置,在任何時刻,一個執行上下文就這樣被其它執行上下文替代,一個新的執行上下文被創建,並壓到棧裏,成為當前執行上下文。 技術分享圖片 在瀏覽器裏用實際的例子來說明這個概念,請看下面這個例子:
 1 var x = 10;
 2 function foo(a) {
 3   var b = 20;
 4 
 5   function bar(c) {
 6     var d = 30;
 7     return boop(x + a + b + c + d);
 8   }
 9 
10   function boop(e) {
11     return e * -1;
12   }
13 
14   return bar;
15 }
16 
17 var moar = foo(5); // Closure  
18 /* 
19   The function below executes the function bar which was returned 
20   when we executed the function foo in the line above. The function bar 
21   invokes boop, at which point bar gets suspended and boop gets push 
22   onto the top of the call stack (see the screenshot below)
23 */
24 moar(15); 


技術分享圖片 當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:人有失足——如果你發現有任何問題,我希望你能跟我說一聲。

延伸閱讀

為了簡單起見,我避開了一些可能對有些讀者感興趣的主題,下面是一些我想分享給你們的鏈接:
  • 執行環境裏的變量環境是什麽?Axel Rauschmayer博士對這個問題做了解釋,所以我把它的博客文章鏈接放在這裏: http://www.2ality.com/2011/04/ecmascript-5-spec-lexicalenvironment.html
  • 各種環境記錄有什麽不同?http://www.ecma-international.org/ecma-262/6.0/#sec-environment-records
  • MDN上關於閉包的一片優秀的文章: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures

(譯)學習JavaScript閉包