第一章:什么是作用域?

特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS

幾乎所有語言的最基礎模型之一就是在變量中存儲值,并且在稍后取出或修改這些值的能力。事實上,在變量中存儲值和取出值的能力,給程序賦予了 狀態

如果沒有這樣的概念,一個程序雖然可以執行一些任務,但是它們將會受到極大的限制而且不會非常有趣。

但是在我們的程序中納入變量,引出了我們現在將要解決的最有趣的問題:這些變量 存活 在哪里?換句話說,它們被存儲在哪兒?而且,最重要的是,我們的程序如何在需要它們的時候找到它們?

回答這些問題需要一組明確定義的規則,它定義如何在某些位置存儲變量,以及如何在稍后找到這些變量。我們稱這組規則為:作用域

但是,這些 作用域 規則是在哪里、如何被設置的?

編譯器理論

根據你與各種編程語言打交道的水平不同,這也許是不證自明的,或者這也許令人吃驚,盡管 JavaScript 一般被劃分到“動態”或者“解釋型”語言的范疇,但是其實它是一個編譯型語言。它 不是 像許多傳統意義上的編譯型語言那樣預先被編譯好,編譯的結果也不能在各種不同的分布式系統間移植。

但是無論如何,JavaScript 引擎在實施許多與傳統的語言編譯器相同的步驟,雖然是以一種我們不易察覺的更精巧的方式。

在傳統的編譯型語言處理中,一塊兒源代碼,你的程序,在它被執行 之前 通常將會經歷三個步驟,大致被稱為“編譯”:

  1. 分詞/詞法分析: 將一連串字符打斷成(對于語言來說)有意義的片段,稱為 token(記號)。舉例來說,考慮這段程序:var a = 2;。這段程序很可能會被打斷成如下 token:vara=2,和 ;。空格也許會被保留為一個 token,這要看它是否是有意義的。

    注意: 分詞和詞法分析之間的區別是微妙和學術上的,其中心在于這些 token 是否以 無狀態有狀態 的方式被識別。簡而言之,如果分詞器去調用有狀態的解析規則來弄清a是否應當被考慮為一個不同的 token,還是只是其他 token 的一部分,那么這就是 詞法分析

  2. 解析: 將一個 token 的流(數組)轉換為一個嵌套元素的樹,它綜合地表示了程序的語法結構。這棵樹稱為“抽象語法樹”(AST —— <b>A</b>bstract <b>S</b>yntax <b>T</b>ree)。

    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 的意思是“去取……的值”。

讓我們挖掘得更深一些。

當我說:

console.log( a );

這個指向 a 的引用是一個 RHS 引用,因為這里沒有東西被賦值給 a。而是我們在查詢 a 并取得它的值,這樣這個值可以被傳遞進 console.log(..)

作為對比:

a = 2;

這里指向 a 的引用是一個 LHS 引用,因為我們實際上不關心當前的值是什么,我們只是想找到這個變量,將它作為 = 2 賦值操作的目標。

注意: LHS 和 RHS 意味著“賦值的左/右手邊”未必像字面上那樣意味著“ = 賦值操作符的左/右邊”。賦值有幾種其他的發生形式,所以最好在概念上將它考慮為:“賦值的目標(LHS)”和“賦值的源(RHS)”。

考慮這段程序,它既有 LHS 引用又有 RHS 引用:

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 foofoo = function(a){...。這樣做會誘使你認為函數聲明涉及了一次 LHS 查詢。

然而,一個微妙但重要的不同是,在這種情況下 編譯器 在代碼生成期間同時處理聲明和值的定義,如此當 引擎 執行代碼時,沒有必要將一個函數值“賦予” foo。因此,將函數聲明考慮為一個我們在這里討論的 LHS 查詢賦值是不太合適的。

引擎/作用域對話

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(..)

...

小測驗

檢查你到目前為止的理解。確保你扮演 引擎,并與 作用域 “對話”:

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo( 2 );
  1. 找到所有的 LHS 查詢(有3處!)。

  2. 找到所有的 RHS 查詢(有4處!)。

注意: 小測驗答案參見本章的復習部分!

嵌套的作用域

我們說過 作用域 是通過標識符名稱查詢變量的一組規則。但是,通常會有多于一個的 作用域 需要考慮。

就像一個代碼塊兒或函數被嵌套在另一個代碼塊兒或函數中一樣,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一個變量的話,引擎 就會咨詢下一個外層作用域,如此繼續直到找到這個變量或者到達最外層作用域(也就是全局作用域)。

考慮這段代碼:

function foo(a) {
    console.log( a + b );
}

var b = 2;

foo( 2 ); // 4

b 的 RHS 引用不能在函數 foo 的內部被解析,但是可以在它的外圍 作用域(這個例子中是全局作用域)中解析。

所以,重返 引擎作用域 的對話,我們會聽到:

引擎:“嘿,foo作用域,聽說過 b 嗎?我得到一個它的 RHS 引用。”

作用域:“沒有,從沒聽說過。問問別人吧。”

引擎:“嘿,foo 外面的 作用域,哦,你是全局 作用域,好吧,酷。聽說過 b 嗎?我得到一個它的 RHS 引用。”

作用域:“是的,當然有。給你。”

遍歷嵌套 作用域 的簡單規則:引擎 從當前執行的 作用域 開始,在那里查找變量,如果沒有找到,就向上走一級繼續查找,如此類推。如果到了最外層的全局作用域,那么查找就會停止,無論它是否找到了變量。

建筑的隱喻

為了將嵌套 作用域 解析的過程可視化,我想讓你考慮一下這個高層建筑。

這個建筑物表示我們程序的嵌套 作用域 規則集合。無論你在哪里,建筑的第一層表示你當前執行的 作用域。建筑的頂層表示全局 作用域

你通過在你當前的樓層中查找來解析 LHS 和 RHS 引用,如果你沒有找到它,就坐電梯到上一層樓,在那里尋找,然后再上一層,如此類推。一旦你到了頂層(全局 作用域),你要么找到了你想要的東西,要么沒有。但是不管怎樣你都不得不停止了。

錯誤

為什么我們區別 LHS 和 RHS 那么重要?

因為在變量還沒有被聲明(在所有被查詢的 作用域 中都沒找到)的情況下,這兩種類型的查詢的行為不同。

考慮如下代碼:

function foo(a) {
    console.log( a + b );
    b = a;
}

foo( 2 );

b 的 RHS 查詢第一次發生時,它是找不到的。它被說成是一個“未聲明”的變量,因為它在作用域中找不到。

如果 RHS 查詢在嵌套的 作用域 的任何地方都找不到一個值,這會導致 引擎 拋出一個 ReferenceError。必須要注意的是這個錯誤的類型是 ReferenceError

相比之下,如果 引擎 在進行一個 LHS 查詢,但到達了頂層(全局 作用域)都沒有找到它,而且如果程序沒有運行在“Strict模式”[1]下,那么這個全局 作用域 將會在 全局作用域中 創建一個同名的新變量,并把它交還給 引擎

“不,之前沒有這樣的東西,但是我可以幫忙給你創建一個。”

在 ES5 中被加入的“Strict模式”[1],有許多與一般/寬松/懶惰模式不同的行為。其中之一就是不允許自動/隱含的全局變量創建。在這種情況下,將不會有全局 作用域 的變量交回給 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模式”[1]),或者一個 ReferenceError(如果是“Strict模式”[1])。

小測驗答案

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


  1. MDN: Strict Mode ? ? ? ?

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容