前面的話
javascript擁有一套設計良好的規則來儲存變數,並且之後可以方便地找到這些變數,這套規則被稱為作用域。作用域貌似簡單,實則複雜,由於作用域與this機制非常容易混淆,使得理解作用域的原理更為重要。本文是深入理解javascript作用域系列的第一篇——內部原理
內部原理分成編譯、執行、查詢、巢狀和異常五個部分進行介紹,最後以一個例項過程對原理進行完整說明
編譯
以var a = 2;為例,說明javascript的內部編譯過程,主要包括以下三步:
【1】分詞(tokenizing)
把由字元組成的字串分解成有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)
var a = 2;被分解成為下面這些詞法單元:var、a、=、2、;。這些詞法單元組成了一個詞法單元流陣列
// 詞法分析後的結果 [ "var" : "keyword", "a" : "identifier", "=" : "assignment", "2" : "integer", ";" : "eos" (end of statement) ]
【2】解析(parsing)
把詞法單元流陣列轉換成一個由元素逐級巢狀所組成的代表程式語法結構的樹,這個樹被稱為“抽象語法樹” (Abstract Syntax Tree, AST)
var a = 2;的抽象語法樹中有一個叫VariableDeclaration的頂級節點,接下來是一個叫Identifier(它的值是a)的子節點,以及一個叫AssignmentExpression的子節點,且該節點有一個叫Numericliteral(它的值是2)的子節點
{ operation: "=", left: { keyword: "var", right: "a" } right: "2" }
【3】程式碼生成
將AST轉換為可執行程式碼的過程被稱為程式碼生成
var a=2;的抽象語法樹轉為一組機器指令,用來建立一個叫作a的變數(包括分配記憶體等),並將值2儲存在a中
實際上,javascript引擎的編譯過程要複雜得多,包括大量優化操作,上面的三個步驟是編譯過程的基本概述
任何程式碼片段在執行前都要進行編譯,大部分情況下編譯發生在程式碼執行前的幾微秒。javascript編譯器首先會對var a=2;這段程式進行編譯,然後做好執行它的準備,並且通常馬上就會執行它
執行
簡而言之,編譯過程就是編譯器把程式分解成詞法單元(token),然後把詞法單元解析成語法樹(AST),再把語法樹變成機器指令等待執行的過程
實際上,程式碼進行編譯,還要執行。下面仍然以var a = 2;為例,深入說明編譯和執行過程
【1】編譯
1、編譯器查詢作用域是否已經有一個名稱為a的變數存在於同一個作用域的集合中。如果是,編譯器會忽略該宣告,繼續進行編譯;否則它會要求作用域在當前作用域的集合中宣告一個新的變數,並命名為a
2、編譯器將var a = 2;這個程式碼片段編譯成用於執行的機器指令
[注意]依據編譯器的編譯原理,javascript中的重複宣告是合法的
//test在作用域中首次出現,所以宣告新變數,並將20賦值給test var test = 20; //test在作用域中已經存在,直接使用,將20的賦值替換成30 var test = 30;
【2】執行
1、引擎執行時會首先查詢作用域,在當前的作用域集合中是否存在一個叫作a的變數。如果是,引擎就會使用這個變數;如果否,引擎會繼續查詢該變數
2、如果引擎最終找到了變數a,就會將2賦值給它。否則引擎會丟擲一個異常
查詢
在引擎執行的第一步操作中,對變數a進行了查詢,這種查詢叫做LHS查詢。實際上,引擎查詢共分為兩種:LHS查詢和RHS查詢
從字面意思去理解,當變數出現在賦值操作的左側時進行LHS查詢,出現在右側時進行RHS查詢
更準確地講,RHS查詢與簡單地查詢某個變數的值沒什麼區別,而LHS查詢則是試圖找到變數的容器本身,從而可以對其賦值
function foo(a){ console.log(a);//2 } foo( 2 );
這段程式碼中,總共包括4個查詢,分別是:
1、foo(...)對foo進行了RHS引用
2、函式傳參a = 2對a進行了LHS引用
3、console.log(...)對console物件進行了RHS引用,並檢查其是否有一個log的方法
4、console.log(a)對a進行了RHS引用,並把得到的值傳給了console.log(...)
巢狀
在當前作用域中無法找到某個變數時,引擎就會在外層巢狀的作用域中繼續查詢,直到找到該變數,或抵達最外層的作用域(也就是全域性作用域)為止
function foo(a){ console.log( a + b ) ; } var b = 2; foo(2);// 4
在程式碼片段中,作用域foo()函式巢狀在全域性作用域中。引擎首先在foo()函式的作用域中查詢變數b,並嘗試對其進行RHS引用,沒有找到;接著,引擎在全域性作用域中查詢b,成功找到後,對其進行RHS引用,將2賦值給b
異常
為什麼區分LHS和RHS是一件重要的事情?因為在變數還沒有宣告(在任何作用域中都無法找到變數)的情況下,這兩種查詢的行為不一樣
RHS
【1】如果RHS查詢失敗,引擎會丟擲ReferenceError(引用錯誤)異常
//對b進行RHS查詢時,無法找到該變數。也就是說,這是一個“未宣告”的變數 function foo(a){ a = b; } foo();//ReferenceError: b is not defined
【2】如果RHS查詢找到了一個變數,但嘗試對變數的值進行不合理操作,比如對一個非函式型別值進行函式呼叫,或者引用null或undefined中的屬性,引擎會丟擲另外一種型別異常:TypeError(型別錯誤)異常
function foo(){ var b = 0; b(); } foo();//TypeError: b is not a function
LHS
【1】當引擎執行LHS查詢時,如果無法找到變數,全域性作用域會建立一個具有該名稱的變數,並將其返還給引擎
function foo(){ a = 1; } foo(); console.log(a);//1
【2】如果在嚴格模式中LHS查詢失敗時,並不會建立並返回一個全域性變數,引擎會丟擲同RHS查詢失敗時類似的ReferenceError異常
function foo(){ 'use strict'; a = 1; } foo(); console.log(a);//ReferenceError: a is not defined
原理
function foo(a){ console.log(a); } foo(2);
以上面這個程式碼片段來說明作用域的內部原理,分為以下幾步:
【1】引擎需要為foo(...)函式進行RHS引用,在全域性作用域中查詢foo。成功找到並執行
【2】引擎需要進行foo函式的傳參a=2,為a進行LHS引用,在foo函式作用域中查詢a。成功找到,並把2賦值給a
【3】引擎需要執行console.log(...),為console物件進行RHS引用,在foo函式作用域中查詢console物件。由於console是個內建物件,被成功找到
【4】引擎在console物件中查詢log(...)方法,成功找到
【5】引擎需要執行console.log(a),對a進行RHS引用,在foo函式作用域中查詢a,成功找到並執行
【6】於是,引擎把a的值,也就是2傳到console.log(...)中
【7】最終,控制檯輸出2