特別說明分井,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS
在第一章中撤奸,我們將“作用域”定義為一組規(guī)則啸驯,它主宰著 引擎 如何通過標(biāo)識(shí)符名稱在當(dāng)前的 作用域,或者在包含它的任意 嵌套作用域 中來查詢一個(gè)變量英上,
作用域的工作方式有兩種占統(tǒng)治地位的模型炭序。其中的第一種是最最常見,在絕大多數(shù)的編程語言中被使用的苍日。它稱為 詞法作用域惭聂,我們將深入檢視它。另一種仍然被一些語言(比如 Bash 腳本相恃,Perl 中的一些模式辜纲,等等)使用的模型,稱為 動(dòng)態(tài)作用域拦耐。
動(dòng)態(tài)作用域在附錄A中講解耕腾。我在這里提到它僅僅是為詞法作用域提供一個(gè)對(duì)比,而詞法作用域是 JavaScript 所采用的作用域模型杀糯。
詞法分析時(shí)
正如我們?cè)诘谝徽轮杏懻摰纳ò常瑯?biāo)準(zhǔn)語言編譯器的第一個(gè)傳統(tǒng)步驟稱為詞法分析(也就是分詞)。如果你回憶一下固翰,詞法分析處理是檢查一串源代碼字符狼纬,并給 token 賦予語法含義作為某種有狀態(tài)解析的輸出羹呵。
正是這個(gè)概念給理解詞法作用域是什么提供了基礎(chǔ),它也是這個(gè)名字的淵源畸颅。
要定義它有點(diǎn)兒兜圈子担巩,詞法作用域是在詞法分析時(shí)被定義的作用域。換句話說没炒,詞法作用域是基于涛癌,你,在寫程序時(shí)送火,變量和作用域的塊兒在何處被編寫決定的拳话,因此它在詞法分析器處理你的代碼時(shí)(基本上)是固定不變的。
注意: 我們將會(huì)稍稍看到有一些方法可以騙過詞法作用域种吸,從而在詞法分析器處理過后改變它弃衍,但是這些方法都是使人皺眉頭的。事實(shí)上公認(rèn)的最佳實(shí)踐是坚俗,將詞法作用域看作是僅僅依靠詞法的镜盯,因此自然而然地完全是編寫時(shí)決定的。
讓我們考慮這段代碼:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
在這個(gè)代碼實(shí)例中有三個(gè)固有的嵌套作用域猖败。將這些作用域考慮為套在一起的氣泡可能有助于思考速缆。
![](fig2.png)
氣泡1 包圍著全局作用域,它里面只有一個(gè)標(biāo)識(shí)符:foo
恩闻。
氣泡2 包圍著作用域 foo
艺糜,它含有三個(gè)標(biāo)識(shí)符:a
,bar
和 b
幢尚。
氣泡3 包圍著作用域 bar
破停,它里面只包含一個(gè)標(biāo)識(shí)符:c
。
作用域氣泡是根據(jù)作用域的塊兒被寫在何處定義的尉剩,一個(gè)嵌套在另一個(gè)內(nèi)部真慢,等等。在下一章中理茎,我們將討論作用域的不同單位晤碘,但是就現(xiàn)在來說,讓我們認(rèn)為每一個(gè)函數(shù)創(chuàng)建了一個(gè)新的作用域氣泡功蜓。
bar
的氣泡完全被包含在 foo
的氣泡中,因?yàn)椋ǘ抑灰驗(yàn)椋┻@就是我們選擇定義函數(shù) bar
的位置宠蚂。
注意這些嵌套的氣泡是嚴(yán)格嵌套的式撼。我們沒有討論氣泡可以跨越邊界的維恩圖(Venn diagrams)。換句話說求厕,沒有那個(gè)函數(shù)的氣泡可以同時(shí)(部分地)存在于另外兩個(gè)外部的作用域氣泡中著隆,就像沒有函數(shù)可以部分地存在于它的兩個(gè)父函數(shù)中一樣扰楼。
查詢
這些作用域氣泡的結(jié)構(gòu)和相對(duì)位置完全解釋了 引擎 在查找一個(gè)標(biāo)識(shí)符時(shí),它需要查看的所有地方美浦。
在上面的代碼段中弦赖,引擎 執(zhí)行語句 console.log(..)
并開始查找三個(gè)被引用的變量 a
,b
和 c
浦辨。它首先從最內(nèi)部的作用域氣泡開始蹬竖,也就是 bar(..)
函數(shù)的作用域。在這里它找不到 a
流酬,所以它向上走一層币厕,到外面下一個(gè)最近的作用域氣泡,foo(..)
的作用域芽腾。它在這里找到了 a
旦装,于是它就使用這個(gè) a
。同樣的事情也發(fā)生在 b
身上摊滔。但是對(duì)于 c
阴绢,它在 bar(..)
內(nèi)部就找到了。
如果在 bar(..)
內(nèi)部和 foo(..)
內(nèi)部都有一個(gè) c
艰躺,那么 console.log(..)
語句將會(huì)找到并使用 bar(..)
中的那一個(gè)呻袭,絕不會(huì)到達(dá) foo(..)
中的那一個(gè)。
一旦找到第一個(gè)匹配描滔,作用域查詢就停止了棒妨。相同的標(biāo)識(shí)符名稱可以在嵌套作用域的多個(gè)層中被指定,這稱為“遮蔽(shadowing)”(內(nèi)部的標(biāo)識(shí)符“遮蔽”了外部的標(biāo)識(shí)符)含长。無論如何遮蔽券腔,作用域查詢總是從當(dāng)前被執(zhí)行的最內(nèi)側(cè)的作用域開始,向外/向上不斷查找拘泞,直到第一個(gè)匹配才停止纷纫。
注意: 全局變量也自動(dòng)地是全局對(duì)象(在瀏覽器中是 window
,等等)的屬性陪腌,所以不直接通過全局變量的詞法名稱辱魁,而通過將它作為全局對(duì)象的一個(gè)屬性引用來間接地引用,是可能的诗鸭。
window.a
這種技術(shù)給出了訪問全局變量的方法染簇,沒有它全局變量將因?yàn)楸徽诒味豢稍L問。然而强岸,被遮蔽的非全局變量是無法訪問的锻弓。
不管函數(shù)是從 哪里 被調(diào)用的,也不論它是 如何 被調(diào)用的蝌箍,它的詞法作用域是由這個(gè)函數(shù)被聲明的位置 唯一 定義的青灼。
詞法作用域查詢 僅僅 在處理頭等標(biāo)識(shí)符時(shí)實(shí)施暴心,比如 a
,b
杂拨,和 c
专普。如果你在一段代碼中擁有一個(gè) foo.bar.baz
的引用,詞法作用域查詢將在查找 foo
標(biāo)識(shí)符時(shí)實(shí)施弹沽,但一旦定位這個(gè)變量檀夹,對(duì)象屬性訪問規(guī)則將會(huì)分別接管 bar
和 baz
屬性的解析。
欺騙詞法作用域
如果詞法作用域是由函數(shù)被聲明的位置唯一定義的贷币,而且這個(gè)位置完全是一個(gè)編寫時(shí)的決定击胜,那么怎么可能有辦法在運(yùn)行時(shí)“修改”(也就是,作弊欺騙)詞法作用域呢役纹?
JavaScript 有兩種這樣的機(jī)制偶摔。在廣大的社區(qū)中它們都等同地被認(rèn)為是讓人皺眉頭的,在你代碼中使用它們是一種差勁兒的做法促脉。但是關(guān)于它們的常見的爭論經(jīng)常錯(cuò)過了最重要的一點(diǎn):欺騙詞法作用域會(huì)導(dǎo)致更低下的性能辰斋。
在我講解性能的問題以前,先讓我們看看這兩種機(jī)制是如何工作的瘸味。
eval
JavaScript 中的 eval(..)
函數(shù)接收一個(gè)字符串作為參數(shù)值宫仗,并將這個(gè)字符串的內(nèi)容看作是好像它已經(jīng)被實(shí)際編寫在程序的那個(gè)位置上。換句話說旁仿,你可以用編程的方式在你編寫好的代碼內(nèi)部生成代碼藕夫,而且你可以運(yùn)行這個(gè)生成的代碼,就好像它在編寫時(shí)就已經(jīng)在那里了一樣枯冈。
如果以這種觀點(diǎn)來評(píng)價(jià) eval(..)
毅贮,那么 eval(..)
是如何允許你修改詞法作用域環(huán)境應(yīng)當(dāng)是很清楚的:欺騙并假裝這個(gè)編寫時(shí)(也就是,詞法)代碼一直就在那里尘奏。
在 eval(..)
被執(zhí)行的后續(xù)代碼行中滩褥,引擎 將不會(huì)“知道”或“關(guān)心”前面的代碼是被動(dòng)態(tài)翻譯的,而且因此修改了詞法作用域環(huán)境炫加。引擎 將會(huì)像它一直做的那樣瑰煎,簡單地進(jì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"
被看作是一直就存在在那里的代碼。因?yàn)檫@個(gè)代碼恰巧聲明了一個(gè)新的變量 b
赋铝,它就修改了現(xiàn)存的 foo(..)
的詞法作用域插勤。事實(shí)上,就像上面提到的那樣,這個(gè)代碼實(shí)際上在 foo(..)
內(nèi)部創(chuàng)建了變量 b
饮六,它遮蔽了聲明在外部(全局)作用域中的 b
。
當(dāng) console.log(..)
調(diào)用發(fā)生時(shí)苛蒲,它會(huì)在 foo(..)
的作用域中找到 a
和 b
卤橄,而且絕不會(huì)找到外部的 b
。這樣臂外,我們就打印出 "1 3" 而不是一般情況下的 "1 2"窟扑。
注意: 在這個(gè)例子中,為了簡單起見漏健,我們傳入的“代碼”字符串是固定的文字嚎货。但是它可以通過根據(jù)你的程序邏輯將字符拼接在一起,很容易地以編程方式創(chuàng)建蔫浆。eval(..)
通常被用于執(zhí)行動(dòng)態(tài)創(chuàng)建的代碼殖属,因?yàn)閯?dòng)態(tài)地對(duì)一段實(shí)質(zhì)上源自字符串字面值的靜態(tài)代碼進(jìn)行求值,并不會(huì)比直接編寫這樣的代碼帶來更多真正的好處瓦盛。
默認(rèn)情況下洗显,如果 eval(..)
執(zhí)行的代碼字符串包含一個(gè)或多個(gè)聲明(變量或函數(shù))的話,這個(gè)動(dòng)作就會(huì)修改這個(gè) eval(..)
所在的詞法作用域原环。技術(shù)上講挠唆,eval(..)
可以通過種種技巧(超出了我們這里的討論范圍)被“間接”調(diào)用,而使它在全局作用域的上下文中執(zhí)行嘱吗,以此修改全局作用域玄组。但不論那種情況,eval(..)
都可以在運(yùn)行時(shí)修改一個(gè)編寫時(shí)的詞法作用域谒麦。
注意: 當(dāng) eval(..)
被用于一個(gè)操作它自己的詞法作用域的 strict 模式程序時(shí)俄讹,在 eval(..)
內(nèi)部做出的聲明不會(huì)實(shí)際上修改包圍它的作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
在 JavaScript 中還有其他的工具擁有與 eval(..)
非常類似的效果弄匕。setTimeout(..)
和 setInterval(..)
可以 為它們各自的第一個(gè)參數(shù)值接收一個(gè)字符串颅悉,其內(nèi)容將會(huì)被 eval
為一個(gè)動(dòng)態(tài)生成的函數(shù)的代碼。這種老舊的迁匠,遺產(chǎn)行為早就被廢棄了剩瓶。別這么做!
new Function(..)
函數(shù)構(gòu)造器類似地為它的 最后 一個(gè)參數(shù)值接收一個(gè)代碼字符串城丧,來把它轉(zhuǎn)換為一個(gè)動(dòng)態(tài)生成的函數(shù)(前面的參數(shù)值延曙,如果有的話,將作為新函數(shù)的形式參數(shù))亡哄。這種函數(shù)構(gòu)造器語法要比 eval(..)
稍稍安全一些枝缔,但在你的代碼中它仍然應(yīng)當(dāng)被避免。
在你的代碼中動(dòng)態(tài)生成代碼的用例少的不可思議,因?yàn)樵谛阅苌系牡雇耸沟眠@種能力幾乎總是得不償失愿卸。
with
JavaScript 的另一個(gè)使人皺眉頭(而且現(xiàn)在被廢棄了A榱佟),而且可以欺騙詞法作用域的特性是 with
關(guān)鍵字趴荸。有許多種合法的方式可以講解 with
儒溉,但是我在此選擇從它如何與詞法作用域互動(dòng)并影響詞法作用域的角度來講解它。
講解 with
的常見方式是作為一種縮寫发钝,來引用一個(gè)對(duì)象的多個(gè)屬性顿涣,而 不必 每次都重復(fù)對(duì)象引用本身。
例如:
var obj = {
a: 1,
b: 2,
c: 3
};
// 重復(fù)“obj”顯得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// “更簡單”的縮寫
with (obj) {
a = 3;
b = 4;
c = 5;
}
然而酝豪,這里發(fā)生的事情要比只是一個(gè)對(duì)象屬性訪問的便捷縮寫要多得多涛碑。考慮如下代碼:
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 -- 哦孵淘,全局作用域被泄漏了蒲障!
在這個(gè)代碼示例中,創(chuàng)建了兩個(gè)對(duì)象 o1
和 o2
夺英。一個(gè)有 a
屬性晌涕,而另一個(gè)沒有。foo(..)
函數(shù)接收一個(gè)對(duì)象引用 obj
作為參數(shù)值痛悯,并在這個(gè)引用上調(diào)用 with (obj) {..}
余黎。在 with
塊兒內(nèi)部,我們制造了一個(gè)變量 a
的看似是普通詞法引用的東西载萌,實(shí)際上是一個(gè) LHS 引用(見第一章)惧财,并將值 2
賦予它。
當(dāng)我們傳入 o1
時(shí)扭仁,賦值 a = 2
找到屬性 o1.a
并賦予它值 2
垮衷,正如在后續(xù)的 console.log(o1.a)
語句中反映出的那樣。然而乖坠,當(dāng)我們傳入 o2
搀突,因?yàn)樗鼪]有 a
屬性,沒有這樣的屬性被創(chuàng)建熊泵,所以 o2.a
還是 undefined
仰迁。
但是之后我們注意到一個(gè)特別的副作用,賦值 a = 2
創(chuàng)建了一個(gè)全局變量 a
顽分。這怎么可能徐许?
with
語句接收一個(gè)對(duì)象,這個(gè)對(duì)象有0個(gè)或多個(gè)屬性卒蘸,并 將這個(gè)對(duì)象視為好像它是一個(gè)完全隔離的詞法作用域雌隅,因此這個(gè)對(duì)象的屬性被視為在這個(gè)“作用域”中詞法定義的標(biāo)識(shí)符。
注意: 盡管一個(gè) with
塊兒將一個(gè)對(duì)象視為一個(gè)詞法作用域,但是在 with
塊兒內(nèi)部的一個(gè)普通 var
聲明將不會(huì)歸于這個(gè) with
塊兒的作用域恰起,而是歸于包含它的函數(shù)作用域修械。
如果 eval(..)
函數(shù)接收一個(gè)含有一個(gè)或多個(gè)聲明的代碼字符串,它就會(huì)修改現(xiàn)存的詞法作用域检盼,而 with
語句實(shí)際上是從你傳遞給它的對(duì)象中憑空制造了一個(gè) 全新的詞法作用域祠肥。
以這種方式理解的話,當(dāng)我們傳入 o1
時(shí) with
語句聲明的“作用域”就是 o1
梯皿,而且這個(gè)“作用域”擁有一個(gè)對(duì)應(yīng)于 o1.a
屬性的“標(biāo)識(shí)符”。但當(dāng)我們使用 o2
作為“作用域”時(shí)县恕,它里面沒有這樣的 a
“標(biāo)識(shí)符”东羹,于是 LHS 標(biāo)識(shí)符查詢(見第一章)的普通規(guī)則發(fā)生了。
“作用域” o2
中沒有忠烛,foo(..)
的作用域中也沒有属提,甚至連全局作用域中都沒有找到標(biāo)識(shí)符 a
,所以當(dāng) a = 2
被執(zhí)行時(shí)美尸,其結(jié)果就是自動(dòng)全局變量被創(chuàng)建(因?yàn)槲覀儧]有在 strict 模式下)冤议。
with
在運(yùn)行時(shí)將一個(gè)對(duì)象和它的屬性轉(zhuǎn)換為一個(gè)帶有“標(biāo)識(shí)符”的“作用域”,這個(gè)奇怪想法有些燒腦师坎。但是對(duì)于我們看到的結(jié)果來說恕酸,這是我能給出的最清晰的解釋。
注意: 除了使用它們是個(gè)壞主意以外胯陋,eval(..)
和 with
都受Strict模式的影響(制約)蕊温。with
干脆就不允許使用,而雖然 eval(..)
還保有其核心功能遏乔,但各種間接形式的或不安全的 eval(..)
是不允許的义矛。
性能
通過在運(yùn)行時(shí)修改,或創(chuàng)建新的詞法作用域盟萨,eval(..)
和 with
都可以欺騙編寫時(shí)定義的詞法作用域凉翻。
你可能會(huì)問,那又有什么大不了的捻激?如果它們提供了更精巧的功能和編碼靈活性制轰,那它們不是 好的 特性嗎?不铺罢。
JavaScript 引擎 在編譯階段期行許多性能優(yōu)化工作艇挨。其中的一些優(yōu)化原理都?xì)w結(jié)為實(shí)質(zhì)上在進(jìn)行詞法分析時(shí)可以靜態(tài)地分析代碼,并提前決定所有的變量和函數(shù)聲明都在什么位置韭赘,這樣在執(zhí)行期間就可以少花些力氣來解析標(biāo)識(shí)符缩滨。
但如果 引擎 在代碼中發(fā)現(xiàn)一個(gè) eval(..)
或 with
,它實(shí)質(zhì)上就不得不 假定 自己知道的所有的標(biāo)識(shí)符的位置可能是無效的,因?yàn)樗豢赡茉谠~法分析時(shí)就知道你將會(huì)向eval(..)
傳遞什么樣的代碼來修改詞法作用域脉漏,或者你可能會(huì)向with
傳遞的對(duì)象有什么樣的內(nèi)容來創(chuàng)建一個(gè)新的將被查詢的詞法作用域苞冯。
換句話說,悲觀地看侧巨,如果 eval(..)
或 with
出現(xiàn)舅锄,那么它 將 做的幾乎所有的優(yōu)化都會(huì)變得沒有意義,所以它就會(huì)簡單地根本不做任何優(yōu)化司忱。
你的代碼幾乎肯定會(huì)趨于運(yùn)行的更慢皇忿,只因?yàn)槟阍诖a的任何地方引入了一個(gè)了 eval(..)
或 with
。無論 引擎 將在努力限制這些悲觀臆測的副作用上表現(xiàn)得多么聰明坦仍,都沒有任何辦法可以繞過這個(gè)事實(shí):沒有優(yōu)化鳍烁,代碼就運(yùn)行的更慢。
復(fù)習(xí)
詞法作用域意味著作用域是由編寫時(shí)函數(shù)被聲明的位置的決策定義的繁扎。編譯器的詞法分析階段實(shí)質(zhì)上可以知道所有的標(biāo)識(shí)符是在哪里和如何聲明的幔荒,并如此在執(zhí)行期間預(yù)測它們將如何被查詢。
在 JavaScript 中有兩種機(jī)制可以“欺騙”詞法作用域:eval(..)
和 with
梳玫。前者可以通過對(duì)一個(gè)擁有一個(gè)或多個(gè)聲明的“代碼”字符串進(jìn)行求值爹梁,來(在運(yùn)行時(shí))修改現(xiàn)存的詞法作用域。后者實(shí)質(zhì)上是通過將一個(gè)對(duì)象引用看作一個(gè)“作用域”提澎,并將這個(gè)對(duì)象的屬性看作作用域中的標(biāo)識(shí)符姚垃,(同樣,也是在運(yùn)行時(shí))創(chuàng)建一個(gè)全新的詞法作用域盼忌。
這些機(jī)制的缺點(diǎn)是莉炉,它壓制了 引擎 在作用域查詢上進(jìn)行編譯期優(yōu)化的能力,因?yàn)?引擎 不得不悲觀地假定這樣的優(yōu)化是無效的碴犬。這兩種特性的結(jié)果就是代碼 將 會(huì)運(yùn)行的更慢絮宁。不要使用它們。