JavaScript中的詞法作用域

作用域共有兩種主要的工作模型。第一種是最為普遍的,被大多數(shù)編程語言所采用的詞法 作用域,我們會(huì)對這種作用域進(jìn)行深入討論。另外一種叫作動(dòng)態(tài)作用域,仍有一些編程語 言在使用(比如 Bash 腳本、Perl 中的一些模式等)。

簡單地說,詞法作用域就是定義在詞法階段的作用域。換句話說,詞法作用域是由你在寫 代碼時(shí)將變量和塊作用域?qū)懺谀睦飦頉Q定的,因此當(dāng)詞法分析器處理代碼時(shí)會(huì)保持作用域 不變(大部分情況下是這樣的)。

考慮以下代碼:


function foo(a){
  var b = a*2;
  function bar(c){
    console.log(a,b,c);
  }
  bar(b*3);
}
foo(2) // 2,4,12

在這個(gè)例子中有三個(gè)逐級嵌套的作用域,為了幫助理解,可以將它們想象成幾個(gè)逐級包含的氣泡。


氣泡.png

1.包含著整個(gè)全局作用域,其中只有一個(gè)標(biāo)識符:foo。
2.包含著foo所創(chuàng)建的作用域,其中有三個(gè)標(biāo)識符:a、bar和b。
3.包含著bar所創(chuàng)建的作用域,其中只有一個(gè)標(biāo)識符:c

作用域氣泡由其對應(yīng)的作用域塊代碼寫在哪里決定,他們是逐級包含的。這里假設(shè)每一個(gè)函數(shù)都會(huì)創(chuàng)建一個(gè)新的作用域就好了。

bar 的氣泡被完全包含在foo所創(chuàng)建的氣泡中,唯一的原因是哪里就是我們希望定義函數(shù)bar的位置。

這里的所有的包含是嚴(yán)格包含的。換句話說,沒有任何函數(shù)的氣泡可以同時(shí)出現(xiàn)在兩個(gè)外部作用域的氣泡中,就如同沒有任何函數(shù)可以部分地同時(shí)出現(xiàn)在兩個(gè)父級函數(shù)中一樣。

查找

作用域氣泡的結(jié)構(gòu)和互相之間的位置關(guān)系給引擎提供了足夠的位置信息,引擎用這些信息來查找標(biāo)識符的位置。

在上一個(gè)代碼片段中,引擎執(zhí)行console.log(...)聲明,并查找a、b和三個(gè)變量的引用,它首先從最內(nèi)部的作用域,也就是bar(...)函數(shù)的作用域氣泡開始查找。引擎無法在這里找到a,因此會(huì)去上一級到做嵌套的foo(...)的作用域中繼續(xù)查找。在這里找到了a,因此引擎使用了這個(gè)引用。對于b也是一樣的。而對c來說,引擎在bar(...)中就找到了它。

作用域查找會(huì)在找到第一個(gè)匹配的標(biāo)識符就停止。在多層的嵌套作用域中可以定義同名的標(biāo)識符,這叫做“遮蔽效應(yīng)”(內(nèi)部的標(biāo)識符遮蔽了外部的標(biāo)識符)拋開遮蔽效應(yīng),作用域查找始終從運(yùn)行時(shí)所處的最內(nèi)部作用域開始,逐級向外或者說向上進(jìn)行,直到遇見第一個(gè)匹配的標(biāo)識符為止。

全局變量會(huì)自動(dòng)成為全局對象的屬性,因此可以不直接通過全局對象的詞法名稱,而是間接地通過對全局對象屬性的引用來對其進(jìn)行訪問。

通過這種技術(shù)可以訪問那些被同名變量所遮蔽的全局變量。但是非全局的變量如果被遮蔽了,無論如何都是無法被訪問到。

無論函數(shù)在哪里被調(diào)用。也無論如何被調(diào)用,他的詞法作用域都只是由函數(shù)被聲明時(shí)所處的位置決定。

詞法作用域只會(huì)查找一級標(biāo)識符,比如a、b、c、如果代碼中引用了 foo.bar.baz詞法作用域查找只會(huì)試圖查找foo標(biāo)識符,找到這個(gè)變量之后,對象屬性訪問規(guī)則會(huì)分別接管對bar和baz屬性的訪問。

