原來JS是這樣的 - 提升, 作用域 與 閉包
引子
長久以來一直都沒有專門學過 JS ,因為之前有自己啃過 C++ ,又打過一段時間的算法競賽(寫得一手好意大利面條),于是自己折騰自己的網站的時候,一直都把 JS 當 C 寫。但寫的時候總會遇到一些奇怪的問題,于是打算花點時間看了看《你不知道的JavaScript》。寫這篇文章以記錄一下一段時間的學習內容,也治療一下我不愛做筆記和總結的毛病。如果你也是一直按著別的語言的編程習慣來寫 JS 而沒有專門去了解過它,不妨一起來了解一下 JS 的一些獨特之處。
首先來看一段代碼:
console.log("Firstly, i = " + i);
// console.log("BTW, a = " + a);
i = 61;
console.log("Then there it got a value, i = " + i);
for(var i = 1; i <= 5; i++) {
console.log("In for loop, i = " + i);
}
console.log("At the end, i = " + i);
你可能注意到,這段代碼一開始就要輸出 i 的值,而在輸出之前我們似乎并沒有寫任何聲明和定義 i 值的語句,而再之后,我們給 i 賦了一個值,但我們依然沒有用 var 之類的關鍵字來做變量聲明的工作。在for循環,我們終于聲明了 i ,但 for 循環之后,我們依然在試圖使用 i 。這些代碼看上去都很荒唐,或許你可能認為這段代碼在第一行的時候就會報 ReferenceError 以提示我們并沒有定義變量 i 并停止執行。但實際真的是這樣嗎?
讓我們看一下這段代碼的執行結果吧:
Firstly, i = undefined
Then there it got a value, i = 61
In for loop, i = 1
In for loop, i = 2
In for loop, i = 3
In for loop, i = 4
In for loop, i = 5
At the end, i = 6
這段代碼其實非常的譚浩強,但卻說明了一個比較明顯的 JS 的不同之處,那就是 提升 和 作用域 規則。
提升(Hoisting)
或許由于之前的編程語言中所得到的經驗,我們可能會認為,在聲明語句之后我們才可以使用我們剛剛聲明過的變量,我們看這段代碼:
a = 61;
console.log(a); // 輸出 61
var a;
你可能認為第一條語句是非法的,但實際上它正常的執行了,但分明我們是在下面才聲明了 a ,這就是 提升 的含義了。
實際上,在 JavaScript 解釋一個作用域內的代碼時,會把變量和函數的聲明在這塊作用域中的任何代碼執行之前進行處理。這就像是把函數和變量的聲明拿到了這個作用域的最上面了一樣。這個過程就叫做 提升 。
于是我們再來看下一段代碼:
console.log(a); // undefined
var a = 61;
停!等等!不是會提升么,不應該是 log 一個 61 出來么?但實際上答案就是未定義。實際上,我們可以理解為,編譯器在分析這段代碼時,這段代碼的第二行會被編譯器解析成兩部分, var a 和 a = 61 。就像剛剛所提到的,聲明的確是要被提升的,于是 var a 就被“拿到最上面”去了,而 a = 61 則留在原地,所以,這段代碼實際會輸出一個 undefined ,而不是在我們還不知道 提升 這種說法時可能猜測的結果 ReferenceError ,以及以為會把賦值也提升上去得到的 61 。
上面提到了,函數的聲明也會提升,如果你之前曾經在你定義一個 function 之前就嘗試使用這個 function 但沒有出錯的原因了,這也是為何你可以把外部 js 代碼在頁面最底部引入你也依然能夠使用那些代碼的原因。
當然,也有一些需要注意的地方,函數表達式的提升規則比較奇怪,比如下面這段代碼。
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
}</code></pre>
它大致上會被這樣解釋:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}</code></pre>
作用域(Scope)
當你知道了提升的概念,反過來看最上面的示例代碼,可能依然會覺得不正常——我們分明是在for循環這個代碼塊里才聲明了變量,為什么在外面也能用它?剛剛不是說,代碼只被提升到一個作用域之內的最上面嗎?于是我們來看下面的這段代碼:
//console.log(bar);
function foo() {
console.log(bar);
if(true) {
var bar = 61;
}
console.log(bar);
}
foo();
最直觀的印象里,這段代碼在函數 foo 內的一個條件語句成立的條件下會聲明變量 bar 并賦值 61,而實際上我們會發現,除了函數外我們注釋掉的那個語句之外,我們都可以訪問到 bar 。
剛剛不是說,提升僅限所在的作用域嗎?對,的確如此,但實際上,JavaScript的作用域本身并不處理這樣的,由 if, for 等后面的花括號構成的塊作用域。因此,此處聲明的 bar 實際所在的作用域是函數 foo 之內,而不是由 if 構成的塊級作用域。不過例外的,需要注意的是, with 和 try/catch 是可以創建自己單獨的作用域的。
當然,實際在 ES6 引入的新關鍵字 let 解決了這個問題,使用 let 聲明的變量就只存在于塊級作用域內了,這解決了 var 導致的名稱污染問題。
那么我們回到最開始的例子,我們看上去是在for循環中才聲明的變量實際被提升到了for循環之外的作用域,于是剩下的內容就沒有什么說不通的問題了。額外的一點是,對于已經聲明過的變量,再次發現聲明同名變量的行為會被忽略。
閉包(Closure)
跟據剛剛講的內容,看下面這段代碼
function foo() {
var t = 61;
function bar() {
console.log(t);
t++;
}
return bar;
}
var baz = foo();
baz(); // 61
baz(); // 62
顯然,我們在 foo 內聲明的變量 t 所在的作用域就是 foo 函數本身,我們不能在外部訪問 foo ,而實際上我們可能總是需要訪問封閉在 foo 作用域內的變量 t ,于是,為了能夠訪問這個變量,我們使 foo 返回了 bar 用以訪問 t 變量,并用 baz 來保存了對 bar() 的引用。于是當我們執行 baz() 的時候,會看到輸出了 t 的值,并且 t 的值會加一。
事實上,我們通過 baz 引用 bar 以防止 bar 所處的作用域被引擎回收,于是我們保住了這個作用域里的變量,以便以后再次使用,并且我們還可以在外部訪問它(這種需求就像面向對象語言中一個類對象中的私有成員一樣)。而我們做的這種事情,實際就叫做閉包。
為了不搞混,還是重新說一下閉包的概念:當函數可以記住并訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行。
我們來看下面一段代碼
var a = 61;
(function IIFE(){
console.log(a);
})();
如上是一個立即執行函數(IIFE),而這是一個閉包嗎?答案是:并不是。因為函數本身并不是在它之外的詞法作用域所執行的,其中使用的變量 a 也并不是函數 IIFE 所封閉的變量。所以,這不是一個閉包。
再考慮下面一段代碼
for(var i = 1; i <= 5; i++) {
(function IIFE(){
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})();
}
這段代碼我們把直接執行函數塞到了for循環里,IIFE里的內容則是延遲i秒后輸出i的值。看上去應該輸出的是1到5,一秒一個,而實際上輸出的則是66666(一秒一個6)。
其實和上一段代碼一樣,這個立即執行函數和這段代碼中的并沒有什么異樣,使用的i依然是外部作用域的i(而不是IIFE構成的作用域內的自有變量)。于是,因為函數被延遲執行,執行的時候for循環已經循環完了,自然輸出了66666。而如果想要達到本身的目的,只需要這樣修改:
for(var i = 1; i <= 5; i++) {
(function IIFE(j){
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
這看上去是一個很蛋疼的把戲,但我們通過參數傳入的 i 在 IIFE 內成了隱式聲明的變量 j ,而j的作用范圍是 IIFE 所構成的語法作用域內,自然不會有問題。
最后我們簡單提及一下模塊機制。回到閉包段落的第一個例子,我們可以看到我們以通過返回一個可以訪問閉包內部變量的函數來達到訪問閉包內部的變量的目的(聽上去好像是廢話),而當我們在編寫一個模塊時,我們通常需要通過這種行為去模擬一個類,這種行為的實現方式很多,比如這樣:
function moduleFoo() { // ps: 以函數表達式的方式聲明該函數,就可以達到單例的效果
var privateVar = "CarraIsMine";
var yetAnotherPrivateVar = "TejiLang";
function doSomething() {
console.log(privateVar);
}
function doSomethingTeji() {
console.log(yetAnotherPrivateVar);
}
return {
doSomething: doSomething,
doSomethingTeji: doSomethingTeji
};
}
var bar = moduleFoo();
bar.doSomething(); // 嘿!
bar.doSomethingTeji(); // 蛤!</code></pre>
我們依然通過返回東西的形式以便訪問閉包內的變量(實際是做一些想要的事),只不過我們這回不止返回了一個函數的引用,而是返回了一大坨。
關于更多模塊機制的實現方式,其實可以展開成單獨的文章來說了,這里就不再闡述,而需要額外提到的是,ES6引入了 import 關鍵字可以將一個單獨的文件視為一個模塊來引入和使用。當然,這就不在剛剛所討論的閉包的范圍內了。
最后
以上講述的內容就是關于 JS 的 提升,作用域以及閉包的相關簡單解釋。如果你對這些內容仍然感興趣,不妨去讀一讀《You don't know JS - Scope & Closures》一書(這一本并沒有多長)。這是一本開源書,你可以在 這里 在線閱讀這本書,或者購買這本書的電子版或實體版。這本書的中文譯本涵蓋在《你所不知道的JavaScript 上卷》中,你也可以考慮看中文版。
JavaScript 的很多地方一直被人詬病,倘若不去了解 JS 而是簡單粗暴的按照別的編程語言帶來的慣性思維去寫 JS ,則很容易踩一些坑,抽出一定的時間去了解它,不僅可以讓你避開這些坑,還可以讓你在使用它時更得心應手。
以及,盡管這一篇我寫的時候檢查了很多次是否有問題,但也不保證這篇文章中一定不會有錯誤,如果您發現文章哪里有問題,請在下面留言指正,感激不盡~
來自:http://www.cnblogs.com/blumia/p/Thats-JavaScript-Hoisting-Scope-n-Closure.html