【譯】理解JavaScript閉包——新手指南

linjiajun發表於2018-11-28

閉包是JavaScript中一個基本的概念,每個JavaScript開發者都應該知道和理解的。然而,很多新手JavaScript開發者對這個概念還是很困惑的。

正確理解閉包可以幫助你寫出更好、更高效、簡潔的程式碼。同時,這將會幫助你成為更好的JavaScript開發者。

因此,在這篇文章中,我將會嘗試解析閉包內部原理以及它在JavaScript中是如何工作的。

好,廢話少說,讓我們開始吧。

什麼是閉包

用一句話來說就是,閉包是一個可以訪問它外部函式作用域的一個函式,即使這個外部函式已經返回了。這意味著即使在函式執行完之後,閉包也可以記住及訪問其外部函式的變數和引數。

在我們深入學習閉包之前,首先,我們先理解下詞法作用域(lexical scope)。

什麼是詞法作用域

JavaScript中的詞法作用域(或者靜態作用域)是指在原始碼物理位置中變數、函式以及物件的可訪問性。舉個例子:

let a = 'global';
  function outer() {
    let b = 'outer';
    function inner() {
      let c = 'inner'
      console.log(c);   // prints 'inner'
      console.log(b);   // prints 'outer'
      console.log(a);   // prints 'global'
    }
    console.log(a);     // prints 'global'
    console.log(b);     // prints 'outer'
    inner();
  }
outer();
console.log(a);         // prints 'global'
複製程式碼

這裡的inner函式可以訪問自己作用域下定義的變數和outer函式的作用域以及全域性作用域。而outer函式可以訪問自己作用域下定義的變數已經全域性作用域。 所以,上面程式碼的一個作用域鏈是這樣的:

Global {
  outer {
    inner
  }
}
複製程式碼

注意到,inner函式被outer函式的詞法作用域所包圍,而outer函式又被全域性作用域所包圍。這就是inner函式可以訪問outer函式以及全域性作用域定義的變數的原因。

閉包的實際例子

在深入閉包是如何工作之前,我們先來看下閉包一些實際的例子。

// 例子1
function person() {
  let name = 'Peter';
  
  return function displayName() {
    console.log(name);
  };
}
let peter = person();
peter(); // prints 'Peter'
複製程式碼

在這段程式碼中,我們呼叫了返回內部函式displayName的person函式,並將該函式儲存在perter變數中。當我們呼叫perter函式時(實際上是引用displayName函式),名字“Perter”會列印到控制檯。 但是在displayName函式中並沒有定義任何名為name到變數,所以即使該函式返回了,該函式也可以用某種方式訪問其外部函式person的變數。所以displayName函式實際上是一個閉包。

// 例子2
function getCounter() {
  let counter = 0;
  return function() {
    return counter++;
  }
}
let count = getCounter();
console.log(count());  // 0
console.log(count());  // 1
console.log(count());  // 2
複製程式碼

同樣地,我們通過呼叫getCounter函式返回一個匿名內部函式,並且儲存到count變數中。由於count函式現在是一個閉包,可以在即使在getCounter函式返回後訪問getCounter函式的變數couneter。 但是請注意,counter的值在每次count函式呼叫時都不會像通常那樣重置為0。 這是因為,在每次呼叫count()的時候,都會建立新的函式作用域,但是隻為getCounter函式建立一個作用域,因為變數counter定義在getCounter函式作用域內,所以每次呼叫count函式時數值會增加而不是重置為0。

閉包工作原理

到目前為止,我們已經討論了什麼是閉包以及一些實際的例子。下面我們來了解下閉包在javaScript中的工作原理。 要真正理解閉包在JavaScript中的工作原理,首先,我們必須要理解JavaScript中的兩個重要的概念:1)執行上下文 2)詞法環境。

執行上下文(Execution Context)

執行上下文是一個抽象的環境,其中的JavaScript程式碼會被計算求值和執行。當全域性程式碼執行時,它在全域性執行上下文中執行,函式程式碼在函式執行上下文中執行。

當前只能有一個正在執行執行環境(因為JavaScript是單執行緒語言),它由被稱為執行堆疊或呼叫堆疊的堆疊資料結構管理。

執行堆疊是一個具有LIFO(後進先出)結構的堆疊,其中只能在堆疊頂部進行新增或刪除選項。

當前正在執行的執行上下文始終位於堆疊的頂部,當正在執行的函式執行完成後,其執行上下文將從堆疊中彈出移除,然後控制到達堆疊中它下面的執行上下文。

下面我們看一個程式碼片段更好地理解執行上下文和堆疊。

【譯】理解JavaScript閉包——新手指南

當以上程式碼執行時,JavaScript引擎會建立一個全域性執行上下文來執行全域性程式碼,然後當執行到呼叫first()函式時,它會為該函式建立一個新的執行上下文並且將其推送到執行堆疊的頂部。 所以,上面程式碼的執行堆疊就如下圖那樣:

【譯】理解JavaScript閉包——新手指南

當first()函式執行完後,它的執行堆疊就會從堆疊中移除。然後,控制到達下一個執行上下文,就是全域性執行上下文了。因此,將會執行全域性作用域下剩餘的程式碼。

詞法環境(Lexical Envirionment)

每次JavaScript引擎建立一個執行上下文執行函式或者全域性程式碼時,它還會建立一個新的詞法環境來儲存在該函式執行期間在該函式中定義的變數。

