JavaScript閉包初探

wyleric 7年前發布 | 10K 次閱讀 閉包 JavaScript開發 JavaScript

閉包是JavaScript中的一個基本的概念,每一個真正的程序員都應該了解它的原理。 互聯網上充斥著對于“閉包”是什么的解釋,但很少深入到事物的“為什么”的一面。 我發現理解內部原理最終可以讓開發者對他們的工具有更強的把握,所以本文將重點闡述“閉包”為什么以及怎樣做的具體細節。 希望您閱讀完本文之后,可以在您的日常工作中更好的利用“閉包”。現在我們就開始吧!

什么是閉包?

“閉包”是JavaScript(和大多數開發語言)的一項非常重要的特性。 

閉包是指向獨立(自由)變量的函數。換句話說,在閉包中定義的函數能夠記住創建它的環境。

注:自由變量指的是那些既不是局部聲明也不是作為參數傳遞進來的變量。 讓我們看幾個例子:

Example 1:

function numberGenerator(){
// Local “free” variable that ends up within the closure
    var num=1;
    function checkNumber(){
        console.log(num);
    }
    num++;
    return checkNumber;
}

var number=numberGenerator();
number();// 2

在上面的例子中,函數numberGenerator創建了一個局部“自由”變量 num 和函數 checkNumber 。函數 checkNumber 沒有屬于自己的局部變量-但是它卻有權限訪問外部函數 numberGenerator 的變量。這就是因為 閉包 。所以它可以使用在 numberGenerator 中定義的變量 num 而且成功地將其輸出到控制臺,即使在 numberGenerator 被返回之后。

Example 2:

在本例中,我們將演示閉包包含在外部函數中聲明的所有局部變量。

function sayHello(){
    var say=function(){console.log(hello);}
    var hello='Hello,world!';
    return say;
}
var sayHelloClosure=sayHello();
sayHelloClosure();// 'Hello,world!'

我們注意到變量 hello 雖然定義在匿名函數之后,但是仍然可以被訪問到。這是因為變量 hello 在創建的時候已經在函數 scope 中定義了,使得當匿名函數最終被執行時能夠獲取到該變量(別著急,我一會兒會解釋 scope 是什么,現在大家可以先忽略它)。

更高層次的理解

下面這些例子將在更高的層次上解釋閉包是什么。總的來說,我們有權訪問到定義在包圍函數中的變量,即使定義這些變量的函數已經被返回。顯然,為了能夠實現這種效果,在后臺一定有某些事情發生。

為了理解這如何變為可能,我們需要了解一些相關的概念-從3000英尺高的地方開始慢慢降落到閉包這片土地上。我們先從“執行上下文”的概念開始入手吧。

Execution Context

執行上下文

執行上下文是ECMAScript標準定義的一個抽象的概念,用來追蹤代碼的運行時賦值。它既可以是代碼初次運行時的全局上下文也可以是代碼執行到一個函數體時的執行上下文。

在任何時間點,只能有一個執行上下文運行 。這就是為什么JavaScript被稱為“單線程”語言,在同一時間,只能處理一個請求。通常,瀏覽器使用“棧”來存放執行上下文。“棧”是一個后進先出(FIFO)的數據結構,意思是最后被壓入棧的數據,會被最先彈出(這是因為我們只能在“棧”的頂端添加或者刪除元素)。當前的或者說“正在運行的”執行上下文總是在“棧”的頂端。當運行的執行上下文中的代碼已經被完全解析時,它被彈出頂部,允許下一個頂部項目作為運行的執行上下文接管。

此外,僅僅因為執行上下文正在運行并不意味著它必須在其他執行上下文可以運行之前完成運行。有時,運行的執行上下文被掛起,并且不同的執行上下文變為運行的執行上下文。然后,掛起的執行上下文可以稍后在其停止的地方拾取。每當一個執行上下文被另一個替換時,一個新的執行上下文被創建并推送到堆棧上,成為當前的執行上下文。

