編譯原理
儘管 JavaScript 經常被歸類為“動態”或“解釋執行”的語言,但實際上它是一門編譯語言。JavaScript 引擎進行的編譯步驟和傳統編譯語言非常相似,但有些地方可能比預想的要複雜。
傳統編譯流程:
分詞/此法分析(Tokenizing/Lexing)
這個過程會將有字元組成的字串分解成(對程式語言來說)有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)。例如:
var a = 2
;這段程式通常會被分解成詞法單元:var
、a
、=
、2
;空格是否會被當成詞法單元,取決於空格在這門語言種是否具有意義。解析/語法分析(Parsing)
這個過程是將詞法單元流(陣列)轉換成一個由元素逐級巢狀所組成的代表了程式語法的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。
var a = 2
的 AST 為:VariableDeclaration
--Identifier = a
--AssignmentExpression
----NumericLiteral = 2
程式碼生成
將 AST 轉換為可執行程式碼的過程被稱為程式碼生成。這個過程與語言、目標平臺等息息相關。簡單來說就是將 AST 轉換為一組機器指令,用來建立一個叫做 a 的變數(包括分配記憶體等),並將值 2 儲存在 a 中。
JavaScript 的編譯
JavaScript 的編譯由 JavaScript 引擎來負責(包括執行)。編譯通常由三個部分組成:
- 引擎:從頭到尾負責整個 JavaScript 的編譯以及執行;
- 編譯器:負責語法分析以及程式碼生成;
- 作用域:負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。
在我們看來var a = 2
;這是一個普通的變數宣告。而在 JavaScript 引擎看來這裡有兩個完全不同的宣告:
var a
,編譯器會尋找當前作用域中是否有同樣的宣告。如果有,則忽略該宣告,並繼續編譯;否則它會在當前作用域(全域性/函式作用域)的集合中宣告一個新的變數,並命名為 a。- 接下來編譯器會為引擎生成執行時所需的程式碼,這些程式碼用來處理賦值(
a = 2
)操作。引擎會在當前作用域中查詢變數 a。如果能找到,則為其賦值;如果找不到,則繼續向上查詢(作用域鏈)。
由於編譯的第一步操作會尋找所有的var
關鍵詞宣告,無論它在程式碼的什麼位置,都會宣告好。在程式碼真正執行時,所有宣告都已經宣告好了,哪怕它是在其他操作的下面,都可以直接進行。這就是var
關鍵詞的宣告提升。
a = 2;
console.log(a);
var a;
LHS 和 RHS
編譯器在編譯過程的第二步生成了程式碼,引擎執行它時,就會查詢變數 a 來判斷它是否已經宣告過。但引擎如何進行查詢,影響最終查詢的結果。
LHS 和 RHS 分別對應的是左側查詢與右側查詢。左右兩側分別代表一個賦值操作的左側和右側。也就說,當變量出現在賦值操作的左側時進行 LHS 查詢,出現在右側時進行 RHS 查詢。
例如:a = 2
,這裡進行的就是 LHS 查詢。這裡不關心 a 的當前值,只想找到 a 併為其賦一個值。
而:console.log(a)
,這裡進行的是 RHS 查詢。因為這裡需要取到 a 的值,而不是為其賦值。
“賦值操作的左側和右側”並不一定代表就是=
的左右兩側,賦值操作還有其他多種形式。因此,可以在概念上理解為“查詢被賦值的目標(LHS)”以及”查詢目標的值(RHS)“。
小測驗:
尋找 LHS 查詢(3處)以及 RHS 查詢(4處)。
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
LHS:
var c = foo(...)
:為變數 c 賦值foo(2)
:傳遞引數時,為形參 a 賦值 2var b = a
:為變數 b 賦值
RHS:
var c = foo(...)
:查詢foo()
var b = a
:(為變數 b 賦值時)取得 a 的值return a + b
:取得 a 與 b(兩次)
異常
通過詳細的瞭解異常可以準確的確定發生的問題所在。
在 LHS 查詢時,如果到作用域頂部還沒有查詢到宣告,則作用域會熱心的幫我們(隱式)建立一個全域性變數(非嚴格模式下)。
而在 RHS 查詢時,如果在作用域頂部還沒有查詢到宣告,就會丟擲一個 ReferenceError 異常。
在嚴格模式下,LHS 如果沒有找到宣告,引擎會丟擲一個和 RHS 類似的 ReferenceError 異常。
無論是 LHS 還是 RHS 都是查詢一個引用,而沒有查詢到對應的引用時,就會得到(引用)ReferenceError 異常。
接下來,如果 RHS 查詢到了一個變數,但是我們嘗試對這個變數的值進行不合理的操作。例如對一個非函式進行函式呼叫,或者對物件中不存在的屬性進行引用。那麼引擎會丟擲另外一個異常,叫做 TypeError。
閉包
閉包是基於詞法作用域書寫程式碼時所產生的自然結果。閉包的主要定義:
當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。
JavaScript 使用的是詞法作用域模型,另一種作用域模型是動態作用域。
仔細來看,閉包的主要定義有:
- 函式記住並可以訪問所在的詞法作用域
- 在當前詞法作用域之外執行也能繼續訪問所在的詞法作用域
來看一個例子:
function foo() {
const a = 123;
function bar() {
console.log(a);
}
bar();
}
foo();
這段程式碼看起來好像符合閉包的一部分定義,雖然bar()
函式並沒有脫離當前的詞法作用域執行。但是它依然記住了foo()
的詞法作用域,並能訪問。
它確實滿足閉包定義的一部分(很重要的一部分),從技術上講,也許是,但並不能完全斷定這就是閉包。通常我們所見到的與認為閉包的情況就是滿足所有定義的時候:
function foo() {
const a = 321;
function bar() {
console.log(a);
}
return bar;
}
// 同理
// foo()();
const baz = foo()
baz();
因為垃圾收集機制,當一個函式執行結束後,通常它的整個內部作用域會被銷燬。當我們的foo()
函式執行結束後,看上去它的內容不會再被使用,所以很自然的考慮會被回收。
但閉包的神奇之處就在這裡,它會阻止這一切的發生。當bar
被return
出去之後,在其詞法作用域的外部依然能夠訪問foo()
的內部作用域。bar
依然持有對該作用域的引用,這個引用就叫作閉包。
這也是經常見到說閉包會影響效能的主要原因。某些情況下,它確實會影響到效能,例如過度多的返回本不需要的函式,甚至是巢狀。這會導致本不需要的作用域沒有被回收。
常見的閉包
上述將一個函式return
出來的案例是最常見的閉包案例。但在我們的程式碼中,也有些其他非常常見的閉包。不過平時可能沒有太過去注意它。
先來回顧一下定義:
無論通過何種手段將內部函式傳遞到詞法作用域之外,它都會保留對改內部詞法作用域的引用,無論在何處執行這個函式都會使其閉包。
function waitAMinute(msg: string) {
setTimeout(() => {
console.log(msg);
}, 1000);
}
waitAMinute('嚶嚶嚶');
function btnClick(selector: string, msg: string) {
$(selector).click(() => {
alert(msg);
});
}
btnClick('#btn_1', 'hah');
btnClick('#btn_2', 'got you');