在學習 javascript 的過程中,我們第一步最應該瞭解和掌握的就是作用域,與之相關還有程式是怎麼編譯的,變數是怎麼查詢的,js 引擎是什麼,引擎和作用域的關係又是什麼,這些是 javascript 這門語言最基礎的地基,至於物件、函式、閉包、原型鏈、作用域鏈以及設計模式等等都是地基以上的建築,只有地基打牢了,建築才會穩。同樣只有先把最基礎的部分掌握了,之後的擴充套件學習才會更容易。
這一節我要說的,就是作用域和編譯原理,從這裡開始,我會一點點的把深入學習 javascript 的過程中總結的知識點以及遇到的問題,一篇一篇的梳理出來,如果有志同道合的朋友,可以關注我這個系列,我們一起玩轉 javascript。
1. 編譯原理
大家通常把 javascript 歸類為一種“動態”或“解釋執行”的語言,但事實上,它是一門編譯語言,但和傳統的編譯語言不同,它不是提前編譯的,編譯結果也不能進行移植。
在傳統編譯語言中,程式在執行之前會經歷三個步驟,統稱為“編譯”:
- 分詞/詞法分析 這個過程會把字串分解成有意義的程式碼塊,這些程式碼塊被稱為詞法單元。 例如 var a = 5; 這段程式通常會被分解成下面這些詞法單元: var、a、=、5、; 。空格是否會被當成詞法單元取決於空格在這門語言中是否有意義。
- 解析/語法分析 這個過程是將詞法單元流(陣列)轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。 var a = 5; 的抽象語法樹中可能如下圖所示:
- 程式碼生成 將 AST 轉換為可執行程式碼的過程被稱為程式碼生成。這個過程與語言、目標平臺等息息相關。簡單來說,就是通過某種方法可以將 var a = 5; 的 AST 轉化為一組機器指令,用來建立一個叫做 a 的變數(包括分配記憶體等),並將一個值 5 儲存在 a 中。
比起那些編譯過程只有三個步驟的語言的編譯器來說,javascript 引擎要複雜的多。 例如,在詞法分析和程式碼生成階段有特定的步驟來對執行效能進行優化,包括對冗餘元素進行優化等。
首先我們要清楚,javaScript 引擎不會有太多的時間來進行優化(相對於其它語言的編譯器來說),因為與其它語言不同,javascript 的編譯過程不是發生在構建之前的。
對於 javascript 來說,大部分情況下編譯發生在程式碼執行前的幾微秒(甚至更短)的時間內。在我們將要討論的作用域背後,javascript 引擎用盡了各種辦法(比如 JIT,可以延遲編譯甚至重新編譯)來保證效能最佳。
總結來說,任何 javascript 程式碼片段在執行前都要進行編譯(預編譯)。因此,javascript 編譯器首先會對 var a = 5; 這段程式進行編譯,然後做好執行它的準備,並且通常馬上就會執行它。
2. 三位好友
要真正理解作用域,我們首先要知道 javascript 中有三位好朋友:
- 引擎 從頭到尾負責整個 javascript 程式的編譯及執行過程。
- 編譯器 負責語法分析及程式碼生成。
- 作用域 負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。
當遇見 var a = 5; 這一段程式碼時,其實執行了兩個步驟:
(1)var a; 編譯器會詢問作用域是否已經有一個該名稱的變數存在於同一作用域的集合中。如果是,編譯器會忽略該宣告,繼續進行編譯,否則它會要求在當前作用域的集合中宣告一個新的變數,並命名為 a 。 (2)a = 5; 編譯器會為引擎生成執行時所需的程式碼,這些程式碼用來處理 a = 5; 這個賦值操作。引擎執行時會首先詢問作用域,在當前作用域的集合中是否存在一個叫作 a 的變數,如果是,引擎就會使用這個變數。如果否,引擎會繼續向父級作用域中查詢,直到找到全域性作用域,如果在全域性作用域中仍沒有找到 a ,那麼在非嚴格模式下,引擎會為全域性物件新建一個屬性 a ,並將其賦值為5,在嚴格模式下,引擎會報錯誤 ReferenceError: a is not defined。
總結來說,變數的賦值會執行兩個操作,首先編譯器會在當前作用域宣告一個變數(如果之前沒有宣告過),然後在執行時引擎會在當前作用域中查詢該變數(找不到就向上一級作用域查詢),如果能夠找到就會對它賦值。
3. LHS 和 RHS
前面說到引擎在為變數賦值的時候會在作用域中查詢變數,但是執行怎樣的查詢,用什麼方式,會對最終的查詢結果造成影響。
在 var a = 5; 這個例子中,引擎會對 a 進行 LHS 查詢,當然,另外一個查詢型別叫作 RHS。
對變數進行賦值所執行的查詢叫 LHS。 找到並使用變數值所執行的查詢叫 RHS。
舉個例子:
function foo(a) {
// 這裡隱式包含了 a = 2 這個賦值,所以對 a 進行了 LHS 查詢
var b = a;
// 這裡對 a 進行了 RHS 查詢,找到 a 的值,然後對 b 進行 LHS 查詢,把 2 賦值給 b
return a + b;
// 這裡包含了對 a 和 b 進行的 RHS 查詢
}
var c = foo(2);
// 這裡首先對 foo 進行 RHS 查詢,找到它是一個函式,然後對 c 進行 LHS 查詢把 foo 賦值給 c
複製程式碼
所以上面的例子共包含 3 個 LHS 查詢和 4 個 RHS 查詢,你們都找對了嗎?
4. 作用域巢狀
當一個塊或函式巢狀在另一個塊或函式中時,就發生了作用域巢狀。因此,在當前作用域中無法找到某個變數時,引擎就會在外層巢狀的作用域中繼續查詢,直到找到該變數,或抵達最外層的作用域(也就是全域性作用域)為止。
舉個例子:
function foo(a) {
console.log(a + b);
}
var b = 2;
foo(2); // 4
複製程式碼
這裡對 b 進行的 RHS 查詢在 foo 作用域中無法找到,但可以在上一級作用域(這個例子中就是全域性作用域)中找到。
總結來說,遍歷巢狀作用域鏈的規則很簡單:引擎從當前執行的作用域中開始查詢變數,如果都找不到,就向上一級繼續查詢。當抵達最外層的全域性作用域時,無論找到還是沒找到,查詢過程都會停止。
5. 總結
編譯器、引擎和作用域是 javascript 程式碼執行的基礎,掌握好這些會對我們深入學習 javascript 起到事半功倍的效果,我們的學習之路才剛剛開始,大家加油!
歡迎關注我的公眾號
參考文章
- 《你不知道的JavaScript》