再談JavaScript作用域——你確定你真的知道?

ZheLin發表於2018-04-27

什麼是作用域?

作用域,這個詞在程式設計界經常能聽到看到,每一個程式設計師幾乎都有被問到過。在前端圈,面試JavaScript相關知識,這可以算說是一個非常基礎的問題了。但早年間我長期陷入了一種“只可意會不可言傳”的地步,我不知道是不是有許多小夥伴與我曾經有一樣的經歷,所以我就抽時間把書本中看到的東西整理了一下。把提煉的東西分享給大家,如有不正確之處煩請指正。可能大多對作用域的通用解釋是這種:

作用域就是變數(識別符號)適用範圍,控制著變數的可見性。

但他具體是什麼,是一個區域?還是一種規則呢?

我記得《JavaScript權威指南》中對變數作用域有這麼一段描述:

一個變數的作用域(scope)是程式原始碼中定義這個變數的區域。全域性變數擁有全域性作用域,在JavaScript程式碼中的任何地方都是有定義的。然而在函式內宣告的變數只在函式體內有定義。它們是區域性變數,作用域是區域性性的。函式引數也是區域性變數,它們只是在函式體內有定義。

這段描述大致的告訴了讀者作用域是個啥,可以在這裡理解為一個“區域”。

兩年前我第一次看到這句話還是答不出作用域是啥,雖然已經在腦海裡有一個大致的輪廓。我覺得我應該繼續深究一下,作用域究竟是啥。

什麼是編譯?

要搞清楚作用域是啥,我們需要或多或少的知道一點點JavaScript的編譯原理,從第一次接觸JavaScript開始,我接觸到的所有知識就告訴我,JavaScript是一門“動態”或“解釋執行”語言,但後來我才知道,它實際上是一門編譯語言。是不是很驚訝?與傳統的編譯語言不同的是,JavaScript不是提前編譯的,編譯結果也不能在分散式系統中進行移植。

那麼編譯過程是啥呢?可以分為這麼三步:

  1. 分詞/詞法分析(Tokenizing/Lexing) 這個過程會將由字元組成的字串分解成(對程式語言來說)有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)。例如,考慮程式var a = 2;。這段程式通常會被分解成下面這些詞法單元:var、a、=、2、;。空格是否會被當作詞法單元,取決於空格在這門語言中是否具有意義。
  2. 解析/語法分析(Parsing) 這個過程是將詞法單元流(陣列)轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree, AST在各大框架及Babel中我們都會看到它的身影)。 var a = 2;的抽象語法樹中可能會有一個叫做VariableDeclaration的頂級節點,接下來是一個叫做Identifier(它的值是a)的子節點,以及一個叫做AssignmentExpression的子節點。AssignmentExpression節點有一個叫做NumericLiteral(它的值是2)的子節點。
  3. 程式碼生成 將AST轉換為可執行程式碼的過程被稱為程式碼生成。這個過程與語言、目標平臺等息息相關。 拋開具體細節,簡單來說就是有某種方法可以將var a = 2;的AST轉化為一組機器指令,用來建立一個叫做a的變數(包括分配記憶體等),並將一個值儲存在a中。

當然,對比就這三步的編譯語言來說,JavaScript引擎要複雜的多。但JavaScript的編譯大多發生在程式碼執行前的幾微秒,甚至更短。在我們要討論的作用域背後,JavaScript引擎用盡了各種方法(比如JIT,可以延遲編譯甚至實施重編譯)來保證最佳效能。

理解作用域

在這裡,我們會無數次用到作用域這個詞,你完全可以按照之前的理解來閱讀。這並不影響我們最終對作用域的理解。

還是var a = 2這行程式碼,通過上面的什麼是編譯部分我們可以知道,編譯器首先會將這段程式碼分解成詞法單元,然後將詞法單元解構成一個樹結構(AST),但是當編譯器開始進行程式碼生成時,它對這段程式碼的處理方式會和預期的有所不同。

當我們看到這行程式碼,用虛擬碼進行跟別人進行概括時,可能會這樣去表述:“為一個變數分配記憶體,並將其命名為a,然後將值2儲存到這個變數(記憶體)中。” 然而,這並不完全正確。

事實上編譯器會進行如下操作:

  1. 遇到var a,編譯器會詢問作用域是否已經有一個該名稱的變數存在於同一個作用域的集合中。如果是,編譯器會忽略該宣告,繼續進行編譯;否則它會要求作用域在當前作用域的集合中宣告一個新的變數,並命名為a。
  2. 接下來編譯器會為引擎生成執行所需的程式碼,這些程式碼被用來處理a = 2這個賦值操作。引擎執行時會首先詢問作用域,在當前的作用域集合中,是否存在一個叫作a的變數,如果是,引擎就會使用這個變數;如果否,引擎就會繼續查詢該變數。

