1. 程式人生 > 實用技巧 >從一道經典的setTimeout面試題談作用域閉包

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

前言

話不多說,先放題:

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

上面這段程式碼相信各位一定不陌生。每個準備過前端面試的同學一定看到過這道題目,並且我猜大家一定能在3s內脫口而出:不會按照預期輸出1、2、3、4、5,而會輸出6、6、6、6、6,要想實現計時器效果,需要把var改成let,變成塊級作用域。完畢。

然後,當面試官問:如果不使用let還能有什麼方法實現預期效果呢?

這時,相信大家也一定會毫無猶豫地說出“閉包”二字,然後信心滿滿地將修改後地答案遞給面試官。

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

可是,隨著對JavaScript學習的深入,這背後的道理似乎並沒有那麼簡單。下面就以這道題為例,展開談一談JavaScript中的作用域閉包。

到底什麼是閉包?

閉包是什麼?能夠訪問其他函式作用域中的變數的函式?函式中的函式?

在回答這個問題之前,我們先來看一下 詞法作用域

作用域說白了就是一套規則,用於確定在何處以及如何查詢變數(識別符號),而詞法作用域就是定義在詞法階段的作用域。也就是說,詞法作用域意味著作用域是由你書寫程式碼時函式宣告的位置來決定的。

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

當函式可以記住並訪問所在的詞法作用域時,哪怕函式是在當前詞法作用域之外執行,就產生了閉包。

舉個例子:

 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(上卷)》第一部分 作用域和閉包