在講完了作用域,this,IIFE 後,我們可以來嘗試講講閉包這個神奇的東西。
你可能不知道閉包這個詞,但也許你的程式碼早已有許多地方有使用到閉包。
如果要一句話定義閉包,那可以這樣說:閉包就是讓函式可以記住並存取它的詞法作用域(Lexical Scope)中的變數。
前文雖然有提到詞法作用域,但僅是帶過,作用域那篇也沒講到,所以還是在這邊補充一下。
JS 語言編譯規則上,主要有三步:語法分析,編譯,執行。
第一步會先對語法進行分析,又稱分詞,把語句拆開成對語言來說有意義的片段,如:
let a = 1;
可能的分詞法如:
let - a - = - 1 - ;
接著做解析(Parsing),來構造抽象語法樹(Abstract Syntax Tree,AST),如果對詳細內容有興趣可以點連結參考維基,這邊不再深入。
簡單的說是顆由語法構造成的樹,
到此算第一步的詞法分析結束,也是我們主要關注的地方。後面兩步主要就依賴這顆樹來進行編譯,生成機器碼、進行優化。
我們可以這樣理解詞法作用域:
詞法作用域是指在程式碼撰寫時,變數宣告的位置決定的作用域。
舉個例子:
a = 'global a';
function foo(){
console.log(a);
}
function bar(){
let a = 'inside bar';
foo();
}
bar();
這裡面的 foo()
在執行時會回傳什麼?會回傳 'global a'
嗎?還是看似調用時同作用域的 'inside bar'
?
答案是 'global a'
,這就是所謂的詞法作用域:變數宣告的位置就已經決定他的作用域了,在 foo()
被宣告的時候,a
往外查找的對象是全域的 a
,而不是 bar
裡面的 a
(宣告的當下碰不到)。
這個依據語言而定,JS 是靜態作用域(Static Scope),而有些語言是動態作用域(Dynamic Scope),指的是編譯時才決定,也就是同個例子,在動態作用域的語言裡會回傳 'inside bar'
。
靜態作用域在大多數情況下等同詞法作用域的意義,相信大家對這個詞這樣會更有印象也更具體。
雖然有些方法能讓詞法作用域誤判,如 eval
,with
,但最佳實踐中,基本認為詞法作用域就依編寫程式時的靜態作用域來判斷。使用這邊提到的欺騙詞法作用域的行為,不僅容易讓人混淆,也會導致編譯中的優化失效,請不要使用這種寫法。
「閉包就是讓函式可以記住並存取它的詞法作用域(Lexical Scope)中的變數。 」
再貼一次上面的這句話,這也是為什麼我們要先介紹詞法作用域,因為閉包是基於詞法作用域的特性的機制。
讓我們來看一下閉包如何作用:
function foo(){
let val = 0;
function bar(){
val++;
console.log(val);
}
return bar;
}
const bar = foo();
bar();//1
bar();//2
bar();//3
原本照理來說,val
的作用域僅限於 foo
裡面,函式結束,該作用域就應該也跟著結束了。下次我們執行的時候理論上又是從 0 開始。
但因為我們回傳了 bar
,在詞法作用域的時, bar
內部使用的 val
是 foo
裡面的 val
,可以認為只要 bar
還存在, foo
裡面的 val
就必須要跟著一起存在。bar
的引用被傳出且記下來了,導致了 foo
裡的 val
保留且不會被回收。讓我們執行三次後分別得到 1,2,3
的結果,最後的 val
結果就會以 3
繼續被存下來。
這樣的行為帶來了什麼好處?
我們僅透過回傳的函式來操作內部的變數,而沒有任何其他方法來接觸他,把變數保護了起來。
假想一個實作,上面的計數行為,如果沒有用閉包,一般我們可以直接操作該變數,可能會導致計數器在意料外的地方被修改,但有了閉包,我們能保證計數器本身只被回傳的相關函式操作。
不使用閉包:
let val = 0;
function getVal(){console.log(val);}
function plusOne(){val++};
function minusOne(){val--};
console.log(getVal());//0
console.log(val += 1000);//unexpected place
如這段程式碼,我們可以在作用域內做任何想做的改動,而非僅由固定方法規範的程度。
使用閉包:
function counter(){
let val = 0;
function getVal(){console.log(val);}
function plusOne(){val++};
function minusOne(){val--};
return {getVal, plusOne, minusOne};
}
const c = counter();
c.getVal();//0
c.plusOne();
c.getVal();//1
c.minusOne();
c.getVal();//0
val
被封閉在 counter
函式的範圍內,外部呼叫 counter
函式時留存的方法指向保留了 val
,但也讓 val
在使用閉包的情況下,僅有回傳的這些方法能對 val
操作,預防了意料外的修改。
實際上,有個廣泛被大家認知的例子,我相信也是許多面試題曾經出過的題目。
for(var i = 0; i < 5; i++){
setTimeout(function(){console.log(i)},i*100);
}
這樣會印出什麼?
答案是 5 個 5,意外嗎?
我們來看看這裡的行為,可以想定的是在 setTimeout
中定義的 console.log
方法實際發生的時候,迴圈早已經跑完,而迴圈的終止條件為 i < 5
,也就是最後 i == 5
時迴圈會脫離。
i
以 var
宣告,所以他的作用域是函式作用域,很明顯,這個例子中是一個全域作用域下,且在迴圈中每個迭代被執行的,都是同一個 i
的變數實體。
以詞法作用域來看,console.log
毫無疑問綁定到了這個全域下的 i
,而 i
最後變成 5,所以印出來的全部都是 5。
如果我們這邊把 var
換成 let
會發生什麼事?
for(let i = 0; i < 5; i++){
setTimeout(function(){console.log(i)},i*100);
}
這個例子,我們會印出 0,1,2,3,4
。
為什麼換成 let
後行為就不一樣了?因為 let
是塊級作用域,而不是函式作用域。
迴圈中的每個迭代,都可以視為一個獨立的塊級作用域。
//實際上跑起來像這樣
{
let i = 0
setTimeout(function(){console.log(i)},i*100);
}
{
let i = 1
setTimeout(function(){console.log(i)},i*100);
}
...
依序執行了五次,每次迭代的都是不同的 i
變數實體,在這個例子中,總共存有 5 個 i
的變數實體,這也就是為什麼換成 let
就可以印出不同的內容。
實際上做的事情是把每次迭代結束的末值,用於下次新的迭代開頭裡的初始化值。
那難道 var
就不行嗎?不,這時候就是閉包的回合了。
for(var i = 0; i < 5; i++){
setTimeout((function(a){console.log(a)})(i),i*100);
}
看看這個例子,我們使用 IIFE 把 console.log
要印出的對象改為了 a
,而 a
是一個被傳進去變數 -- 傳入 i
。在上面講的閉包概念中,因為這個 IIFE 的引用還在,所以當下的 i
必須被保留,所以成功的讓要印出的時候能夠參照到執行當下的 i
值,這個例子,也能夠印出 0,1,2,3,4
。
在清楚這個原理之後,要使用 IIFE 傳入參數做出閉包,或是用 let
來善加利用塊級作用域的特性,都端看情況而定,你可以拿出最適合的工具,因為你現在擁有也理解他們了。