讓我們一起學習JavaScript閉包吧

xdxiaotao 8年前發布 | 12K 次閱讀 閉包 JavaScript開發 JavaScript

讓我們一起學習 JavaScript 閉包吧

閉包是JavaScript中的一個基本概念,每一個認真的程序員都應該對它了如指掌。

互聯網上充斥著大量關于“什么是閉包”的解釋,卻很少有人深入探究它“為什么”的一面。

我發現理解閉包的內在原理會使開發者們在使用開發工具時有更大的把握。所以,本文將致力于講解閉包是如何工作的以及其工作原理的具體細節。

希望在你能從中獲得更好的知識儲備,以便在日常工作中更好地利用閉包。讓我們開始吧!

什么是閉包?

閉包是 JavaScript (以及其他大多數編程語言) 的一個極其強大的屬性。正如在 MDN (Mozilla Developer Network) 中定義的那樣:

閉包是指能夠訪問自由變量的函數。換句話說,在閉包中定義的函數可以“記憶”它被創建的環境。

注:自由變量是既不是在本地聲明又不作為參數傳遞的一類變量。(譯者注:如果一個作用域中使用的變量并不是在該作用域中聲明的,那么這個變量對于該作用域來說就是自由變量)

讓我們來看一些例子:

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

GitHub   上查看   raw numberGenerator.js  

在以上例子中,numberGenerator 函數創建了一個局部的自由變量 num (一個數字) 和 checkNumber 函數 (一個在控制臺打印 num 的函數)。checkNumber 函數沒有自己的局部變量,但是,由于使用了閉包,它可以通過 numberGenerator 這個外部函數來訪問(外部聲明的)變量。因此即使在 numberGenerator 函數被返回以后,checkNumber 函數也可以使用 numberGenerator 中聲明的變量 num 從而成功地在控制臺記錄日志。

Example 2:

function sayHello() {
  var say = function() { console.log(hello); } 
  // Local variable that ends up within the closure
  var hello = 'Hello, world!'; 
  return say; 
} 
var sayHelloClosure = sayHello(); 
sayHelloClosure(); // ‘Hello, world!’

在這個例子中我們演示了一個閉包包含了外圍函數中聲明的全部局部變量。

請注意,變量 hello 是在匿名函數之后定義的,但是該匿名函數仍然可以訪問到 hello 這個變量。這是因為變量hello在創建這個函數的“作用域”時就已經被定義了,這使得它在匿名函數最終執行的時候是可用的。(不必擔心,我會在本文的后面解釋“作用域”是什么,現在暫時跳過它!)

深入理解閉包

這些例子從更深層次闡述了什么是閉包。總體來說情況是這樣的:即使聲明這些變量的外圍函數已經返回以后,我們仍然可以訪問在外圍函數中聲明的變量。顯然,在這背后有一些事情發生了,使得這些變量在外圍函數返回值以后仍然可以被訪問到。

為了理解這是如何發生的,我們需要接觸到幾個相關的概念——從3000英尺的高空(抽象的概念)逐步地返回到閉包的“陸地”上來。讓我們從函數運行中最重要的內容——“執行上下文”開始吧!

Execution Context   執行上下文

執行上下文是一個抽象的概念,ECMAScript 規范使用它來追蹤代碼的執行。它可能是你的代碼第一次執行或執行的流程進入函數主體時所在的全局上下文。

執行上下文

在任意一個時間點,只能有唯一一個執行上下文在運行之中。這就是為什么 JavaScript 是“單線程”的原因,意思就是一次只能處理一個請求。一般來說,瀏覽器會用“棧”來保存這個執行上下文。棧是一種“后進先出” (Last In First Out) 的數據結構,即最后插入該棧的元素會最先從棧中被彈出(這是因為我們只能從棧的頂部插入或刪除元素)。當前的執行上下文,或者說正在運行中的執行上下文永遠在棧頂。當運行中的上下文被完全執行以后,它會由棧頂彈出,使得下一個棧頂的項接替它成為正在運行的執行上下文。

除此之外,一個執行上下文正在運行并不代表另一個執行上下文需要等待它完成運行之后才可以開始運行。有時會出現這樣的情況,一個正在運行中的上下文暫停或中止,另外一個上下文開始執行。暫停的上下文可能在稍后某一時間點從它中止的位置繼續執行。一個新的執行上下文被創建并推入棧頂,成為當前的執行上下文,這就是執行上下文替代的機制。

 

讓我們一起學習JavaScript閉包吧

以下是這個概念在瀏覽器中的行為實例:

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);

讓我們一起學習JavaScript閉包吧

當 boop 返回時,它會從棧中彈出,bar 函數會恢復運行:

 

讓我們一起學習JavaScript閉包吧

