從一道經典的setTimeout面試題談做用域閉包

前言

話很少說,先放題:前端

1 for(var i = 1; i <= 5; i++) {
2   setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);        
5 }

上面這段代碼相信各位必定不陌生。每一個準備過前端面試的同窗必定看到過這道題目,而且我猜你們必定能在3s內脫口而出:不會按照預期輸出一、二、三、四、5,而會輸出六、六、六、六、6,要想實現計時器效果,須要把var改爲let,變成塊級做用域。完畢。面試

而後,當面試官問:若是不使用let還能有什麼方法實現預期效果呢?閉包

這時,相信你們也必定會毫無猶豫地說出「閉包」二字,而後信心滿滿地將修改後地答案遞給面試官。函數

嗯,看起來很簡單,這有什麼難的?!學習

但是,隨着對JavaScript學習的深刻,這背後的道理彷佛並無那麼簡單。下面就以這道題爲例,展開談一談JavaScript中的做用域閉包。spa

 

到底什麼是閉包?

閉包是什麼?可以訪問其餘函數做用域中的變量的函數?函數中的函數?code

在回答這個問題以前,咱們先來看一下 詞法做用域 對象

做用域說白了就是一套規則,用於肯定在何處以及如何查找變量(標識符),而詞法做用域就是定義在詞法階段的做用域。也就是說,詞法做用域意味着做用域是由你書寫代碼時函數聲明的位置來決定的。blog

那麼,回答什麼是閉包的問題:ip

當函數能夠記住並訪問所在的詞法做用域時,哪怕函數是在當前詞法做用域以外執行,就產生了閉包。

舉個例子:

 1 function foo() {
 2   var a = 2;
 3   function bar() {
 4         console.log(a);
 5   }
 6   return bar;  
 7 }
 8 
 9 var baz = foo();
10 
11 baz();

基於詞法做用域的查找規則,函數bar()能夠訪問foo()的內部做用域。而後咱們將bar()函數自己看成一個值類型進行傳遞,即把bar所引用的函數對象自己看成foo()的返回值。

第9行,在foo()執行後,其返回值賦值給變量baz,而後在第11行調用baz()。這裏實質上只是經過不一樣的標識符引用調用了foo()內部的函數bar()。

bar()顯然能夠被正常執行,控制檯輸出2。這剛好應證了上面的定義:bar()在本身定義的詞法做用域之外的地方(此處是在全局做用域中)執行了。

正常來講,若是內部沒有bar(),那麼在foo()執行完以後,其內部做用域會被銷燬,佔用的內存空間會被垃圾回收器回收。然而,根據前面的分析,咱們知道,bar()擁有涵蓋foo()內部做用域的閉包。也就是說,foo()的內部做用域因爲被bar()使用所以不會被垃圾回收器回收,它依然存在在內存中,以供bar()在以後任什麼時候間進行引用。

bar()依然持有對該做用域的引用,而這個引用就叫作 閉包 。 

所以,當不久以後變量baz被實際調用(調用內部函數bar())時,能夠正常訪問定義時的詞法做用域,便可以正常訪問變量a。

這就是閉包的神奇之處:閉包使得函數能夠繼續訪問定義時的詞法做用域 。而且,不管經過何種手段將內部函數傳遞到所在的詞法做用域之外,它都會持有對原始定義做用域的引用,不管在何處執行這個函數都會使用閉包。

 

理解setTimeout和閉包

搞清楚閉包是什麼以後,對於setTimeout()函數的第一個參數func,咱們就很好理解了。

1 function wait(message) {
2     setTimeout(function timer() {
3       console.log(message);
4     }, 1000);
5 }
6 
7 wait("This is closure!");

做爲wait()的內部函數,timer()具備涵蓋wait()做用域的閉包,所以在第7行的wait()執行1000ms後,timer函數依然保有wait()做用域的閉包,即保有對變量message的引用。這也就是在前面說的「不管在何處執行這個函數都會使用閉包」。

詞法做用域在引擎調用setTimeout()的過程當中保持完整。

 

循環和閉包

下面咱們回到前言中那道經典的面試題:

1 for(var i = 1; i <= 5; i++) {
2   setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);        
5 }

咱們要弄懂一個關鍵點,那就是當定時器運行時,不管每一個迭代中設置的延遲時間是多長(即便是setTimeout(func, 0)),全部的回調函數都是在循環結束後纔會被執行。這道題目中,for循環終止的條件是 i = 6。所以輸出顯示的是循環結束時i的最終值,也就是咱們看到的66666!

那麼究竟是什麼缺陷致使了這段代碼的行爲同語義所暗示的不一致呢?