總結起來就是:1、編譯器在作用域宣告變數(如果沒有);2、引擎在執行這些程式碼時查詢該變數,如果有就進行賦值;

在上面的第二步中,引擎執行“執行時所需的程式碼”時,會通過查詢變數a來判斷它是否已經宣告過。查詢的過程由作用域進行協助,但時引擎執行怎麼查詢,會影響最終的查詢結果。

還是var a = 2;這個例子,引擎會為變數a進行LHS查詢。當然還有一種RHS查詢。那麼LHS和RHS查詢是什麼呢?這裡的L代表左側,R代表右側。通俗且不嚴謹的解釋LHS和RHS的含義就是:當變數出現在賦值操作的左側時進行LHS查詢,出現在右側時進行RHS查詢。

那麼描述的更準確的一點,RHS查詢與簡單的查詢某個變數的值毫無二致,而LHS查詢則是試圖找到變數的容器本身,從而可以對其賦值。從這個角度說,RHS並不是真正意義上的“賦值操作的右側”,更準確的說是“非左側”。所以,我們可以將RHS理解成Retrieve his source value(取到它的源值),這意味著,“得到某某的值”。

那我們來看一段程式碼深入理解一下LHS與RHS。

function foo(a) {
  console.log(a)
}

foo(2)
複製程式碼

從這段程式碼中,我們先看看: console.log(a)

其中a的引用是一個RHS引用,因為我們是取到a的值。並將這個值傳遞給console.log(...)方法。

相比之下,例如: a = 2 // 呼叫foo(2)時,隱式的進行了賦值操作 這裡對a的引用就是LHS引用,因為我們實際上不關心當前的值時什麼,只要想把=2這個賦值操作找到一個目標。

當然上面的程式並不只有一個LHS和RHS引用:

function foo(a) {
  // 這裡隱式的進行了對形參a的LHS引用。
  
  // 這裡對log()方法進行了RHS引用,詢問console物件上是否有log()方法。
  // 對log(a)方法內的a進行RHS引用,取到a的值。
  console.log(a)
}

// 此處呼叫foo()方法,需要呼叫對foo的RHS引用。意味著“去找foo這個值,並把它給我。”
foo(2)
複製程式碼

需要注意的是:我們經常會將函式宣告function foo(a) {...} 轉化為普通的變數賦值 var foo = function(a) {...},這樣去理解的話,這個函式是LHS查詢。但是有一個細微的差別,編譯器可以在程式碼生成的同時處理宣告和值的定義,比如引擎執行程式碼時,並不會有執行緒專門用來將一個函式值“分配給”foo,因此,將函式宣告理解成前面討論的LHS查詢和賦值的形式並不合適。

到這裡,是否對作用域的工作有了一個理解呢?但是它是什麼,還是有些模糊,不知道該怎麼去表述。先不管,先看看什麼是作用域鏈。

作用域鏈

問道作用域,跑不掉的就是作用域鏈了,我們來看一個程式碼例子:

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

foo(2); // 4
複製程式碼

通過上面我們得知,對b的RHS引用無法在函式內部完成的,因為函式內部並沒有定義b,但是在這個例子中,我們可以在上一級作用域(這裡是全域性作用域)中完成。

那麼這個查詢規則就很簡單了:引擎從當前的執行作用域開始查詢變數,如果找不到,就像上一級繼續查詢,當抵達最外層的全域性作用域時,無論找沒找到,查詢都會停止。那麼這麼一個自上而下的查詢關係,是一個鏈式的查詢關係。

那麼沒找到會發生什麼呢?進行RHS引用時,如果RHS查詢所有的巢狀的作用域中遍尋不到所需的變數,引擎就會丟擲一個ReferenceError的異常。

相較之下,當引擎執行LHS查詢時,如果全域性作用域下都無法找到目標變數,全域性作用域中就會建立一個具有該名稱的變數,並返回給引擎。前提是該程式執行在非“嚴格模式”下。反之則會丟擲ReferenceError異常。

那麼在寫程式碼過程中,ReferenceError異常與作用域判別失敗相關,而TypeError則代表著作用域判別成功,但是對結果的操作時不合法的。

總結

所以,寫到這裡,對作用域是幹什麼的有了一個比較清晰的理解呢? 好,我來試著重新表述一下什麼是作用域:

作用域是一套“識別符號的查詢規則”(注意我這裡用的詞是規則),根據查詢的目的進行LHS與RHS查詢。確定了在何處(當前作用域、上級作用域...全域性作用域)如何查詢(LHS、RHS)。

當然,這篇文章也釋出在我的個人部落格《再談JavaScript作用域》,有興趣的小夥伴可以看一看。

參考文獻

Flanagan. JavaScript權威指南[M]. 北京:機械工業出版社, 2012.
Kyle Simpson. 你不知道的JavaScript[M]. 北京:人民郵電出版社, 2015.

相關文章