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綁定到當前作用域中。