特別說明,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS
幾乎所有語言的最基礎(chǔ)模型之一就是在變量中存儲值,并且在稍后取出或修改這些值的能力。事實(shí)上,在變量中存儲值和取出值的能力,給程序賦予了 狀態(tài)。
如果沒有這樣的概念,一個程序雖然可以執(zhí)行一些任務(wù),但是它們將會受到極大的限制而且不會非常有趣。
但是在我們的程序中納入變量,引出了我們現(xiàn)在將要解決的最有趣的問題:這些變量 存活 在哪里?換句話說,它們被存儲在哪兒?而且,最重要的是,我們的程序如何在需要它們的時候找到它們?
回答這些問題需要一組明確定義的規(guī)則,它定義如何在某些位置存儲變量,以及如何在稍后找到這些變量。我們稱這組規(guī)則為:作用域。
但是,這些 作用域 規(guī)則是在哪里、如何被設(shè)置的?
編譯器理論
根據(jù)你與各種編程語言打交道的水平不同,這也許是不證自明的,或者這也許令人吃驚,盡管 JavaScript 一般被劃分到“動態(tài)”或者“解釋型”語言的范疇,但是其實(shí)它是一個編譯型語言。它 不是 像許多傳統(tǒng)意義上的編譯型語言那樣預(yù)先被編譯好,編譯的結(jié)果也不能在各種不同的分布式系統(tǒng)間移植。
但是無論如何,JavaScript 引擎在實(shí)施許多與傳統(tǒng)的語言編譯器相同的步驟,雖然是以一種我們不易察覺的更精巧的方式。
在傳統(tǒng)的編譯型語言處理中,一塊兒源代碼,你的程序,在它被執(zhí)行 之前 通常將會經(jīng)歷三個步驟,大致被稱為“編譯”:
-
分詞/詞法分析: 將一連串字符打斷成(對于語言來說)有意義的片段,稱為 token(記號)。舉例來說,考慮這段程序:
var a = 2;。這段程序很可能會被打斷成如下 token:var,a,=,2,和;??崭褚苍S會被保留為一個 token,這要看它是否是有意義的。注意: 分詞和詞法分析之間的區(qū)別是微妙和學(xué)術(shù)上的,其中心在于這些 token 是否以 無狀態(tài) 或 有狀態(tài) 的方式被識別。簡而言之,如果分詞器去調(diào)用有狀態(tài)的解析規(guī)則來弄清
a是否應(yīng)當(dāng)被考慮為一個不同的 token,還是只是其他 token 的一部分,那么這就是 詞法分析。 -
解析: 將一個 token 的流(數(shù)組)轉(zhuǎn)換為一個嵌套元素的樹,它綜合地表示了程序的語法結(jié)構(gòu)。這棵樹稱為“抽象語法樹”(AST —— <b>A</b>bstract <b>S</b>yntax <b>T</b>ree)。
var a = 2;的樹也許開始于稱為VariableDeclaration(變量聲明)頂層節(jié)點(diǎn),帶有一個稱為Identifier(標(biāo)識符)的子節(jié)點(diǎn)(它的值為a),和另一個稱為AssignmentExpression(賦值表達(dá)式)的子節(jié)點(diǎn),而這個子節(jié)點(diǎn)本身帶有一個稱為NumericLiteral(數(shù)字字面量)的子節(jié)點(diǎn)(它的值為2)。 -
代碼生成: 這個處理將抽象語法樹轉(zhuǎn)換為可執(zhí)行的代碼。這一部分將根據(jù)語言,它的目標(biāo)平臺等因素有很大的不同。
所以,與其深陷細(xì)節(jié),我們不如籠統(tǒng)地說,有一種方法將我們上面描述的
var a = 2;的抽象語法樹轉(zhuǎn)換為機(jī)器指令,來實(shí)際上 創(chuàng)建 一個稱為a的變量(包括分配內(nèi)存等等),然后在a中存入一個值。注意: 引擎如何管理系統(tǒng)資源的細(xì)節(jié)遠(yuǎn)比我們要挖掘的東西深刻,所以我們將理所當(dāng)然地認(rèn)為引擎有能力按其需要創(chuàng)建和存儲變量。
和大多數(shù)其他語言的編譯器一樣,JavaScript 引擎要比這區(qū)區(qū)三步復(fù)雜太多了。例如,在解析和代碼生成的處理中,一定會存在優(yōu)化執(zhí)行效率的步驟,包括壓縮冗余元素,等等。
所以,我在此描繪的只是大框架。但是我想你很快就會明白為什么我們涵蓋的這些細(xì)節(jié)是重要的,雖然是在很高的層次上。
其一,JavaScript 引擎沒有(像其他語言的編譯器那樣)大把的時間去優(yōu)化,因?yàn)?JavaScript 的編譯和其他語言不同,不是提前發(fā)生在一個構(gòu)建的步驟中。
對 JavaScript 來說,在許多情況下,編譯發(fā)生在代碼被執(zhí)行前的僅僅幾微秒之內(nèi)(或更少?。榱舜_保最快的性能,JS 引擎將使用所有的招數(shù)(比如 JIT,它可以懶編譯甚至是熱編譯,等等),而這遠(yuǎn)超出了我們關(guān)于“作用域”的討論。
為了簡單起見,我們可以說,任何 JavaScript 代碼段在它執(zhí)行之前(通常是 剛好 在它執(zhí)行之前?。┒急仨毐痪幾g。所以,JS 編譯器將把程序 var a = 2; 拿過來,并首先編譯它,然后準(zhǔn)備運(yùn)行它,通常是立即的。
理解作用域
我們將采用的學(xué)習(xí)作用域的方法,是將這個處理過程想象為一場對話。但是,誰 在進(jìn)行這場對話呢?
演員
讓我們見一見處理程序 var a = 2; 時進(jìn)行互動的演員吧,這樣我們就能理解稍后將要聽到的它們的對話:
引擎:負(fù)責(zé)從始至終的編譯和執(zhí)行我們的 JavaScript 程序。
編譯器:引擎 的朋友之一;處理所有的解析和代碼生成的重活兒(見前一節(jié))。
作用域:引擎 的另一個朋友;收集并維護(hù)一張所有被聲明的標(biāo)識符(變量)的列表,并對當(dāng)前執(zhí)行中的代碼如何訪問這些變量強(qiáng)制實(shí)施一組嚴(yán)格的規(guī)則。
為了 全面理解 JavaScript 是如何工作的,你需要開始像 引擎(和它的朋友們)那樣 思考,問它們問的問題,并像它們一樣回答。
反復(fù)
當(dāng)你看到程序 var a = 2; 時,你很可能認(rèn)為它是一個語句。但這不是我們的新朋友 引擎 所看到的。事實(shí)上,引擎 看到兩個不同的語句,一個是 編譯器 將在編譯期間處理的,一個是 引擎 將在執(zhí)行期間處理的。
那么,讓我們來分析 引擎 和它的朋友們將如何處理程序 var a = 2;。
編譯器 將對這個程序做的第一件事情,是進(jìn)行詞法分析來將它分解為一系列 token,然后這些 token 被解析為一棵樹。但是當(dāng) 編譯器 到了代碼生成階段時,它會以一種與我們可能想象的不同的方式來對待這段程序。
一個合理的假設(shè)是,編譯器 將產(chǎn)生的代碼可以用這種假想代碼概括:“為一個變量分配內(nèi)存,將它標(biāo)記為 a,然后將值 2 貼在這個變量里”。不幸的是,這不是十分準(zhǔn)確。
編譯器 將會這樣處理:
遇到
var a,編譯器 讓 作用域 去查看對于這個特定的作用域集合,變量a是否已經(jīng)存在了。如果是,編譯器 就忽略這個聲明并繼續(xù)前進(jìn)。否則,編譯器 就讓 作用域 去為這個作用域集合聲明一個稱為a的新變量。然后 編譯器 為 引擎 生成稍后要執(zhí)行的代碼,來處理賦值
a = 2。引擎 運(yùn)行的代碼首先讓 作用域 去查看在當(dāng)前的作用域集合中是否有一個稱為a的變量可以訪問。如果有,引擎 就使用這個變量。如果沒有,引擎 就查看 其他地方(參見下面的嵌套 作用域 一節(jié))。
如果 引擎 最終找到一個變量,它就將值 2 賦予它。如果沒有,引擎 將會舉起它的手并喊出一個錯誤!
總結(jié)來說:對于一個變量賦值,發(fā)生了兩個不同的動作:第一,編譯器 聲明一個變量(如果先前沒有在當(dāng)前作用域中聲明過),第二,當(dāng)執(zhí)行時,引擎 在 作用域 中查詢這個變量并給它賦值,如果找到的話。
編譯器術(shù)語
為了繼續(xù)更深入地理解,我們需要一點(diǎn)兒更多的編譯器術(shù)語。
當(dāng) 引擎 執(zhí)行 編譯器 在第二步為它產(chǎn)生的代碼時,它必須查詢變量 a 來看它是否已經(jīng)被聲明過了,而且這個查詢是咨詢 作用域 的。但是 引擎 所實(shí)施的查詢的類型會影響查詢的結(jié)果。
在我們這個例子中,引擎 將會對變量 a 實(shí)施一個“LHS”查詢。另一種類型的查詢稱為“RHS”。
我打賭你能猜出“L”和“R”是什么意思。這兩個術(shù)語表示“Left-hand Side(左手邊)”和“Right-hand Side(右手邊)”
什么的……邊?賦值操作的。
換言之,當(dāng)一個變量出現(xiàn)在賦值操作的左手邊時,會進(jìn)行 LHS 查詢,當(dāng)一個變量出現(xiàn)在賦值操作的右手邊時,會進(jìn)行 RHS 查詢。
實(shí)際上,我們可以表述得更準(zhǔn)確一點(diǎn)兒。對于我們的目的來說,一個 RHS 是難以察覺的,因?yàn)樗唵蔚夭樵兡硞€變量的值,而 LHS 查詢是試著找到變量容器本身,以便它可以賦值。從這種意義上說,RHS 的含義實(shí)質(zhì)上不是 真正的 “一個賦值的右手邊”,更準(zhǔn)確地說,它只是意味著“不是左手邊”。
在這一番油腔滑調(diào)之后,你也可以認(rèn)為“RHS”意味著“取得他/她的源(值)”,暗示著 RHS 的意思是“去取……的值”。
讓我們挖掘得更深一些。
當(dāng)我說:
console.log( a );
這個指向 a 的引用是一個 RHS 引用,因?yàn)檫@里沒有東西被賦值給 a。而是我們在查詢 a 并取得它的值,這樣這個值可以被傳遞進(jìn) console.log(..)。
作為對比:
a = 2;
這里指向 a 的引用是一個 LHS 引用,因?yàn)槲覀儗?shí)際上不關(guān)心當(dāng)前的值是什么,我們只是想找到這個變量,將它作為 = 2 賦值操作的目標(biāo)。
注意: LHS 和 RHS 意味著“賦值的左/右手邊”未必像字面上那樣意味著“ = 賦值操作符的左/右邊”。賦值有幾種其他的發(fā)生形式,所以最好在概念上將它考慮為:“賦值的目標(biāo)(LHS)”和“賦值的源(RHS)”。
考慮這段程序,它既有 LHS 引用又有 RHS 引用:
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
調(diào)用 foo(..) 的最后一行作為一個函數(shù)調(diào)用要求一個指向 foo 的 RHS 引用,意味著,“去查詢 foo 的值,并把它交給我”。另外,(..) 意味著 foo 的值應(yīng)當(dāng)被執(zhí)行,所以它最好實(shí)際上是一個函數(shù)!
這里有一個微妙但重要的賦值。你發(fā)現(xiàn)了嗎?
你可能錯過了這個代碼段隱含的 a = 2。它發(fā)生在當(dāng)值 2 作為參數(shù)值傳遞給 foo(..) 函數(shù)時,值 2 被賦值 給了參數(shù) a。為了(隱含地)給參數(shù) a 賦值,進(jìn)行了一個 LHS 查詢。
這里還有一個 a 的值的 RHS 引用,它的結(jié)果值被傳入 console.log(..)。console.log(..) 需要一個引用來執(zhí)行。它為 console 對象進(jìn)行一個 RHS 查詢,然后發(fā)生一個屬性解析來看它是否擁有一個稱為 log 的方法。
最后,我們可以將這一過程概念化為,在將值 2(通過變量 a 的 RHS 查詢得到的)傳入 log(..) 時發(fā)生了一次 LHS/RHS 的交換。在 log(..) 的原生實(shí)現(xiàn)內(nèi)部,我們可以假定它擁有參數(shù),其中的第一個(也許被稱為 arg1)在 2 被賦值給它之前,進(jìn)行了一次 LHS 引用查詢。
注意: 你可能會試圖將函數(shù)聲明 function foo(a) {... 概念化為一個普通的變量聲明和賦值,比如 var foo 和 foo = function(a){...。這樣做會誘使你認(rèn)為函數(shù)聲明涉及了一次 LHS 查詢。
然而,一個微妙但重要的不同是,在這種情況下 編譯器 在代碼生成期間同時處理聲明和值的定義,如此當(dāng) 引擎 執(zhí)行代碼時,沒有必要將一個函數(shù)值“賦予” foo。因此,將函數(shù)聲明考慮為一個我們在這里討論的 LHS 查詢賦值是不太合適的。
引擎/作用域?qū)υ?/h3>
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
讓我們將上面的(處理這個代碼段的)交互想象為一場對話。這場對話將會有點(diǎn)兒像這樣進(jìn)行:
引擎:嘿 作用域,我有一個
foo的 RHS 引用。聽說過它嗎?
作用域;啊,是的,聽說過。編譯器 剛在一秒鐘之前聲明了它。它是一個函數(shù)。給你。
引擎:太棒了,謝謝!好的,我要執(zhí)行
foo了。
引擎:嘿,作用域,我得到了一個
a的 LHS 引用,聽說過它嗎?
作用域:啊,是的,聽說過。編譯器 剛才將它聲明為
foo的一個正式參數(shù)了。給你。
引擎:一如既往的給力,作用域。再次感謝你?,F(xiàn)在,該把
2賦值給a了。
引擎:嘿,作用域,很抱歉又一次打擾你。我需要 RHS 查詢
console。聽說過它嗎?
作用域:沒關(guān)系,引擎,這是我一天到晚的工作。是的,我得到
console了。它是一個內(nèi)建對象。給你。
引擎:完美。查找
log(..)。好的,很好,它是一個函數(shù)。
引擎:嘿,作用域。你能幫我查一下
a的 RHS 引用嗎?我想我記得它,但只是想再次確認(rèn)一下。
作用域:你是對的,引擎。同一個家伙,沒變。給你。
引擎:酷。傳遞
a的值,也就是2,給log(..)。
...
小測驗(yàn)
檢查你到目前為止的理解。確保你扮演 引擎,并與 作用域 “對話”:
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
找到所有的 LHS 查詢(有3處?。?/p>
找到所有的 RHS 查詢(有4處?。?/p>
注意: 小測驗(yàn)答案參見本章的復(fù)習(xí)部分!
嵌套的作用域
我們說過 作用域 是通過標(biāo)識符名稱查詢變量的一組規(guī)則。但是,通常會有多于一個的 作用域 需要考慮。
就像一個代碼塊兒或函數(shù)被嵌套在另一個代碼塊兒或函數(shù)中一樣,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一個變量的話,引擎 就會咨詢下一個外層作用域,如此繼續(xù)直到找到這個變量或者到達(dá)最外層作用域(也就是全局作用域)。
考慮這段代碼:
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
b 的 RHS 引用不能在函數(shù) foo 的內(nèi)部被解析,但是可以在它的外圍 作用域(這個例子中是全局作用域)中解析。
所以,重返 引擎 和 作用域 的對話,我們會聽到:
引擎:“嘿,
foo的 作用域,聽說過b嗎?我得到一個它的 RHS 引用?!?/p>
作用域:“沒有,從沒聽說過。問問別人吧。”
引擎:“嘿,
foo外面的 作用域,哦,你是全局 作用域,好吧,酷。聽說過b嗎?我得到一個它的 RHS 引用。”
作用域:“是的,當(dāng)然有。給你?!?/p>
遍歷嵌套 作用域 的簡單規(guī)則:引擎 從當(dāng)前執(zhí)行的 作用域 開始,在那里查找變量,如果沒有找到,就向上走一級繼續(xù)查找,如此類推。如果到了最外層的全局作用域,那么查找就會停止,無論它是否找到了變量。
建筑的隱喻
為了將嵌套 作用域 解析的過程可視化,我想讓你考慮一下這個高層建筑。
這個建筑物表示我們程序的嵌套 作用域 規(guī)則集合。無論你在哪里,建筑的第一層表示你當(dāng)前執(zhí)行的 作用域。建筑的頂層表示全局 作用域。
你通過在你當(dāng)前的樓層中查找來解析 LHS 和 RHS 引用,如果你沒有找到它,就坐電梯到上一層樓,在那里尋找,然后再上一層,如此類推。一旦你到了頂層(全局 作用域),你要么找到了你想要的東西,要么沒有。但是不管怎樣你都不得不停止了。
錯誤
為什么我們區(qū)別 LHS 和 RHS 那么重要?
因?yàn)樵谧兞窟€沒有被聲明(在所有被查詢的 作用域 中都沒找到)的情況下,這兩種類型的查詢的行為不同。
考慮如下代碼:
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );
當(dāng) b 的 RHS 查詢第一次發(fā)生時,它是找不到的。它被說成是一個“未聲明”的變量,因?yàn)樗谧饔糜蛑姓也坏健?/p>
如果 RHS 查詢在嵌套的 作用域 的任何地方都找不到一個值,這會導(dǎo)致 引擎 拋出一個 ReferenceError。必須要注意的是這個錯誤的類型是 ReferenceError。
相比之下,如果 引擎 在進(jìn)行一個 LHS 查詢,但到達(dá)了頂層(全局 作用域)都沒有找到它,而且如果程序沒有運(yùn)行在“Strict模式”[1]下,那么這個全局 作用域 將會在 全局作用域中 創(chuàng)建一個同名的新變量,并把它交還給 引擎。
“不,之前沒有這樣的東西,但是我可以幫忙給你創(chuàng)建一個?!?/em>
在 ES5 中被加入的“Strict模式”[1],有許多與一般/寬松/懶惰模式不同的行為。其中之一就是不允許自動/隱含的全局變量創(chuàng)建。在這種情況下,將不會有全局 作用域 的變量交回給 LHS 查詢,并且類似于 RHS 的情況, 引擎 將拋出一個 ReferenceError。
現(xiàn)在,如果一個 RHS 查詢的變量被找到了,但是你試著去做一些這個值不可能做到的事,比如將一個非函數(shù)的值作為函數(shù)運(yùn)行,或者引用 null 或者 undefined 值的屬性,那么 引擎 就會拋出一個不同種類的錯誤,稱為 TypeError。
ReferenceError 是關(guān)于 作用域 解析失敗的,而 TypeError 暗示著 作用域 解析成功了,但是試圖對這個結(jié)果進(jìn)行了一個非法/不可能的動作。
復(fù)習(xí)
作用域是一組規(guī)則,它決定了一個變量(標(biāo)識符)在哪里和如何被查找。這種查詢也許是為了向這個變量賦值,這時變量是一個 LHS(左手邊)引用,或者是為取得它的值,這時變量是一個 RHS(右手邊)引用。
LHS 引用得自賦值操作。作用域 相關(guān)的賦值可以通過 = 操作符發(fā)生,也可以通過向函數(shù)參數(shù)傳遞(賦予)參數(shù)值發(fā)生。
JavaScript 引擎 在執(zhí)行代碼之前首先會編譯它,因此,它將 var a = 2; 這樣的語句分割為兩個分離的步驟:
首先,
var a在當(dāng)前 作用域 中聲明。這是在最開始,代碼執(zhí)行之前實(shí)施的。稍后,
a = 2查找這個變量(LHS 引用),并且如果找到就向它賦值。
LHS 和 RHS 引用查詢都從當(dāng)前執(zhí)行中的 作用域 開始,如果有需要(也就是,它們在這里沒能找到它們要找的東西),它們會在嵌套的 作用域 中一路向上,一次一個作用域(層)地查找這個標(biāo)識符,直到它們到達(dá)全局作用域(頂層)并停止,既可能找到也可能沒找到。
未被滿足的 RHS 引用會導(dǎo)致 ReferenceError 被拋出。未被滿足的 LHS 引用會導(dǎo)致一個自動的,隱含地創(chuàng)建的同名全局變量(如果不是“Strict模式”[1]),或者一個 ReferenceError(如果是“Strict模式”[1])。
小測驗(yàn)答案
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
-
找出所有的 LHS 查詢(有3處?。?/p>
c = ..,a = 2(隱含的參數(shù)賦值)和b = .. -
找出所有的 RHS 查詢(有4處?。?。
foo(2..,= a;,a + ..和.. + b
-
MDN: Strict Mode ? ? ? ?