JavaScript中的作用域與閉包
JavaScript由於設計的原因和歷史遺留的問題,經常被開發人員所詬病。經過不斷的發展和優化,最新的ES6版本已經向主流程式語言靠齊。但還是有一些公司在面試中,喜歡考察變數提升的概念、變數先使用再宣告的輸出順序、閉包、還有老生常談的迴圈體內定時器問題。本文將總結你不知道的JavaScript中的關於作用域和閉包的章節,結合實際開發,解釋上述問題。
1、編譯執行
很多資料上寫道JavaScript是一門純解釋型語言,由直譯器解釋原始碼執行。實際上JavaScript是“編譯型”語言,參考MDN 上概念,JavaScript是一種具有函式優先的輕量級,解釋型或即時編譯型的程式語言。因此在執行之前,確有編譯的過程。理解這個概念,就很好理解變數提升的合理性。
2、作用域
2.1、什麼是作用域
作用域顧名思義,指的是一塊區域,這塊區域儲存著對應變數的集合,保證程式對變數的有序訪問。你肯定不希望將所有的變數都放在一個區域,這樣會帶來諸多的問題。就像班級裡的學生一樣,按照年級和教室進行劃分區域。可以大大減少重名的概率。 不同的作用域之間相互隔離的,用來保證資料的有序訪問。但不同的作用域也是可以串聯的,用來保證在找不到變數的時候通過作用域鏈訪問上層作用域變數。JavaScript就是這樣設計的。
2.2、作用域的型別
JavaScript中的有不同型別的作用域。按照語言的實現,作用域可以分為詞法作用域、和動態作用域。JavaScript中的實現使用詞法作用域。另外按照JavaScript的語法實現上,作用域還可區分為全域性作用域、函式作用域和塊作用域。
詞法作用域
詞法作用域顧名思義,就是定義在詞法階段的作用域,即寫程式碼時候寫在哪裡,變數就在當前所處的作用域中生效。
function fun1() { function fun2(a) { //pass } }
上述程式碼在全域性作用域中定義,顯然可以看到,全域性作用域中定義著函式fun1,在函式fun1作用域中定義著函式fun2,函式fun2中定義區域性變數a,是的,形式引數a也是定義在作用域中的。
動態作用域
與詞法作用域相對應的是動態作用域,使用是個案例便可理解這一概念。
function foo() { console.log(a); } function bar() {var a = 3; foo(); } var a = 2; bar()
執行bar()函式,控制檯列印的結果為2。執行bar函式 將會呼叫foo函式,foo函式執行並列印a變數。由於foo函式作用域中沒有定義a變數,根據作用域鏈查詢到全域性作用域中的a變數,列印2;這是執行的邏輯。也是詞法作用域的概念,即與定義位置有關,與呼叫位置無關。
2.3、函式作用域
函式作用域指的是這個函式內部定義的全部變數都可以在整個函式的範圍內使用,外部作用域無法訪問包裝函式內部的任何內容。即函式隱藏了內部實現,對外只暴露輸入(形參)和輸出(return)介面。函式作用域有一下幾種形勢:
宣告式:
var a = 2; function foo() { var a = 3; console.log(a); // 3 } foo(); console.log(a);
就像平時定義一個普通函式一樣。很明顯,foo函式內部作用域的變數a,與全域性作用域中的a變數,互不干擾。但這種寫法帶來的問題是foo函式會出現在全域性作用域中,並且需要呼叫這個函式foo(),才能執行其中的程式碼。而我們的初衷,只是想通過建立一個函式作用域,將內部變數包裹起來,使之與外部變數互不干擾。JavaScript提供了立即執行函式表示式(IIFE)。
立即執行函式表示式:(IIFE)
var a = 2; (function foo() { var a = 3; console.log(a); // 3 })() console.log(a);
將函式用()包裹起來,並直接使用()呼叫,就是立即執行函式表示式。另一種立即執行函式表示式形式:
var a = 2; (function foo() { var a = 3; console.log(a); // 3 }()) console.log(a);
兩種形式在功能上是一致的。另一種IIEF是將其作為函式呼叫,並傳入引數。
var a = 2; (function foo(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 }(window)) var a = 2; (function foo(global) { var a = 3; console.log(a); // 3 console.log(global.a); // 2 })(window)
還有一種形式,是倒置程式碼的執行順序,將需要執行的函式放在第二位。
var a = 2; (function foo(fun) { fun(window); })(function fun(global) { var a = 3; console.log(a); console.log(global.a) })
咋一看不好理解,但拆開看就一目瞭然。主要利用了JavaScript中函式是一等公民的實現,即JavaScript中的函式可以像變數一樣傳遞給函式引數。
//拆開後的形勢 var a = 2; function fun(global) { var a = 3; console.log(a); console.log(global.a) } (function foo(fun) { fun(window); })(fun)
2.4、塊級作用域
所謂的塊級作用域指的是使用 { } 包裹的程式碼塊,內部擁有獨立作用域。很顯然,我們使用的 if 語句和 for 迴圈,都是有 {} 的, 但ES6之前的版本無塊級作用域,也就是說ES6之前的版本,大括號內都沒有獨立作用域的問題,比如:
var a = 1; function f() { console.log(a); if (true) { var a = 2; } } f(); // undefined
按理來說,通過作用域鏈,可以訪問到全域性作用域中的a變數;但由於在 f 函式內部存在變數提升(後面會講到)又由於if語句沒有塊級作用域,內部宣告的變數a 遮蔽了全域性作用域中的a變數,因此是undefined。(將var 改成let 可以得到正確結果)
在for迴圈中沒有塊級作用域會帶來變數洩露的問題,比如:
for (var i = 1; i <= 1; i++) { console.log(i); } console.log(i); // 2
i 現在是全域性變量了。同理使用 let 可以得到預計的結果。(記得重新整理瀏覽器)
值得注意的是,使用let在一個已經存在的塊作用域上的行為是隱式的。簡單來說 let 會複用所在的程式碼塊的 {}。
var a = 1; function f() { console.log(a); if (true) { let a = 2; // 隱式 } } f(); var a = 1; function f() { console.log(a); if (true) { { let a = 2; // 顯式 } } } f();
3、變數提升
所謂的變數提升,指的是JavaScript程式碼在執行之前,會有一個預編譯的階段,在這一階段,會將使用 var 宣告的變數名、完整的函式宣告,提升到當前所在作用域的頂部。(let、const宣告的變數不會提升!)這也很好理解,就像在旅行之前,你肯定會檢查揹包裡面的物品,不至於在途中落了東西而終止旅行。觀察程式碼:
var a = 1;
簡單的宣告賦值語句,實際上變數的宣告式在編譯階段執行的,賦值只是執行階段執行的。讓我們來解決文章開頭提到的“變數先使用再宣告的輸出順序”問題:
var a; console.log(a); a = 2;
遇到這樣問題,只需要只要程式碼中的所有使用var的變數都會提升到當前作用域的頂部,這裡當然是全域性作用域,然後從上到下執行,這裡的a,顯然是undefined。無論題有多複雜,按照編譯器執行的思維去思考,答案是顯而易見的。函式也是一樣。
fun(); function fun() { console.log(a); // undefinded var a = 2; }
這裡的函式整體都會被提升,所以可以正常執行。函式內部作用域執行和在全域性作用域中的執行沒有什麼不同。注意:函式宣告可以提升,但函式表示式不存在提升。
提升優先順序
如果函式宣告和變數宣告式同名的,函式會首先被提升,變數提升在函式宣告提升之後。
foo(); function foo() { console.log(2); // 2 } var foo = 1;
由於函式的宣告會被提升到普通變數之前,上述的 var foo = 1; 無論在函式宣告位置之前 還是之後,都是重複的宣告。因此會被忽略。
相同的函式宣告會被覆蓋:
foo(); // 2 function foo() { console.log(1); } function foo() { console.log(2) }
後面的函式宣告覆蓋了前面的函式宣告。
實際上這些都是二流面試過程中會被問到的問題。在實際開發中,遵循變數先聲明後使用,規範命名,完全使用let取代var;就不會遇到這麼多費力燒腦的問題;實際上也是完全沒有必要的。生命很短,做一些有意義的事情,不必糾結語言過去的缺陷。
4、閉包
閉包,準確來說叫作用域閉包,之前的作用域寫了很多,顯然是為閉包作鋪墊的。
4.1、閉包的定義
當函式可以記住並訪問所在的詞法作用域, 即使函式是在當前詞法作用域之外執行, 這時就產生了閉包。--你不知道的JavaScript。
function foo() { var a = 2; function bar() { console.log(a) } return bar; } var baz = foo(); baz(); //2
顯然函式bar所在的詞法作用域位於foo函式的內部,但執行卻在全域性作用域中。這樣便形成了一個閉包。這是書中的定義,如何用通俗的語言去解釋閉包這一概念?
1、在JavaScript中,函式是一等公民,意味著函式可以像變數一樣作為函式的引數傳入,也可以像變數一樣作為函式的return 內容。
2、函式的執行不僅僅需要函式的定義,還需對應的執行環境。觀察bar函式,不僅處於foo的詞法環境中,內部還通過作用域鏈訪問foo函式的內部變數。
3、JavaScript執行後自動執行垃圾回收,釋放記憶體。
當函式被return出去的時候,意味著可能需要在外部的詞法作用域中執行。拿bar函式來說,被return出去之後,baz獲取到了其引用,並在全域性作用域中執行。但對於垃圾回收程式來說,foo函式已經執行完成了,需要將內部的變數全部清理掉。問題來了,如果內部的變數被清理了,bar函式執行以依賴的a變數不存在,就會報錯。函式return出去不能執行有什麼用。顯然在這種情況下,foo函式的詞法環境不會被完全清理,至少bar函式依賴的詞法環境不會被清理。也就是,雖然只有函式被return,但函式依賴的執行環境也需要儲存在記憶體中,以便隨時呼叫,這樣就形成了一個閉包。所以閉包就是由一個待執行的函式和所依賴詞法作用域構成的。
4.2、閉包的其他形式
除了通過return一個函式實現閉包以外,閉包還有其他形式。
定時器:
function wait(mes) { setTimeout(() => { console.log(mes) }, 1000) } wait(1)
這個定時器程式碼顯然也是一個閉包。套用定義來說,函式的定義詞法作用域與執行的詞法作用域顯然是不同的,這裡產生了一個閉包。通俗點來說,wait函式執行完成後,應該啟動垃圾回收程式。但內部的定時器函式在將來會被執行,所依賴的詞法作用域不可以被回收,變數應存在記憶體中,以便將來呼叫,這邊是一個閉包。除此之外,事件監聽器、Ajax通訊中,只要使用了回撥函式,實際上都是使用閉包。
4.3、迴圈定時器問題
迴圈,每秒列印數字:
for (var i = 1; i <= 5; i++) { setTimeout(function timer() { console.log(i) }, i * 1000) }
for迴圈結束後, timer函式所依賴的詞法環境不會被釋放,並且timer函式會在將來的時刻執行,這裡顯然也是一個閉包。不同的是,我們定義了5次timer函式,每個timer函式會引用變數i。(重複函式宣告不會被覆蓋,而是被setTimeout函式推入非同步任務佇列)。這裡的問題是非同步任務執行時,所引用到的變數都是i,而此時的i變數已經是迴圈結束後的值6。因此會列印5次6。為了達到預期的效果,需要通過閉包,讓回撥函式的執行都有獨立的詞法作用域。
for (var i = 1; i <= 6; i++) { (function(j) { setTimeout(function timer() { console.log(j) }, j * 1000) })(i) }
通過立即執行表示式形成一個獨立的函式作用域,每個獨立的函式作用域中有待執行的函式和函式依賴執行的詞法環境。應不會相互干擾。或者通過let解決。
for (let i = 1; i <= 5; i++) { setTimeout(function timer() { console.log(i) }, i * 1000) }
顯然let幫助我們建立了獨立的作用域,並且每次自動將變數i繫結到當前作用域中。