缺陷是咱們想固然地覺得循環中的每一個迭代在運行時都會給本身「捕獲」一個i的副本。但根據做用域的工做原理,實際上儘管循環中的每一個函數是在各個迭代中分別定義的,可是它們都被封閉在一個共享的全局做用域中,所以實際上只有一個i(全部函數共享一個i的引用)。這段循環代碼和重複定義五次延遲函數的回調是徹底等價的。

怎麼解決這個缺陷呢?很明顯,咱們須要爲每一個timer()建立屬於它們本身的閉包做用域。也就是說在循環的過程當中每一個迭代都須要一個閉包做用域。

IIFE (Immediately Invoked Function Expression) 會經過聲明並當即執行一個函數來建立做用域,那這樣改造呢?

1 for(var i = 1; i <= 5; i++) {
2     (function () {
3         setTimeout(function timer() {
4             console.log(i);
5         }, i * 1000);  
6     })();
7 }    

上面的改法確實能夠擁有更多的詞法做用域了 —— 每一個延遲函數都會將IIFE在每次迭代中建立的做用域封閉起來。不過,這些做用域都是空的,並不能產生什麼實際效果。

咱們須要讓這些空的封閉的做用域包含一些實質性的東西。好比每次迭代建立閉包的時候,用一個臨時變量 j 來儲存循環中的 i 的值:

1 for(var i = 1; i <= 5; i++) {
2     (function () {
3         var j = i;
4         setTimeout(function timer() {
5             console.log(j);
6         }, j * 1000);  
7     })();
8 }  

就像下圖這樣,如今改造後的代碼能夠如期輸出12345了!

咱們在 IIFE 中 var 出來的 j 變量實際上只是起到了「傳值」的做用,徹底能夠用參數來替代,不必單獨抽出來。因此,咱們把 j 放到參數列表裏,稍微改造後的簡潔寫法是:

1 for(var i = 1; i <= 5; i++) {
2     (function (j) {
3         setTimeout(function timer() {
4             console.log(j);
5         }, j * 1000);  
6     })(i);
7 } 

這裏,j 的命名並不重要,由於它只是一個函數的參數,取名叫 i 也能夠。

總結一下,在 for 循環內使用 IIFE 會爲每次迭代都生成一個新的做用域,使得延遲函數的回調能夠將新的做用域封閉在每一個迭代內部,這樣每一個迭代中都會含有一個具備正確值的變量供咱們訪問了。

 

let塊做用域

ES6以前,要想在JavaScript中使用塊做用域,基本上都是經過 IIFE 來操做的。ES6中新增了一種變量聲明方式:let,它能夠用來劫持塊級做用域,而且在這個塊做用域中聲明一個變量。本質上這是將一個塊轉換成一個能夠被關閉的做用域。

1 for(var i = 1; i <= 5; i++) {
2     let j = i;
3     setTimeout(function timer() {
4         console.log(j);
5     }, j * 1000);  
6 } 

使用let以後,咱們就不須要用 IIFE 來包裹 setTimeout() 了!

不過這彷佛仍是不夠簡潔...... 實際上,for循環頭部的 let 聲明還會有一個特殊的行爲。這個行爲指出變量在循環過程當中不止被聲明一次,每次迭代都會聲明。隨後的每一個迭代都會使用上一個迭代結束時的值來初始化這個變量。

1 for(let i = 1; i <= 5; i++) {
2     setTimeout(function timer() {
3         console.log(i);
4     }, i * 1000);  
5 } 

下面的圖示能夠用來理解循環中的 let:

let i0 = 1;
setTimeout(function timer() {
    console.log(i);
}, i * 1000);  
//1000ms後輸出1
--------------------------------- let i1 = i0 + 1 = 2; setTimeout(function timer() { console.log(i); }, i * 1000);
//2000ms後輸出2
--------------------------------- let i2 = i1 + 1 = 3; setTimeout(function timer() { console.log(i); }, i * 1000);
//3000ms後輸出3
--------------------------------- let i3 = i3 + 1 = 4; setTimeout(function timer() { console.log(i); }, i * 1000);
//4000ms後輸出4
--------------------------------- let i4 = i3 + 1 = 5; setTimeout(function timer() { console.log(i); }, i * 1000);
//5000ms後輸出5
--------------------------------- let i5 = i4 + 1 = 6 > 5; //退出循環

好了,這就是終極版本了!最簡單的代碼,實現語義和預期相一致的輸出。

 

 

參考:

《你不知道的JavaScript(上卷)》第一部分 做用域和閉包

相關文章
相關標籤/搜索