有關在瀏覽器中操作的此概念的實際示例,請參閱下面的示例:

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);// 閉包

moar(15);

然后當 boop 返回時,它會彈出堆棧,并恢復 bar :

當我們有一堆執行上下文一個接一個執行的時候,會經常在中間暫停,然后恢復,我們需要一些方法來跟蹤狀態,以便我們可以管理這些上下文的順序和執行。事實是,根據ECMAScript規范,每個執行上下文具有各種狀態組件,用于跟蹤每個上下文中的代碼執行的進度。

  • 代碼解析狀態 :執行,掛起和恢復與此執行上下文相關聯的代碼的解析所需的任何狀態
  • 函數 :執行上下文正在解析的函數對象(如果正在解析的上下文是腳本或模塊,則為null)
  • 領域 :一組內部對象,ECMAScript全局環境,在該全局環境范圍內加載的所有ECMAScript代碼,以及其他關聯的狀態和資源
  • 詞匯環境 :用于解析此執行上下文中的代碼所作的標識符引用
  • 變量環境 :詞匯環境,其環境記錄保存由此執行上下文中的變量狀態創建的綁定

如果這聽起來太混亂,不要擔心。 在所有這些變量中,詞匯環境變量是我們最感興趣的變量,因為它明確聲明它解析了執行上下文中的代碼所作的“標識符引用”。 你可以把“標識符”當成變量。 因為我們的最初目標是弄清楚在一個函數(或“上下文”)返回之后,我們是否可以神奇地訪問變量,詞匯環境看起來像我們應該挖掘的東西!

注意:技術上,可變環境和詞匯環境都用于實現閉包,但為了簡單起見,我們將其概括為“環境”。

詞匯環境

按照定義:詞匯環境是一種規范類型,用于定義標識符與特定變量和函數的關聯,基于ECMAScript代碼的詞匯嵌套結構。詞匯環境包括環境記錄和對外部詞匯環境的可能空引用。通常,詞匯環境與ECMAScript代碼的一些特定句法結構相關聯,例如TryStatement的函數聲明,BlockStatement或Catch子句,并且每次解析這樣的代碼時創建新的詞匯環境。

讓我們來分解它。

  • 用于定義標識符的關聯 :詞匯環境的目的是管理代碼中的數據(即標識符)。換句話說,它使標識符有意義。例如,如果我們有一行代碼 console.log(x / 10) ,變量(或“標識符”)x沒有任何東西為其提供意義,那么這件事情是沒有意義的。詞匯環境通過其環境記錄(見下文)提供了這個意義(或“關聯”)。、
  • 詞匯環境由一個環境記錄組成 :環境記錄是一種奇特的方式,它保留所有標識符及其在詞匯環境中存在的綁定的記錄。每個詞匯環境都有自己的環境記錄。
  • 詞匯嵌套結構 :這是個有趣的部分,基本上說,內部環境引用圍繞它的外部環境,并且該外部環境也可以具有它自己的外部環境。因此,環境可以用作多于一個內部環境的外部環境。全局環境是沒有外部環境的唯一詞匯環境。這里的語言很棘手,所以讓我們使用一個比喻,并認為詞匯環境如洋蔥層:全局環境是洋蔥的最外層;下面的每個后續層都嵌套在其中。

抽象地,環境在偽代碼中看起來像這樣:

LexicalEnvironment = {
 EnvironmentRecord: {
 // Identifier bindings go here |
 },
 // Reference to the outer environment
 outer: < >
 };
  • 每次解析此類代碼時都會創建一個新的詞匯環境 :每次調用包圍外部函數時,都會創建一個新的詞匯環境。這很重要 - 我們將在最后再回到這一點。(附注:函數不是創建詞匯環境的唯一方法。其他包括塊語句或catch子句。為簡單起見,我將重點介紹由這篇文章中的函數創建的環境)

簡而言之,每個執行上下文都有一個詞匯環境。這個詞匯環境保存著變量及其相關的值,并且還引用了它的外部環境。詞匯環境可以是全局環境,模塊環境(其包含用于模塊的頂層聲明的綁定)或函數環境(由于調用函數而創建的環境)。

