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