欺騙詞法

如果詞法作用域完全由寫代碼期間函數(shù)所聲明的位置來決定,怎樣才能在運(yùn)行時(shí)來"修改"(也可以說欺騙)詞法作用域呢?

js中有兩種機(jī)制來實(shí)現(xiàn)這個(gè)目的,社區(qū)普遍認(rèn)為在代碼中使用這兩種機(jī)制并不是什么好主意,但是關(guān)于他們的爭論通常會(huì)忽略掉最重要的點(diǎn):欺騙詞法作用域會(huì)導(dǎo)致性能下降。

eval

js中的eval(...)函數(shù)可以接收一個(gè)字符串為參數(shù),并將其中的內(nèi)容視為好像在書寫的時(shí)候就存在程序中這個(gè)位置的代碼。換句話說,可以在你寫代碼的中用程序生成代碼并運(yùn)行,就好像代碼是寫在那個(gè)位置一樣。

根據(jù)這個(gè)原理來理解 eval(...) 它是如何通過代碼欺騙和假裝成書寫時(shí)(也就是詞法期)代碼就在那里,來實(shí)現(xiàn)修改詞法作用域環(huán)境的,這個(gè)原理就變的清晰易懂了。

在執(zhí)行eval(...)之后的代碼時(shí),引擎并不”知道“或者”在意“前面的代碼時(shí)以動(dòng)態(tài)形式插入進(jìn)來,并對詞法作用域的環(huán)境進(jì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;"這段代碼會(huì)被當(dāng)做本來就在那里一樣來處理。由于那段代碼聲明了一個(gè)新的變量b,因此它對已經(jīng)存在的foo(...)的詞法作用域進(jìn)行了修改。事實(shí)上,和前面所說的原理一樣,這段代碼實(shí)際上在foo(...)內(nèi)部創(chuàng)建了變量b,并這逼了外部(全局)作用域中的同名變量。

當(dāng)console.log(...)被執(zhí)行的時(shí)候,會(huì)在foo(...)的內(nèi)部同事找到a和b 但是永遠(yuǎn)也無法找到外部的b,因此會(huì)輸出”1,3“而不是正常情況下會(huì)輸出”1,2“.

在這個(gè)例子中,為了展示的方便和簡潔,我們傳遞進(jìn)去的”代碼“字符串是固定不變的。而在實(shí)際開發(fā)中,可以非常容易地根據(jù)程序邏輯動(dòng)態(tài)地將字符拼接在一起之后再傳遞進(jìn)去。eval(...)通常被用來執(zhí)行動(dòng)態(tài)創(chuàng)建的代碼,因?yàn)橄窭又羞@樣動(dòng)態(tài)地執(zhí)行一段固定字符所組成的代碼,并沒有比直接將代碼卸載哪里更有好處。

with

Javascript 中的另一個(gè)難以掌握(并且現(xiàn)在也不推薦使用)的用來欺騙詞法作用域的功能是with關(guān)鍵字??梢杂泻芏嗟姆椒▉斫忉寃ith,在這里我選擇一個(gè)角度來解釋它;它如何同被它所影響的詞法作用域進(jìn)行交互。

with 通常被當(dāng)做重復(fù)引用同一個(gè)對象中的多個(gè)屬性的快捷方式,可以不需要重復(fù)引用對象本身。

比如:

var obj = {
  a:1,
  b:2,
  c:3
}
// 單調(diào)乏味的重復(fù) “obj”
obj.a = 2;
obj.b = 3;
obj.c = 4;

// 簡單的快捷方式
with(obj){
  a = 3;
  b = 4;
  c = 5;
}

但是實(shí)際上這不僅僅是為了方便得訪問對象屬性??紤]如下代碼:

  function foo(obj){
    with(obj){
      a = 2;
    }
  }

  var o1 = {
    a:3
  };
  var o2 = {
    b:3
  }
  foo(o1);
  console.log(o1.a);

  foo(o2);
  console.log(o2.a);
  console.log(a); 

在這個(gè)例子中創(chuàng)建了o1和o2兩個(gè)對象。其中一個(gè)具有a屬性,另外一個(gè)沒有。foo(...)
函數(shù)接受obj參數(shù),讓參數(shù)是一個(gè)對象引用,并對這個(gè)對象引用執(zhí)行了with(obj){...}
在with塊內(nèi)部,我們寫的代碼看起來知識對變量a進(jìn)行簡單的詞法引用,實(shí)際上就是一個(gè)LHS引用,并將2賦值給它。

