感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
正如我們在第二章中探索的,作用域由一系列“氣泡”組成,這些“氣泡”的每一個就像一個容器或籃子,標識符(變量,函數)就在它里面被聲明。這些氣泡整齊地互相嵌套在一起,而且這種嵌套是在編寫時定義的。
但是到底是什么才能制造一個新氣泡?只能是函數嗎?JavaScript中的其他結構可以創建作用域的氣泡嗎?
函數中的作用域
對這些問題的最常見的回答是,JavaScript擁有基于函數的作用域。也就是,你聲明的每一個函數都為自己創建了一個氣泡,而且沒有其他的結構可以創建它們自己的作用域氣泡。但是就像我們一會兒就會看到的,這不完全正確。
但首先,讓我們探索一下函數作用域和它的含義。
考慮這段代碼:
function foo(a) {
var b = 2;
// 一些代碼
function bar() {
// ...
}
// 更多代碼
var c = 3;
}
在這個代碼段中,foo(..)
的作用域氣泡包含標識符a
,b
,c
和bar
。一個聲明出現在作用域 何處 是 無關緊要的,不管怎樣,變量和函數屬于包含它們的作用域氣泡。在下一章中我們將會探索這到底是如何工作的。
bar(..)
擁有它自己的作用域氣泡。全局作用域也一樣,它僅含有一個標識符:foo
。
因為a
,b
,c
,和bar
都屬于foo(..)
的作用域氣泡,所以它們在foo(..)
外部是不可訪問的。也就是,接下來的代碼都會得到ReferenceError
錯誤,因為這些標識符在全局作用域中都不可用:
bar(); // 失敗
console.log( a, b, c ); // 3個都失敗
然而,所有這些標識符(a
,b
,c
,和bar
)在foo(..)
內部 都是可以訪問的,而且在bar(..)
內部實際上也都是可用的(假定在bar(..)
內部沒有遮蔽標識符的聲明)。
函數作用域支持著這樣的想法:所有變量都屬于函數,而且貫穿整個函數始終都可以使用和重用(而且甚至可以在嵌套的作用域中訪問)。這種設計方式可以十分有用,而且肯定可以完全利用JavaScript的“動態”性質 —— 變量可以根據需要接受不同種類型的值。
另一方面,如果你不小心提防,跨越整個作用域存在的變量可能會導致一些以外的陷阱。
隱藏于普通作用域
考慮一個函數的傳統方式是,你聲明一個函數,并在它內部添加代碼。但是相反的想法也同樣強大和有用:拿你所編寫的代碼的任意一部分,在它周圍包裝一個函數聲明,這實質上“隱藏”了這段代碼。
其實際結果是在這段代碼周圍創建了一個作用域氣泡,這意味著現在在這段代碼中的任何聲明都將綁在這個新的包裝函數的作用域上,而不是前一個包含它們的作用域。換句話說,你可以通過將變量和函數圍在一個函數的作用域中來“隱藏”它們。
為什么“隱藏”變量和函數是一種有用的技術?
有各種原因驅使著這種基于作用域的隱藏。它們主要是由一種稱為“最低權限原則”的軟件設計原則引起的[1],有時也被稱為“最低授權”或“最少曝光”。這個原則規定,在軟件設計中,比如一個模塊/對象的API,你應當只暴露所需要的最低限度的東西,而“隱藏”其他的一切。
這個原則可以擴展到用哪個作用域來包含變量和函數的選擇。如果所有的變量和函數在全局作用域中,它們將理所當然地對任何嵌套的作用域是可訪問的。但這回違背“最少……”原則,因為你(很可能)暴露了許多你本應當保持為私有的變量和函數,而這些代碼的恰當用法是不鼓勵訪問這些變量/函數的。
例如:
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
在這個代碼段中,變量b
和函數doSomethingElse(..)
很可能是doSomething(..)
如何工作的“私有”細節。允許外圍的作用域“訪問”b
和doSomethingElse(..)
不僅沒必要而且可能是“危險的”,因為它們可能會以種種意外的方式,有意或無意地被使用,而這也許違背了doSomething(..)
假設的前提條件。
一個更“恰當”的設計是講這些私有細節隱藏在doSomething(..)
的作用域內部,比如:
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( (b * 3) );
}
doSomething( 2 ); // 15
現在,b
和doSomethingElse(..)
對任何外界影響都是不可訪問的,而是僅僅由doSomething(..)
控制。它的功能和最終結果不受影響,但是這種設計將私有細節保持為私有的,這通常被認為是好的軟件。
避免沖突
將變量和函數“隱藏”在一個作用域內部的另一個好處是,避免兩個同名但用處不同的標識符之間發生無意的沖突。沖突經常導致值被意外地覆蓋。
例如:
function foo() {
function bar(a) {
i = 3; // 在外圍的for循環的作用域中改變`i`
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 噢,無限循環!
}
}
foo();
bar(..)
內部的賦值i = 3
意外地覆蓋了在foo(..)
的for循環中聲明的i
。在這個例子中,這將導致一個無限循環,因為i
被設定為固定的值3
,而它將永遠< 10
。
bar(..)
內部的賦值需要聲明一個本地變量來使用,不論選用什么樣的標識符名稱。var i = 3;
將修復這個問題(并將為i
創建一個前面提到的“遮蔽變量”聲明)。一個 另外的,不是代替的,選項是完全選擇另外一個標識符名稱,比如var j = 3;
。但是你的軟件設計也許會自然而然地使用相同的標識符名稱,所以在這種情況下利用作用域來“隱藏”你的內部聲明是你最好/唯一的選擇。
全局“名稱空間”
變量沖突(可能)發生的一個特別強有力的例子是在全局作用域中。多個庫被加載到你的程序中時可以十分容易地互相沖突,如果它們沒有適當地隱藏它們的內部/私有函數和變量。
這樣的庫通常會在全局作用域中使用一個足夠獨特的名稱來創建一個單獨的變量聲明,它經常是一個對象。然后這個對象被用作這個庫的一個“名稱空間”,所有要明確暴露出來的功能都被作為屬性掛在這個對象(名稱空間)上,而不是將它們自身作為頂層詞法作用域的標識符。
例如:
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
模塊管理
另一種回避沖突的選擇是更加現代的“模塊”方式,它使用任意一種依賴管理器。使用這些工具,沒有庫可以向全局作用域添加任何標識符,取而代之的是使用依賴管理器的各種機制,要求庫的標識符被明確地導入到另一個指定的作用域中。
應該可以看到,這些工具并不擁有可以豁免于詞法作用域規則的“魔法”功能。它們簡單地使用這里講解的作用域規則,來強制標識符不會被注入任何共享的作用域,而是保持在私有的,不易沖突的作用域中,這防止了任何意外的作用域沖突。
因此,如果你選擇這樣做的話,你可以防御性地編碼,并在實際上不使用依賴管理器的情況下,取得與使用它們相同的結果。關于模塊模式的更多信息參見第五章。
函數作為作用域
我們已經看到,我們可以拿來一段代碼并在它周圍包裝一個函數,而這實質上對外部作用域“隱藏”了這個函數內部作用域包含的任何變量或函數聲明。
例如:
var a = 2;
function foo() { // <-- 插入這個
var a = 3;
console.log( a ); // 3
} // <-- 和這個
foo(); // <-- 還有這個
console.log( a ); // 2
雖然這種技術“可以工作”,但它不一定非常理想。它引入了幾個問題。首先是我們不得不聲明一個命名函數foo()
,這意味著這個標識符名稱foo
本身就“污染”了外圍作用域(在這個例子中是全局)。我們要不得不通過名稱(foo()
)明確地調用這個函數來使被包裝的代碼真正運行。
如果這個函數不需要名稱(或者,這個名稱不污染外圍作用域),而且如果這個函數能自動地被執行就更理想了。
幸運的是,JavaScript給這兩個問題提供了一個解決方法。
var a = 2;
(function foo(){ // <-- 插入這個
var a = 3;
console.log( a ); // 3
})(); // <-- 和這個
console.log( a ); // 2
讓我們分析一下這里發生了什么。
首先注意,與僅僅是function...
相對,這個包裝函數語句以(function...)
開頭。雖然這看起來像是一個微小的細節,但實際上這是重大改變。與將這個函數視為一個標準的聲明不同的是,這個函數被視為一個函數表達式。
注意: 區分聲明與表達式的最簡單的方法是,這個語句中(不僅僅是一行,而是一個獨立的語句)“function”一詞的位置。如果“function”是這個語句中的第一個東西,那么它就是一個函數聲明。否則,它就是一個函數表達式。
這里我們可以觀察到一個函數聲明和一個函數表達式之間的關鍵不同是,它的名稱作為一個標識符被綁定在何處。
比較這前兩個代碼段。在第一個代碼段中,名稱foo
被綁定在外圍作用域中,我們用foo()
直接調用它。在第二個代碼段中,名稱foo
沒有被綁定在外圍作用域中,而是被綁定在它自己的函數內部。
換句話說,(function foo(){ .. })
作為一個表達式意味著標識符foo
僅能在..
代表的作用域中被找到,而不是在外部作用域中。將名稱foo
隱藏在它自己內部意味著它不會多余地污染外圍作用域。
匿名與命名
你可能對函數表達式作為回調參數再熟悉不過了,比如:
setTimeout( function(){
console.log("I waited 1 second!");
}, 1000 );
這稱為一個“匿名函數表達式”,因為function()...
上沒有名稱標識符。函數表達式可以是匿名的,但是函數聲明不能省略名稱 —— 那將是不合法的JS程序。
匿名函數表達式可以快速和很容易地鍵入,而且許多庫和工具往往鼓勵使用這種代碼慣用風格。然而,它們有幾個缺點需要考慮:
在棧軌跡上匿名函數沒有有用的名稱可以表示,這使得調試更加困難。
沒有名稱的情況下,如果這個函數需要為了遞歸等目的引用它自己,那么就需要很不幸地使用 被廢棄的
arguments.callee
引用。另一個需要自引用的例子是,當一個事件處理器函數在被觸發后想要把自己解除綁定。匿名函數省略的名稱經常對提供更易讀/易懂的代碼很有幫助。一個描述性的名稱可以幫助代碼自解釋。
內聯函數表達式 很強大且很有用 —— 匿名和命名的問題并不會貶損這一點。給你的函數表達式提供一個名稱就可以十分有效地解決這些缺陷,而且沒有實際的壞處。最佳的方法是總是命名你的函數表達式:
setTimeout( function timeoutHandler(){ // <-- 看,我有一個名字!
console.log( "I waited 1 second!" );
}, 1000 );
立即調用函數表達式
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
得益于包裝在一個()
中,我們有了一個作為表達式的函數,我們可以通過在末尾加入另一個()
來執行這個函數,就像(function foo(){ .. })()
。第一個外圍的()
使這個函數變成表達式,而第二個()
執行這個函數。
這個模式是如此常見,以至于幾年前開發者社區同意給它一個術語:IIFE,它表示“立即被調用的函數表達式”(Immediately Invoked Function E xpression)。
當然,IIFE不一定需要一個名稱 —— IIFE的最常見形式是使用一個匿名函數表達式。雖然少見一些,與匿名函數表達式相比,命名的IIFE擁有前述所有的好處,所以它是一個可以采用的好方式。
var a = 2;
(function IIFE(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
傳統的IIFE有一種稍稍變化的形式,一些人偏好:(function(){ .. }())
。仔細觀察不同之處。在第一種形式中,函數表達式被包在( )
中,然后用于調用的()
出現在它的外側。在第二種形式中,用于調用的()
被移動到用于包裝的( )
內側。
這兩種形式在功能上完全相同。這純粹是一個你偏好的風格的選擇。
IIFE的另一種十分常見的變種是,利用它們實際上只是函數調用的事實,來傳入參數值。
例如:
var a = 2;
(function IIFE( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
我們傳入window
對象引用,但是我們將參數命名為global
,這樣我們對于全局和非全局引用就有了一個清晰的文體上的劃分。當然,你可以從外圍作用域傳入任何你想要的東西,而且你可以將參數命名為任何適合你的名稱。這幾乎僅僅是文體上的選擇。
這種模式的另一種應用解決了一個小問題:默認的undefined
標識符的值也許會被不正確地覆蓋掉,而導致意外的結果。通過將參數命名為undefined
,同時不為它傳遞任何參數值,我們就可以保證在一個代碼塊中undefined
標識符確實是是一個未定義的值。
undefined = true; // 給其他的代碼埋地雷!別這么干!
(function IIFE( undefined ){
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();
IIFE還有另一種變種將事情的順序倒了過來,要被執行的函數在調用和傳遞給它的參數 之后 給出。這種模式被用于UMD(Universal Module Definition —— 統一模塊定義)項目。一些人發現它更干凈和移動一些,雖然有點兒繁冗。
var a = 2;
(function IIFE( def ){
def( window );
})(function def( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});
def
函數表達式帶這個代碼段的后半部分被定義,然后作為一個參數(也叫def
)被傳遞給在代碼段前半部分定義的IIFE
函數。最后,參數def
(函數)被調用,并將window
作為global
參數傳入。
塊兒作為作用域
雖然函數是最常見的作用域單位,而且當然也是在世面上流通的絕大多數JS中最為廣泛傳播的設計方式,但是其他的作用域單位也是可能的,而且使用這些作用域單位可以導致更好,對于維護來說更干凈的代碼。
JavaScript之外的許多其他語言都支持塊兒作用域,所以有這些語言背景的開發者習慣于這種思維模式,然而那些主要在JavaScript中工作的開發者可能會發現這個概念有些陌生。
但即使你從沒用塊兒作用域的方式寫過一行代碼,你可能依然對JavaScript中這種極其常見的慣用法很熟悉:
for (var i=0; i<10; i++) {
console.log( i );
}
我們在for循環頭的內部直接聲明了變量i
,因為我們意圖很可能是僅在這個for循環內部的上下文環境中使用i
,而實質上忽略了這個變量實際上將自己劃入了外圍作用域中(函數或全局)的事實。
這就是有關塊兒作用域的一切。盡可能封閉地,盡可能局部地,在變量將被使用的位置聲明它。另一個例子是:
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}
我們僅在if語句的上下文環境中使用變量bar
,所以我們將它聲明在if塊兒的內部是有些道理的。然而,當使用var
時,我們在何處聲明變量是無關緊要的,因為它們將總是屬于外圍作用域。這個代碼段實質上為了代碼風格的原因“假冒”了塊兒作用域,并依賴于我們要管好自己,不要在這個作用域的其他地方意外地使用bar
。
從將信息隱藏在函數中到將信息隱藏在我們代碼的塊兒中,塊兒作用域是一種擴展了早先的“最低 權限 暴露原則”[1]的工具。
再次考慮這個for循環的例子:
for (var i=0; i<10; i++) {
console.log( i );
}
為什么要用僅將(或者至少是,僅 應當)在這個for循環中使用的變量i
去污染一個函數的整個作用域呢?
但更重要的是,開發者們也許偏好于 檢查 他們自己來防止在變量預期的目的之外意外地(重)使用它們,例如如果你試著在錯誤的地方使用變量會導致一個未知變量的錯誤。對于變量i
的塊兒作用域(如果它是可能的話)將使i
僅在for循環內部可用,使得如果在函數的其他地方訪問i
將導致一個錯誤。這有助于保證變量不會被糊涂地重用或者難于維護。
但是,悲慘的現實是,表面上看來,JavaScript沒有塊兒作用域的能力。
更確切地說,直到你再深入一些才有。
with
我們在第二章中學習了with
。雖然它是一個使人皺眉頭的結構,但它確實是一個(一種形式的)塊兒作用域的例子,它從對象中創建的作用域僅存在于這個with
語句的生命周期中,而不再外圍作用域中。
try/catch
一個鮮為人知的是事實,JavaScript在ES3中明確指出在try/catch
的catch
子句中聲明的變量,是屬于catch
塊兒的塊兒作用域的。
例如:
try {
undefined(); //用非法的操作強制產生一個異常!
}
catch (err) {
console.log( err ); // 好用!
}
console.log( err ); // ReferenceError: `err` not found
如你所見,err
僅存在于catch
子句中,并且在你試著從其他地方引用他時拋出一個錯誤。
注意: 雖然這種行為已經被明確規定,而且對于幾乎所有的標準JS環境(也許除了老IE)來說都是成立的,但是如果你在同一個作用域中有兩個或多個catch
子句,而它們又各自用相同的標識符名稱聲明了它們表示錯誤的變量時,許多linter依然會報警。實際上這不是重定義,因為這些變量都安全地位于塊兒作用域中,但是linter看起來依然,惱人地,抱怨這個事實。
為了避免這些不必要的警告,一些開發者將他們的catch
變量命名為err1
,err2
,等等。另一些開發者干脆關閉linter對重復變量名的檢查。
catch
的塊兒作用域性質看起來像是一個沒用的,只有學院派意義的事實,但是參看附錄B來了解更多它如何有用的信息。
let
至此,我們看到JavaScript僅僅有一些奇怪的小眾行為暴露了塊兒作用域功能。如果這就是我們擁有的一切,而且許多許多年以來這 確實就是 我們擁有的一切,那么塊作用域對JavaScript開發者來說就不是非常有用。
幸運的是,ES6改變了這種狀態,并引入了一個新的關鍵字let
,作為另一種聲明變量的方式伴隨著var
。
let
關鍵字將變量聲明附著在它所在的任何塊兒(通常是一個{ .. }
)的作用域中。換句話說,let
為它的變量聲明隱含地劫持了任意塊兒的作用域。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
使用let
將一個變量附著在一個現存的塊兒上有些隱晦。它可能會使人困惑 —— 在你開發和設計代碼時,如果你不仔細注意哪些塊兒的作用域包含了變量,并且習慣于將塊兒四處移動,將它們包進其他的塊兒中,等等。
為塊兒作用域創建明確的塊兒可以解決這些問題中的一些,使變量附著在何處更加明顯。通常來說,明確的代碼要比隱晦或微妙的代碼好。這種明確的塊兒作用域風格很容易達成,而且它與塊兒作用域在其他語言中的工作方式匹配得更自然:
var foo = true;
if (foo) {
{ // <-- 明確的塊兒
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError
我們可以在一個語句是合法文法的任何地方,通過簡單地引入一個{ .. }
來為let
創建一個任意的可以綁定的塊兒。在這個例子中,我們在if語句內部制造了一個明確的塊兒,在以后的重構中將整個塊兒四處移動可能會更容易,而且不會影響外圍的if語句的位置和語義。
注意: 另一個明確表達塊兒作用域的方法,參見附錄B。
在第四章中,我們將講解提升(hoisting),它講述關于聲明在它們所出現的整個作用域中都被認為是存在的。
然而,使用let
做出的聲明將 不會 在它們所出現的整個塊兒的作用域中提升。如此,直到聲明語句為止,聲明將不會“存在”于塊兒中。
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
垃圾回收
塊兒作用域的另一個有用之處是關于閉包和釋放內存的垃圾回收。我們將簡單地在這里展示一下,但是閉包機制將在第五章中詳細講解。
考慮這段代碼:
function process(data) {
// 做些有趣的事
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
點擊事件的處理器回調函數click
根本不 需要 someReallyBigData
變量。這意味著從理論上講,在process(..)
運行之后,這個消耗巨大內存的數據結構可以被作為垃圾回收。然而,JS引擎很可能(雖然這要看具體實現)將會仍然將這個結構保持一段時間,因為click
函數在整個作用域上擁有一個閉包。
塊兒作用域可以解決這個問題,使引擎清楚地知道它不必再保持someReallyBigData
了:
function process(data) {
// 做些有趣的事
}
// 運行過后,任何定義在這個塊中的東西都可以消失了
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
聲明可以將變量綁定在本地的明確的塊兒是一種強大的工具,你可以把它加入你的工具箱。
let
循環
一個使let
閃光的特殊例子是我們先前討論的for循環。
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
在for循環頭部的let
不僅將i
綁定在for循環體中,而且實際上,它會對每一次循環的 迭代 重新綁定i
,確保它被賦予來自上一次循環迭代末尾的值。
這是描繪這種為每次迭代進行綁定的行為的另一種方式:
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每次迭代都重新綁定
console.log( i );
}
}
這種為每次迭代進行的綁定有趣的原因將在第五章中我們討論閉包時變得明朗。
因為let
聲明附著于任意的塊兒,而不是外圍的函數作用域(或全局),所以在重構代碼時可能會有一些坑需要額外小心:現存的代碼擁有對函數作用域的var
聲明有隱藏的依賴,但你想要用let
來取代var
。
考慮如下代碼:
var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}
// ...
}
這段代碼可以相當容易地重構為:
var foo = true, baz = 10;
if (foo) {
var bar = 3;
// ...
}
if (baz > bar) {
console.log( baz );
}
但是,當使用塊兒作用域變量時要小心這樣的變化:
var foo = true, baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) { // <-- 移動時不要忘了`bar`
console.log( baz );
}
}
附錄B介紹了一種塊作用域的(更加明確的)替代形式,它可能會在這些場景下提供更易于維護/重構的更健壯的代碼。
const
除了let
之外,ES6還引入了const
,它也創建一個塊兒作用域變量,但是它的值是固定的(常量)。任何稍后改變它的企圖都將導致錯誤。
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 存在于包含它的`if`作用域中
a = 3; // 沒問題!
b = 4; // 錯誤!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
復習
在JavaScript中函數是最常見的作用域單位。在另一個函數內部聲明的變量和函數,實質上對任何外圍“作用域”都是“隱藏的”,這是優秀軟件的一個有意的設計原則。
但是函數絕不是唯一的作用域單位。塊兒作用域指的是這樣一種想法:變量和函數可以屬于任意代碼塊兒(一般來說,就是任意的{ .. }
),而不是僅屬于外圍的函數。
從ES3開始,try/catch
結構在catch
子句上擁有塊兒作用域。
在ES6中,引入了let
關鍵字(var
關鍵字的表兄弟)允許在任意代碼塊中聲明變量。if (..) { let a = 2; }
將會聲明變量a
,而它實質上劫持了if
的{ .. }
塊兒的作用域,并將自己附著在這里。
雖然有些人對此深信不疑,但是塊兒作用域不應當被認為是var
函數作用域的一個徹頭徹尾的替代品。兩種機能是共存的,而且開發者們可以并且應當同時使用函數作用域和塊兒作用域技術 —— 在它們各自可以產生更好,更易讀/易維護代碼的地方。