詞法環境是一個包含識別符號(identifier)-變數(variable)對映的資料結構。(這裡所說的識別符號(identifier)指的是變數或者函式的名稱,而變數(variable)是實際物件[包括函式型別物件]或原始值的引用)。

一個詞法環境有兩個元件:(1)環境資料 (2)對外部環境的引用。

1、環境資料是指變數和函式宣告實際存放的地方。

2、對外部環境的引用意思是說它可以訪問外部(父級)的詞法環境。這個元件很重要,是理解閉包工作原理的關鍵。

一個詞法環境從概念上看起來像這樣:

lexicalEnvironment = {
  environmentRecord: {
    <identifier> : <value>,
    <identifier> : <value>
  }
  outer: < Reference to the parent lexical environment> // 父級詞法環境引用
}
複製程式碼

現在我們來重新看下之前上面的程式碼片段:

let a = 'Hello World!';
function first() {
  let b = 25;  
  console.log('Inside first function');
}
first();
console.log('Inside global execution context');
複製程式碼

當JavaScript引擎建立一個全域性執行上下文來執行全域性程式碼時,它還建立了一個新的詞法環境來儲存在全域性作用域定義的變數和函式。因此,全域性作用域的詞法環境將如下所示:

globalLexicalEnvironment = {
  environmentRecord: {
      a     : 'Hello World!',
      first : < reference to function object >
  }
  outer: null
}
複製程式碼

這裡的外部詞法環境設定為null,因為全域性作用域沒有外部詞法環境。 當引擎為first()函式建立執行上下文時,它還會建立一個詞法環境來儲存在執行函式期間在該函式中定義的變數。 所以函式的詞彙環境看起來像這樣:

functionLexicalEnvironment = {
  environmentRecord: {
      b    : 25,
  }
  outer: <globalLexicalEnvironment>
}
複製程式碼

函式的外部詞法環境設定為全域性詞法環境,因為該函式被原始碼中的全域性作用域所包圍。

詳細的閉包示例

現在我們理解了執行上下文和詞法環境了,下面我們回到閉包。

例子一

我們先看下這個程式碼塊

function person() {
  let name = 'Peter';
  
  return function displayName() {
    console.log(name);
  };
}
let peter = person();
peter(); // prints 'Peter'
複製程式碼

當person函式執行,JavaScript引擎會給這個函式建立一個新的執行上下文和詞法環境。當該函式執行完成後,將返回displayName函式並且分配給到perter變數。 所以它的詞法環境看起來像這樣:

personLexicalEnvironment = {
  environmentRecord: {
    name : 'Peter',
    displayName: < displayName function reference>
  }
  outer: <globalLexicalEnvironment>
}
複製程式碼

當person函式執行完成後,它的執行上下文就會從堆疊裡移除。但它的詞法環境仍然在記憶體裡,是因為它的詞法環境被它內部的displayName函式的詞法環境引用。所以變數在記憶體中仍然可用。

當peter函式執行(其實是引用displayName函式),JavaScript引擎會為該函式建立新的執行上下文和詞法環境。 所以它的詞法環境看起來像這樣:

displayNameLexicalEnvironment = {
  environmentRecord: {
    
  }
  outer: <personLexicalEnvironment>
}
複製程式碼

因為displayName函式沒有宣告變數,所以它的環境資料是空的。該函式在執行期間,javaScript引擎將嘗試在該函式的詞法環境中尋找變數name。 因為displayName函式的詞法環境沒有任何變數,所以引擎會到外層的詞法環境尋找,這就是還在記憶體中的person函式的詞法環境。JavaScript引擎找到了這個變數name然後列印到控制檯。

例子二

function getCounter() {
  let counter = 0;
  return function() {
    return counter++;
  }
}
let count = getCounter();
console.log(count());  // 0
console.log(count());  // 1
console.log(count());  // 2
複製程式碼

同樣地,getCounter函式的詞法環境是這樣的:

getCounterLexicalEnvironment = {
  environmentRecord: {
    counter: 0,
    <anonymous function> : < reference to function>
  }
  outer: <globalLexicalEnvironment>
}
複製程式碼

這個函式返回一個匿名函式並且把它分配到變數count。 當這個count函式執行,它的詞法環境看起來是這樣的:

countLexicalEnvironment = {
  environmentRecord: {
  
  }
  outer: <getCountLexicalEnvironment>
}
複製程式碼

當count函式被呼叫,Javascript引擎會嘗試在該函式詞法環境查詢變數counter。同樣地,因為它的環境資料是空的,所以引擎將到該函式外層詞法環境查詢。 因此,在第一次呼叫count函式之後getCounter函式的詞法環境是這樣的:

getCounterLexicalEnvironment = {
  environmentRecord: {
    counter: 1,
    <anonymous function> : < reference to function>
  }
  outer: <globalLexicalEnvironment>
}
複製程式碼

在每次呼叫count函式,Javascript引擎都會為count函式建立一個新的詞法環境,遞增count變數並且更新getCounter函式的詞法環境以表示做了變更。

結語

所以我們學習了什麼是閉包和閉包的原理。閉包是JavaScript的基本概念,每個JavaScript開發者都應該理解的。熟悉這些概念將有助於你成為一個更高效、更好的JavaScript開發者。 如果你覺得這文章對你有幫助,請點個贊! (完)

後記

以上譯文僅用於學習交流,水平有限,難免有錯誤之處,敬請指正。

原文

原文連結

相關文章