Scope Chain

范圍鏈

基于上述定義,我們知道環境可以訪問其父環境,并且其父環境可以訪問其父環境,等等。每個環境都可以訪問的這組標識符稱為“范圍”。我們可以將范圍嵌套到稱為“范圍鏈”的分層環境鏈中。

讓我們看看這個嵌套結構的一個例子:

var x=10;
function foo(){
    var y=20;
    function bar(){
        var z=15;
        return x+y-z;
    }
    return bar;
}

如你所見, bar 嵌套在 foo 中。為了幫助您可視化嵌套,請參見下圖:

我們將在后面的帖子中再次討論這個例子。 與函數關聯的此范圍鏈或環境鏈在創建時保存到函數對象,換句話說,它是由源代碼中的位置靜態定義的。 (這也稱為“詞匯范圍”)。

讓我們快速繞行來理解“動態范圍”和“靜態范圍”之間的區別,這將有助于澄清為了具有閉包,為什么靜態范圍(或詞匯范圍)是必要的。

改道: 動態范圍 vs. 靜態范圍

動態作用域語言具有“基于棧的實現”,意味著局部變量和函數的參數存儲在堆棧中。因此,程序堆棧的運行時狀態決定了要引用的變量。 另一方面,靜態范圍是根據創建的時間來記錄在上下文中。換句話說,程序源代碼的結構決定了你所引用的變量。 此時,您可能想知道動態作用域和靜態作用域是如何不同的。這里有兩個例子來幫助說明:

Example 1:

var x=10;
function foo(){
    var y=x+5;
    return y;
}
function bar(){
    var x=2;
    return foo();
}
function main(){
    foo();// Static scope: 15; Dynamic scope: 15
    bar();// Static scope: 15; Dynamic scope: 7
    return 0;
}

我們在上面看到,當調用函數欄時,靜態作用域和動態作用域返回不同的值。 對于靜態作用域,bar的返回值基于foo創建時的x值。這是因為源代碼的靜態和語法結構,這導致x為10,結果為15。 另一方面,動態范圍為我們提供了一個在運行時跟蹤的變量定義的堆棧 - 這樣我們使用哪個x取決于在運行時是否動態定義范圍。運行函數欄將x = 2推到堆棧頂部,使foo返回7。

Example 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
})();

(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)。bar可以訪問自由變量y,即使在函數foo返回后,因為bar通過其外部環境引用了y,這是foo的環境!bar也可以訪問全局變量x,因為foo的環境可以訪問全局環境。這被稱為“范圍鏈查找”。 回到我們對動態范圍與靜態范圍的討論:對于要實現的閉包,我們不能使用動態棧來存儲我們的變量.原因是因為這意味著當一個函數返回時,這些變量會從堆棧中彈出,不再可用 - 這與我們最初的閉包定義相矛盾。而是,父級上下文的閉包數據保存在所謂的“堆”中,這允許數據在使它們返回的函數調用之后保留(即使在執行上下文從執行調用堆棧彈出之后). 合理?好!現在我們在抽象層面上理解了內部實現,讓我們再來看幾個例子:

Example 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遞增時,它都會更新范圍 - 這是所有函數共享的。這就是為什么任何5個函數嘗試訪問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

Example 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都會創建一個新的閉包,即foo和bar。隨后調用任一閉包函數將更新閉包本身內的閉包變量,表明在iCantThinkOfAName返回后,每個閉包中的變量可以通過iCantThinkOfAName的doSomething函數繼續使用。

Example 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,
};

因為我們的加法和減法函數引用了mysteriousCalculator函數環境,所以他們能夠利用該環境中的變量來計算結果。

Example 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獨占訪問密碼變量,同時使得不可能從外部訪問密碼。

 

來自:http://www.zcfy.cc/article/let-s-learn-javascript-closures-2228.html

 

 本文由用戶 wyleric 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!