你不懂JS:作用域與閉包 第二章:詞法作用域

官方中文版原文鏈接

感謝社區(qū)中各位的大力支持,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運(yùn)大獎(jiǎng):點(diǎn)擊這里領(lǐng)取

在第一章中,我們將“作用域”定義為一組規(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í)踐是,將詞法作用域看作是僅僅依靠詞法的,因此在本質(zhì)上完全是編寫時(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í)符:abarb

氣泡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è)被引用的變量abc。它首先從最內(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í)施,比如ab,和c。如果你在一段代碼中擁有一個(gè)foo.bar.baz的引用,詞法作用域查詢將在查找foo標(biāo)識(shí)符時(shí)實(shí)施,但一旦定位這個(gè)變量,對(duì)象屬性訪問規(guī)則將會(huì)分別接管barbaz屬性的解析。

欺騙詞法作用域

如果詞法作用域僅僅是由函數(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(..)的作用域中找到ab,而且絕不會(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)在被廢棄了!),而且可以欺騙詞法作用域的特性是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ì)象o1o2。一個(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)語句反應(yīng)的那樣。然而,當(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í)符。

但如果 引擎 在代碼中找到一個(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)行的更慢。不要使用它們。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容