特別說明,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS
在第一章中,我們將“作用域”定義為一組規(guī)則,它主宰著 引擎 如何通過標識符名稱在當前的 作用域,或者在包含它的任意 嵌套作用域 中來查詢一個變量,
作用域的工作方式有兩種占統(tǒng)治地位的模型。其中的第一種是最最常見,在絕大多數(shù)的編程語言中被使用的。它稱為 詞法作用域,我們將深入檢視它。另一種仍然被一些語言(比如 Bash 腳本,Perl 中的一些模式,等等)使用的模型,稱為 動態(tài)作用域。
動態(tài)作用域在附錄A中講解。我在這里提到它僅僅是為詞法作用域提供一個對比,而詞法作用域是 JavaScript 所采用的作用域模型。
詞法分析時
正如我們在第一章中討論的,標準語言編譯器的第一個傳統(tǒng)步驟稱為詞法分析(也就是分詞)。如果你回憶一下,詞法分析處理是檢查一串源代碼字符,并給 token 賦予語法含義作為某種有狀態(tài)解析的輸出。
正是這個概念給理解詞法作用域是什么提供了基礎,它也是這個名字的淵源。
要定義它有點兒兜圈子,詞法作用域是在詞法分析時被定義的作用域。換句話說,詞法作用域是基于,你,在寫程序時,變量和作用域的塊兒在何處被編寫決定的,因此它在詞法分析器處理你的代碼時(基本上)是固定不變的。
注意: 我們將會稍稍看到有一些方法可以騙過詞法作用域,從而在詞法分析器處理過后改變它,但是這些方法都是使人皺眉頭的。事實上公認的最佳實踐是,將詞法作用域看作是僅僅依靠詞法的,因此自然而然地完全是編寫時決定的。
讓我們考慮這段代碼:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
在這個代碼實例中有三個固有的嵌套作用域。將這些作用域考慮為套在一起的氣泡可能有助于思考。
氣泡1 包圍著全局作用域,它里面只有一個標識符:foo。
氣泡2 包圍著作用域 foo,它含有三個標識符:a,bar 和 b。
氣泡3 包圍著作用域 bar,它里面只包含一個標識符:c。
作用域氣泡是根據(jù)作用域的塊兒被寫在何處定義的,一個嵌套在另一個內(nèi)部,等等。在下一章中,我們將討論作用域的不同單位,但是就現(xiàn)在來說,讓我們認為每一個函數(shù)創(chuàng)建了一個新的作用域氣泡。
bar 的氣泡完全被包含在 foo 的氣泡中,因為(而且只因為)這就是我們選擇定義函數(shù) bar 的位置。
注意這些嵌套的氣泡是嚴格嵌套的。我們沒有討論氣泡可以跨越邊界的維恩圖(Venn diagrams)。換句話說,沒有那個函數(shù)的氣泡可以同時(部分地)存在于另外兩個外部的作用域氣泡中,就像沒有函數(shù)可以部分地存在于它的兩個父函數(shù)中一樣。
查詢
這些作用域氣泡的結(jié)構(gòu)和相對位置完全解釋了 引擎 在查找一個標識符時,它需要查看的所有地方。
在上面的代碼段中,引擎 執(zhí)行語句 console.log(..) 并開始查找三個被引用的變量 a,b 和 c。它首先從最內(nèi)部的作用域氣泡開始,也就是 bar(..) 函數(shù)的作用域。在這里它找不到 a,所以它向上走一層,到外面下一個最近的作用域氣泡,foo(..) 的作用域。它在這里找到了 a,于是它就使用這個 a。同樣的事情也發(fā)生在 b 身上。但是對于 c,它在 bar(..) 內(nèi)部就找到了。
如果在 bar(..) 內(nèi)部和 foo(..) 內(nèi)部都有一個 c,那么 console.log(..) 語句將會找到并使用 bar(..) 中的那一個,絕不會到達 foo(..) 中的那一個。
一旦找到第一個匹配,作用域查詢就停止了。相同的標識符名稱可以在嵌套作用域的多個層中被指定,這稱為“遮蔽(shadowing)”(內(nèi)部的標識符“遮蔽”了外部的標識符)。無論如何遮蔽,作用域查詢總是從當前被執(zhí)行的最內(nèi)側(cè)的作用域開始,向外/向上不斷查找,直到第一個匹配才停止。
注意: 全局變量也自動地是全局對象(在瀏覽器中是 window,等等)的屬性,所以不直接通過全局變量的詞法名稱,而通過將它作為全局對象的一個屬性引用來間接地引用,是可能的。
window.a
這種技術(shù)給出了訪問全局變量的方法,沒有它全局變量將因為被遮蔽而不可訪問。然而,被遮蔽的非全局變量是無法訪問的。
不管函數(shù)是從 哪里 被調(diào)用的,也不論它是 如何 被調(diào)用的,它的詞法作用域是由這個函數(shù)被聲明的位置 唯一 定義的。
詞法作用域查詢 僅僅 在處理頭等標識符時實施,比如 a,b,和 c。如果你在一段代碼中擁有一個 foo.bar.baz 的引用,詞法作用域查詢將在查找 foo 標識符時實施,但一旦定位這個變量,對象屬性訪問規(guī)則將會分別接管 bar 和 baz 屬性的解析。
欺騙詞法作用域
如果詞法作用域是由函數(shù)被聲明的位置唯一定義的,而且這個位置完全是一個編寫時的決定,那么怎么可能有辦法在運行時“修改”(也就是,作弊欺騙)詞法作用域呢?
JavaScript 有兩種這樣的機制。在廣大的社區(qū)中它們都等同地被認為是讓人皺眉頭的,在你代碼中使用它們是一種差勁兒的做法。但是關(guān)于它們的常見的爭論經(jīng)常錯過了最重要的一點:欺騙詞法作用域會導致更低下的性能。
在我講解性能的問題以前,先讓我們看看這兩種機制是如何工作的。
eval
JavaScript 中的 eval(..) 函數(shù)接收一個字符串作為參數(shù)值,并將這個字符串的內(nèi)容看作是好像它已經(jīng)被實際編寫在程序的那個位置上。換句話說,你可以用編程的方式在你編寫好的代碼內(nèi)部生成代碼,而且你可以運行這個生成的代碼,就好像它在編寫時就已經(jīng)在那里了一樣。
如果以這種觀點來評價 eval(..),那么 eval(..) 是如何允許你修改詞法作用域環(huán)境應當是很清楚的:欺騙并假裝這個編寫時(也就是,詞法)代碼一直就在那里。
在 eval(..) 被執(zhí)行的后續(xù)代碼行中,引擎 將不會“知道”或“關(guān)心”前面的代碼是被動態(tài)翻譯的,而且因此修改了詞法作用域環(huán)境。引擎 將會像它一直做的那樣,簡單地進行詞法作用域查詢。
考慮如下代碼:
function foo(str, a) {
eval( str ); // 作弊!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1 3
在 eval(..) 調(diào)用的位置上,字符串 "var b = 3" 被看作是一直就存在在那里的代碼。因為這個代碼恰巧聲明了一個新的變量 b,它就修改了現(xiàn)存的 foo(..) 的詞法作用域。事實上,就像上面提到的那樣,這個代碼實際上在 foo(..) 內(nèi)部創(chuàng)建了變量 b,它遮蔽了聲明在外部(全局)作用域中的 b。
當 console.log(..) 調(diào)用發(fā)生時,它會在 foo(..) 的作用域中找到 a 和 b,而且絕不會找到外部的 b。這樣,我們就打印出 "1 3" 而不是一般情況下的 "1 2"。
注意: 在這個例子中,為了簡單起見,我們傳入的“代碼”字符串是固定的文字。但是它可以通過根據(jù)你的程序邏輯將字符拼接在一起,很容易地以編程方式創(chuàng)建。eval(..) 通常被用于執(zhí)行動態(tài)創(chuàng)建的代碼,因為動態(tài)地對一段實質(zhì)上源自字符串字面值的靜態(tài)代碼進行求值,并不會比直接編寫這樣的代碼帶來更多真正的好處。
默認情況下,如果 eval(..) 執(zhí)行的代碼字符串包含一個或多個聲明(變量或函數(shù))的話,這個動作就會修改這個 eval(..) 所在的詞法作用域。技術(shù)上講,eval(..) 可以通過種種技巧(超出了我們這里的討論范圍)被“間接”調(diào)用,而使它在全局作用域的上下文中執(zhí)行,以此修改全局作用域。但不論那種情況,eval(..) 都可以在運行時修改一個編寫時的詞法作用域。
注意: 當 eval(..) 被用于一個操作它自己的詞法作用域的 strict 模式程序時,在 eval(..) 內(nèi)部做出的聲明不會實際上修改包圍它的作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
在 JavaScript 中還有其他的工具擁有與 eval(..) 非常類似的效果。setTimeout(..) 和 setInterval(..) 可以 為它們各自的第一個參數(shù)值接收一個字符串,其內(nèi)容將會被 eval 為一個動態(tài)生成的函數(shù)的代碼。這種老舊的,遺產(chǎn)行為早就被廢棄了。別這么做!
new Function(..) 函數(shù)構(gòu)造器類似地為它的 最后 一個參數(shù)值接收一個代碼字符串,來把它轉(zhuǎn)換為一個動態(tài)生成的函數(shù)(前面的參數(shù)值,如果有的話,將作為新函數(shù)的形式參數(shù))。這種函數(shù)構(gòu)造器語法要比 eval(..) 稍稍安全一些,但在你的代碼中它仍然應當被避免。
在你的代碼中動態(tài)生成代碼的用例少的不可思議,因為在性能上的倒退使得這種能力幾乎總是得不償失。
with
JavaScript 的另一個使人皺眉頭(而且現(xiàn)在被廢棄了?。铱梢云垓_詞法作用域的特性是 with 關(guān)鍵字。有許多種合法的方式可以講解 with,但是我在此選擇從它如何與詞法作用域互動并影響詞法作用域的角度來講解它。
講解 with 的常見方式是作為一種縮寫,來引用一個對象的多個屬性,而 不必 每次都重復對象引用本身。
例如:
var obj = {
a: 1,
b: 2,
c: 3
};
// 重復“obj”顯得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// “更簡單”的縮寫
with (obj) {
a = 3;
b = 4;
c = 5;
}
然而,這里發(fā)生的事情要比只是一個對象屬性訪問的便捷縮寫要多得多。考慮如下代碼:
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作用域被泄漏了!
在這個代碼示例中,創(chuàng)建了兩個對象 o1 和 o2。一個有 a 屬性,而另一個沒有。foo(..) 函數(shù)接收一個對象引用 obj 作為參數(shù)值,并在這個引用上調(diào)用 with (obj) {..}。在 with 塊兒內(nèi)部,我們制造了一個變量 a 的看似是普通詞法引用的東西,實際上是一個 LHS 引用(見第一章),并將值 2 賦予它。
當我們傳入 o1 時,賦值 a = 2 找到屬性 o1.a 并賦予它值 2,正如在后續(xù)的 console.log(o1.a) 語句中反映出的那樣。然而,當我們傳入 o2,因為它沒有 a 屬性,沒有這樣的屬性被創(chuàng)建,所以 o2.a 還是 undefined。
但是之后我們注意到一個特別的副作用,賦值 a = 2 創(chuàng)建了一個全局變量 a。這怎么可能?
with 語句接收一個對象,這個對象有0個或多個屬性,并 將這個對象視為好像它是一個完全隔離的詞法作用域,因此這個對象的屬性被視為在這個“作用域”中詞法定義的標識符。
注意: 盡管一個 with 塊兒將一個對象視為一個詞法作用域,但是在 with 塊兒內(nèi)部的一個普通 var 聲明將不會歸于這個 with 塊兒的作用域,而是歸于包含它的函數(shù)作用域。
如果 eval(..) 函數(shù)接收一個含有一個或多個聲明的代碼字符串,它就會修改現(xiàn)存的詞法作用域,而 with 語句實際上是從你傳遞給它的對象中憑空制造了一個 全新的詞法作用域。
以這種方式理解的話,當我們傳入 o1 時 with 語句聲明的“作用域”就是 o1,而且這個“作用域”擁有一個對應于 o1.a 屬性的“標識符”。但當我們使用 o2 作為“作用域”時,它里面沒有這樣的 a “標識符”,于是 LHS 標識符查詢(見第一章)的普通規(guī)則發(fā)生了。
“作用域” o2 中沒有,foo(..) 的作用域中也沒有,甚至連全局作用域中都沒有找到標識符 a,所以當 a = 2 被執(zhí)行時,其結(jié)果就是自動全局變量被創(chuàng)建(因為我們沒有在 strict 模式下)。
with 在運行時將一個對象和它的屬性轉(zhuǎn)換為一個帶有“標識符”的“作用域”,這個奇怪想法有些燒腦。但是對于我們看到的結(jié)果來說,這是我能給出的最清晰的解釋。
注意: 除了使用它們是個壞主意以外,eval(..) 和 with 都受Strict模式的影響(制約)。with 干脆就不允許使用,而雖然 eval(..) 還保有其核心功能,但各種間接形式的或不安全的 eval(..) 是不允許的。
性能
通過在運行時修改,或創(chuàng)建新的詞法作用域,eval(..) 和 with 都可以欺騙編寫時定義的詞法作用域。
你可能會問,那又有什么大不了的?如果它們提供了更精巧的功能和編碼靈活性,那它們不是 好的 特性嗎?不。
JavaScript 引擎 在編譯階段期行許多性能優(yōu)化工作。其中的一些優(yōu)化原理都歸結(jié)為實質(zhì)上在進行詞法分析時可以靜態(tài)地分析代碼,并提前決定所有的變量和函數(shù)聲明都在什么位置,這樣在執(zhí)行期間就可以少花些力氣來解析標識符。
但如果 引擎 在代碼中發(fā)現(xiàn)一個 eval(..) 或 with,它實質(zhì)上就不得不 假定 自己知道的所有的標識符的位置可能是無效的,因為它不可能在詞法分析時就知道你將會向eval(..)傳遞什么樣的代碼來修改詞法作用域,或者你可能會向with傳遞的對象有什么樣的內(nèi)容來創(chuàng)建一個新的將被查詢的詞法作用域。
換句話說,悲觀地看,如果 eval(..) 或 with 出現(xiàn),那么它 將 做的幾乎所有的優(yōu)化都會變得沒有意義,所以它就會簡單地根本不做任何優(yōu)化。
你的代碼幾乎肯定會趨于運行的更慢,只因為你在代碼的任何地方引入了一個了 eval(..) 或 with。無論 引擎 將在努力限制這些悲觀臆測的副作用上表現(xiàn)得多么聰明,都沒有任何辦法可以繞過這個事實:沒有優(yōu)化,代碼就運行的更慢。
復習
詞法作用域意味著作用域是由編寫時函數(shù)被聲明的位置的決策定義的。編譯器的詞法分析階段實質(zhì)上可以知道所有的標識符是在哪里和如何聲明的,并如此在執(zhí)行期間預測它們將如何被查詢。
在 JavaScript 中有兩種機制可以“欺騙”詞法作用域:eval(..) 和 with。前者可以通過對一個擁有一個或多個聲明的“代碼”字符串進行求值,來(在運行時)修改現(xiàn)存的詞法作用域。后者實質(zhì)上是通過將一個對象引用看作一個“作用域”,并將這個對象的屬性看作作用域中的標識符,(同樣,也是在運行時)創(chuàng)建一個全新的詞法作用域。
這些機制的缺點是,它壓制了 引擎 在作用域查詢上進行編譯期優(yōu)化的能力,因為 引擎 不得不悲觀地假定這樣的優(yōu)化是無效的。這兩種特性的結(jié)果就是代碼 將 會運行的更慢。不要使用它們。