當我們有很多執行上下文一個接一個地運行時——通常情況下會在中間暫停然后再恢復運行——為了能很好地管理這些上下文的順序和執行情況,我們需要用一些方法來對其狀態進行追蹤。而實際上也是如此,根據ECMAScript的規范,每個執行上下文都有用于跟蹤代碼執行進程的各種狀態的組件。包括:

  • 代碼執行狀態: 任何需要開始運行,暫停和恢復執行上下文相關代碼執行的狀態
  • 函數: 上下文中正在執行的函數對象(正在執行的上下文是腳本或模塊的情況下可能是null)
  • Realm 一系列內部對象,一個ECMAScript全局環境,所有在全局環境的作用域內加載的ECMAScript代碼,和其他相關的狀態及資源。
  • 詞法環境: 用于解決此執行上下文內代碼所做的標識符引用。
  • 變量環境: 一種詞法環境,該詞法環境的環境記錄保留了變量聲明時在執行上下文中創建的綁定關系。

如果以上這些讓你讀起來很困惑,不必擔心。在所有變量之中,詞法環境變量是我們最感興趣的一個,因為它明確聲明它解決了這個執行上下文內代碼中的“標識符引用”。你可以把“標識符”想成是變量。由于我們最初的目的就是弄清楚它是如何做到在一個函數(或“上下文”)返回以后還能神奇地訪問變量,因此詞法環境看起來就是我們需要深入挖掘的東西!

注意:從技術上來說,變量環境和詞法環境都是用來實現閉包的,但為了簡單起見,我們將這二者歸納為“環境”。想了解關于詞法環境和變量環境的區別的更詳盡的解釋,可以參看 Alex Rauschmayer 博士這篇非常棒的 文章

詞法環境

定義:詞法環境是一個基于 ECMAScript 代碼的詞法嵌套結構來定義特定變量和函數標識符的關聯的規范類型。詞法環境由一個環境記錄及一個可能為空的對外部詞法環境的引用構成。通常,一個詞法環境會與ECMAScript代碼的一些特定語法結構相關聯,例如:FunctionDeclaration(函數聲明), BlockStatement(塊語句), TryStatement(Try語句)的Catch clause(Catch子句)。每當此類代碼執行時,都會創建一個新的詞法環境。— ECMAScript-262/6.0

讓我們來把這個概念分解一下。

  • “用于定義標識符的關聯”: 詞法環境目的就是在代碼中管理數據(即標識符)。換句話說,它給標識符賦予了含義。比如當我們寫出這樣一行代碼 “log(x /10)” 如果我們沒有給變量x賦予一些含義(聲明變量 x),那么這個變量(或者說標識符)x 就是毫無意義的。詞法環境就通過它的環境記錄(參見下文)提供了這個含義(或“關聯”)。
  • “詞法環境包含一個環境記錄”: 環境記錄保留了所有存在于該詞法環境中的標識符及其綁定的記錄。每一個詞法環境都有它自己的環境記錄。
  • “詞法嵌套結構”: 這是最有趣的部分,它大致說明了一個內部環境引用了包圍它的外部環境,同時,這個外部環境還可以有它自己的外部環境。結果就是,一個環境可以作為外部環境服務于多個內部環境。全局環境是唯一一個沒有外部環境的詞法環境。這里會有一點難理解,讓我們來用一個比喻:把詞法環境想成是洋蔥的層,全局環境是洋蔥的最外層,隨后的每一層都依次被嵌套在內部。

 

抽象地來說,(嵌套的)環境就像下面的偽代碼中表現的這樣:

LexicalEnvironment = {
  EnvironmentRecord: { 
  // Identifier bindings go here
  },
  // Reference to the outer environment
  outer: < > 
};
  • “每當此類代碼執行時,就會創建一個新的詞法環境”: 每次一個外圍函數被調用時,就會創建一個新的詞法環境。這很重要——我們會在文末再回到這一點。(邊注:函數并不是創建詞法環境的唯一途徑。其他途徑包括:塊語句或 catch 子句。為簡單起見,我會在本文中將重點放在通過函數創建環境)

總之,每個執行上下文都有一個詞法環境。這個詞法環境保留了變量和與其相關聯的值,以及對其外部環境的引用。詞法環境可以是全局環境,模塊的環境(包含一個模塊的頂級聲明的綁定),或是函數的環境(該環境隨著函數的調用而創建)。

作用域鏈

基于以上概念,我們知道了一個環境可以訪問它的父環境,并且該父環境還可以繼續訪問它的父環境,以此類推。每個環境能夠訪問的一系列標識符,我們稱其為“作用域”。我們可以將多個作用域嵌套到一個環境的分級鏈式結構中,即“作用域鏈”。

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

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

可以看到,bar 嵌套在 foo 之中。為了幫助你更清晰地看到嵌套結構,請看下方圖解:

 

讓我們一起學習JavaScript閉包吧

我們會在本文的后面重溫這個例子。

這個作用域鏈,或者說與函數相關聯的環境鏈,在函數被創建時就被保存在函數對象當中。換句話說,它按照位置被靜態地定義在源代碼內部。(這也被稱為“詞法作用域”。)

讓我們來快速地繞個路,來理解一下“動態作用域”和“靜態作用域”的區別。它講幫助我們闡明為什么想實現閉包,靜態作用域(或詞法作用域)是必不可少的。