當(dāng)我們將 o1 傳遞進(jìn)去,a=2 賦值操作找到了 o1.a 并將 2 賦值給它,這在后面的 console. log(o1.a) 中可以體現(xiàn)。而當(dāng) o2 傳遞進(jìn)去,o2 并沒有 a 屬性,因此不會(huì)創(chuàng)建這個(gè)屬性, o2.a 保持 undefined。

但是可以注意到一個(gè)奇怪的副作用,實(shí)際上 a = 2 賦值操作創(chuàng)建了一個(gè)全局的變量 a。這 是怎么回事?
with 可以將一個(gè)沒有或有多個(gè)屬性的對象處理為一個(gè)完全隔離的詞法作用域,因此這個(gè)對 象的屬性也會(huì)被處理為定義在這個(gè)作用域中的詞法標(biāo)識符。

性能

eval(..) 和 with 會(huì)在運(yùn)行時(shí)修改或創(chuàng)建新的作用域,以此來欺騙其他在書寫時(shí)定義的詞法作用域。

你可能會(huì)問,那又怎樣呢? 如果它們能實(shí)現(xiàn)更復(fù)雜的功能,并且代碼更具有擴(kuò)展性,難道
不是非常好的功能嗎?答案是否定的。

JavaScript 引擎會(huì)在編譯階段進(jìn)行數(shù)項(xiàng)的性能優(yōu)化。其中有些優(yōu)化依賴于能夠根據(jù)代碼的 詞法進(jìn)行靜態(tài)分析,并預(yù)先確定所有變量和函數(shù)的定義位置,才能在執(zhí)行過程中快速找到 標(biāo)識符。

但如果引擎在代碼中發(fā)現(xiàn)了 eval(..) 或 with,它只能簡單地假設(shè)關(guān)于標(biāo)識符位置的判斷 都是無效的,因?yàn)闊o法在詞法分析階段明確知道 eval(..) 會(huì)接收到什么代碼,這些代碼會(huì) 如何對作用域進(jìn)行修改,也無法知道傳遞給 with 用來創(chuàng)建新詞法作用域的對象的內(nèi)容到底 是什么。

最悲觀的情況是如果出現(xiàn)了 eval(..) 或 with,所有的優(yōu)化可能都是無意義的,因此最簡 單的做法就是完全不做任何優(yōu)化。

如果代碼中大量使用 eval(..) 或 with,那么運(yùn)行起來一定會(huì)變得非常慢。無論引擎多聰 明,試圖將這些悲觀情況的副作用限制在最小范圍內(nèi),也無法避免如果沒有這些優(yōu)化,代 碼會(huì)運(yùn)行得更慢這個(gè)事實(shí)。

總結(jié)

詞法作用域意味著作用域是由書寫代碼時(shí)函數(shù)聲明的位置來決定的。編譯的詞法分析階段 基本能夠知道全部標(biāo)識符在哪里以及是如何聲明的,從而能夠預(yù)測在執(zhí)行過程中如何對它 們進(jìn)行查找。

JavaScript 中有兩個(gè)機(jī)制可以“欺騙”詞法作用域:eval(..) 和 with。前者可以對一段包 含一個(gè)或多個(gè)聲明的“代碼”字符串進(jìn)行演算,并借此來修改已經(jīng)存在的詞法作用域(在 運(yùn)行時(shí))。后者本質(zhì)上是通過將一個(gè)對象的引用當(dāng)作作用域來處理,將對象的屬性當(dāng)作作 用域中的標(biāo)識符來處理,從而創(chuàng)建了一個(gè)新的詞法作用域(同樣是在運(yùn)行時(shí))。

這兩個(gè)機(jī)制的副作用是引擎無法在編譯時(shí)對作用域查找進(jìn)行優(yōu)化,因?yàn)橐嬷荒苤?jǐn)慎地認(rèn) 為這樣的優(yōu)化是無效的。使用這其中任何一個(gè)機(jī)制都將導(dǎo)致代碼運(yùn)行變慢。不要使用它們。

參考《你不知道的JavaScript》上卷

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容