前言
話不多說,先放題:
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(上卷)》第一部分 作用域和閉包
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/590.html
標籤:其他
上一篇:從業以來最差的一次前端面試體驗
