手寫面試題五:閉包和詞法環境

語言: CN / TW / HK

轉載請註明原文連結。原文連結

手寫面試題系列是我為了準備當下和以後的面試而編寫的文章系列,當然對於前端小夥伴也有幫助。我建議讀完之後,自己動手敲程式碼或者手寫一遍才能更好地掌握。

參考文獻: 1. 閉包 - MDN 2. Lexical Environment — The hidden part to understand Closures 3. 【譯】 理解 JavaScript 中的執行上下文和執行棧

在上一篇文章 手寫面試題四:執行上下文、執行棧和詞法環境的末尾,我解釋了詞法環境的概念,也提及的閉包,但是沒有展開去講清楚。在這篇文章,我就來帶領窺探一下閉包的奧祕。

一、什麼是閉包?

先看一下MDN的官方解釋:

一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure)。也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域。在 JavaScript 中,每當建立一個函式,閉包就會在函式建立的同時被創建出來。

1. 個人解讀

首先,我來分析一下官方解釋的含義。從第一句話開始:一個函式和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函式被引用包圍),這樣的組合就是閉包(closure)。

把第一句話再簡化一點來說就是:一個函式和它的詞法環境的引用組合起來就是閉包。即:函式fn1的閉包 = fn1 + fn1的詞法環境的引用。我們知道,函式一旦被定義,其實它的詞法環境就已經確定了,所以一個函式必然是存在閉包的

再看下一句話:也就是說,閉包讓你可以在一個內層函式中訪問到其外層函式的作用域。

我開始接觸閉包這個概念時,就認為一個內層函式中訪問到其外層函式的作用域的情況才算是閉包。但是這句話的意思是,閉包具有一個特性:閉包可以讓你可以在一個內層函式中訪問到其外層函式的作用域

再看最後一句話:在 JavaScript 中,每當建立一個函式,閉包就會在函式建立的同時被創建出來。

最後一句話,佐證了我對第一句話的解釋,也就是說:只要函式建立就會產生閉包

2. 結論

上面大段的解釋,只是為了向讀者說明閉包到底是什麼。因為我以前對閉包的認知也是錯誤的,為了避免讀者和我以前有一樣認知,所以花些篇幅去解釋。

下面,給出閉包官方含義的結論:

  1. 閉包 = 函式 + 函式詞法環境的引用;
  2. 閉包具有一個特性:可以在一個內層函式中訪問到其外層函式的作用域;
  3. 閉包在函式建立的時候建立,意味著只要函式被定義,就一定產生閉包,與它是否訪問外層函式的作用域沒關係;

二、函式的詞法環境與閉包

閉包的特性就是可以在內層函式訪問外層函式的作用域。要想弄清楚閉包為什麼有這個特性就不得不提及詞法環境這個概念。

詞法環境由2個部分組成,一是環境記錄器,二是外部環境的引用

環境記錄器是儲存變數和函式宣告的實際位置,更具體點就是儲存了函式的變數、函式和引數列表(arguments物件,可以把函式引數理解為函式內部宣告的變數,給函式引數傳值就是給引數賦值,但函式引數的作用域仍屬於函式內部); 外部環境的引用意味著它可以訪問其父級詞法環境(作用域);

通俗點解釋就是,在詞法環境中,環境記錄器記錄儲存了執行上下文中的變數和函式的實際值,外部環境的引用使得該執行上下文可以沿著作用域鏈訪問父級的作用域。

用虛擬碼來表示就是: LexicalEnvironment: { // 環境記錄器:記錄函式內部定義的變數(let、const關鍵字宣告的)、函式和引數列表 EnvironmentRecord: { Type: "Object", // 在這裡繫結識別符號 } // 對外部環境的引用 outer: <null> }

由於函式的詞法環境儲存著對外部環境的引用,所以使得函式可以訪問函式外層作用域中的變數和函式。又由於函式的詞法環境是在函式定義的時候就確定了,所以函式可以訪問的外部環境的層級在函式定義的時候就已經確定了,可以理解為:函式的能訪問外部環境的層級在定義時已經確定,不會因為函式的呼叫方式而發生變化

這句話很繞,而且很容易與函式this的指向取決於函式的呼叫方式產生混淆,讀者需要花費一定的時間去理解和消化。

舉個簡單的例子說明:

``` const num = 10; function fn1() { console.log(num); }

function fn2() { const num = 20; fn1(); }

fn2(); // 輸出 10 ```

例子中,儘管函式fn1()在函式fn2()內部被呼叫,但是輸出的num仍舊是全域性環境中的num的值;改變fn1()呼叫的方式並不會改變fn1()可以訪問的上層作用域的層級,因為這些事在函式定義的時候就確定了的。

三、閉包的常見使用示例

閉包的特性使得函式可以訪問到外層作用域。如果一個內層函式訪問外層函式中的變數,外層函式已經執行完畢,但是內層函式還沒執行完畢時,內層函式想要訪問外層函式中的變數,變數會直到內層函式執行完時它的儲存空間才會被收回。

1. 在定時器中

(function autorun() { const num = 100 setTimeout(function log() { console.log(num) }, 1000) console.log('autorun 執行完畢') })() 輸出結果為: autorun 執行完畢 1000

在autorun執行完畢1秒後,內部函式log會輸出num的值100。也就是說,在函式autorun()執行完畢後,函式log()仍然可以訪問到外層函式autorun()中的變數num。

2. 在事件處理中

(function autorun(){ const num = 100; $("#btn").on("click", function log(){ console.log(num); }); console.log('autorun 執行完畢') })();

同樣的輸出結果。因為點選事件也是延遲出發的,所以在autorun()執行完畢後才會呼叫函式log()。

在上面的2個例子中,我們可以看到,log() 函式在外層函式autorun()執行完畢後仍舊可以訪問內層函式中的變數。

如果內層函式訪問了外層函式中的變數,那麼變數的生命週期取決於內層的生命週期。被內層函式引用的外部作用域中的變數將一直存活直到閉包函式被銷燬。如果一個變數被多個內層函式所引用,那麼直到所有的內層函式被垃圾回收後,該變數才會被銷燬。

3. 閉包與迴圈

內層函式訪問外層函式中的變數時,訪問的事外層函式中變數的引用,而不會拷貝外層函式中變數的值。如在迴圈中使用:

(function initEvents(){ for(var i=1; i<=10; i++){ setTimeout(() => { (function showNumber(){ console.log(i) })() }, 100); } })()

函式showNumber()會在for迴圈執行完畢後再執行,所以i會最後再自增一次,i為11, 由於i通過關鍵字var定義, 它會被變數提升至全域性作用域,所以showNumber取到的自始至終是同一個i的引用,所以結果不變,再加上迴圈會在 i = 10之後再執行一次i++,所以10次輸出結果相同,都是11。

在這裡,如果把var換成let,那麼每一個i都會被單獨定義一次,這時,每個showNumber引用的i都不同,所以輸出的結果會是1-10。

4. 閉包的效能考量

如果不是某些特定任務需要使用閉包,在其它函式中建立函式是不明智的,因為閉包在處理速度和記憶體消耗方面對指令碼效能具有負面影響。 由於閉包的特性,可以訪問到外層函式,但是這種操作需要上溯作用域鏈,造成不必要的消耗。

另外由於內層函式訪問外層函式中的變數會導致外層函式中的這些變數在外層函式沒有執行完畢時,變數不會銷燬,使得儲存空間一直得不到回收,造成效能問題。

所以如無必要,不應該大量的使用閉包。