動態作用域 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 的時候,靜態作用域和動態作用域返回了不同的值。

在靜態作用域中,bar 的返回值是基于函數 foo 創建時 x 的值。這是因為源代碼的靜態和詞法的結構導致 x 是 10 而最終結果是 15.

而另一方面,動態作用域給了我們一個在運行時追蹤變量定義的棧——因此,由于我們使用的 x 在運行時被動態地定義,所以它的值取決于 x 在當前作用域中的實際的定義。函數 bar 在運行時將 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
})(); 
// Higher-order function
(function (arg) { 
  var myVar = 1500; 
  arg();  // Static scope: 100; Dynamic scope: 1500
})(foo);

類似地,在以上動態作用域的例子中,變量 myVar 是通過被調用的函數中(動態定義)的 myVar 來解析的 ,而相對靜態作用域來說,myVar 解析為在創建時即儲存于兩個立即調用函數(IIFE, Immediately Invoked Function Expression)的作用域中的變量。

可以看到,動態作用域通常會導致一些歧義。它沒有明確自由變量會從哪個作用域被解析。

閉包

你可能會認為以上討論是題外話,但事實上,我們已經覆蓋了需要用來理解閉包的所有(知識):

每個函數都有一個執行上下文,它包括一個在函數中能夠賦予變量含義的環境和一個對其父環境的引用。對父環境的引用使得它父環境中的所有變量可以用于內部函數,無論內部函數是在創建它們(這些變量)的作用域以外還是以內調用的。

因此,這看起來就像是函數會“記得”這個環境(或者說作用域),因為字面上來看函數能夠引用環境(和環境中定義的變量)!

讓我們回到這個嵌套結構的例子

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 的環境通向全局環境。這叫做“作用域鏈查找”。

回到我們關于動態作用域和靜態作用域的討論:為了實現閉包,我們不能經由一個動態的棧來儲存變量(不能使用動態作用域)。原因是,這(使用動態作用域)意味著當一個函數返回時,變量將會從棧中彈出并且不再可用——這與我們最初定義的閉包相互矛盾。真正的情況應該正相反,閉包中父上下文的數據儲存于“堆”(heap,一種數據結構)中,它允許數據在調用的函數返回(也就是在執行上下文在執行調用的棧中彈出)以后仍然能夠保留。

明白了嗎?好的!既然我們已經從抽象的層面理解了內在含義,讓我們來多看幾個例子:

Example 1:

我們在 for-loop 中試圖將其中的計數變量和其它函數關聯在一起時的一個典型的例子/錯誤:

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-loop 存在時,它的環境看起來是這樣的:

environment: {
  EnvironmentRecord: { 
    result: [...], 
    i: 5 
  }, 
  outer: null, 
}

這里錯誤的假設就是,在結果(result)數列中,五個函數的作用域是不同的。事實上正相反,實際上五個函數的環境(上下文/作用域)全部相同。因此,每次變量i增加時,作用域都會更新——這個作用域被所有函數共享。這就是為什么這五個函數中的任意一個在訪問i時都返回 5(i 在 for-loop 存在時等于 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-loop 的迭代都會創建一個新的標識符綁定。

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

因為我們的 add 和 subtract 函數引用了 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  能獨家訪問 password 變量,也保證了不能從外部(其他途徑)訪問 password。

太長不想看?以下是本文摘要

  • 執行上下文是由 ECMAScript 規范所使用的一個抽象的概念,它用于追蹤代碼的執行狀態。在任意時間點,只能有唯一一個執行上下文對應正在執行的代碼。
  • 每個執行上下文都有一個詞法環境。這個詞法環境保持著標識符的綁定(即變量和與其相關聯的變量),還可以引用它的外部環境。
  • 每個環境能夠訪問的標識符集叫做“作用域”。我們可以將這些作用域嵌套成為一個分級的環境鏈——就是我們所知的“作用域鏈”。
  • 每個函數都有一個執行上下文,它包括一個在函數中賦予變量含義的詞法環境和對其父環境的引用。因為函數對環境的引用,使它看起來就像是函數“記住了”這個環境(作用域)一樣。這就是一個閉包
  • 每當一個封閉的外部函數被調用時都會創建一個閉包。換句話說,內部函數不需要為了創建閉包而返回。
  • 在 JavaScript 中,閉包是詞法相關的,意思是它在源代碼中由它的位置而被靜態地定義。
  • 閉包有很多實際應用案例。一個非常重要的用途就是保留外部作用域對一個變量的私有引用(僅通過唯一途徑例如某一個特定函數來訪問一個變量)。

結語

希望這篇文章對你有一定幫助,并且能讓你在頭腦中形成一個關于 JavaScript 中閉包是如何實現的模型。可以看到,理解它工作原理的細節能讓人更容易看懂閉包——更不用說這會讓我們在debug的時候不那么頭痛。

 

 

 

來自:http://web.jobbole.com/88167/

 

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