javasrcipt的作用域和閉包(二)續篇之:函式內部提升機制與Variable Object
一個先有雞還是先有蛋的問題,先看一段程式碼:
a = 2; var a; console.log(a);
通常我們都說JavaScript程式碼是由上到下一行一行執行,但實際這段程式碼輸出的結果是2。但這段程式碼並不能為我們要討論的問題提供完整的參考意義,所以再看一下程式碼:
console.log(a) var a = 2;
這段程式碼的測試結果輸出了undefined。
這兩段程式碼打破了我們常說的JavaScript程式碼從上往下執行的說法,那到底是變數宣告在前還是賦值在前呢?
函式預編譯:
還記得我在上篇部落格中分析詞法作用域,如有程式碼是var a = 2;引擎會把這段程式碼分成兩個宣告來進行編譯,第一次編譯的是var a,第二次編譯的是a = 2,在前面的所有內容我並沒有對編譯邏輯做任何解釋,通常情況下我們也都認為這兩個編譯環節是一個先後相鄰的編譯邏輯過程,但是真實情況顯然不是,如果是這樣的話,上面兩段程式碼的執行結果就會有很大的不同了,這是顯而易見。
這就是JavaScript特有的一種編譯機制,函式預編譯的提升機制。
那這個機制到底在程式碼執行的時候幹了什麼呢?可以通過對上面兩個示例採用內部機制的方式做一個人為的顯示修改,來模仿內部提升機制處理程式。
第一段程式碼可以如下形式處理:
var a; a = 2; console.log(a);
第二段程式碼可以如下形式處理:
var a; console.log(a); a = 2;
由修改的程式碼可以看到一個規律,就是變數宣告操作會被提升到程式的最上方。是的,這就是JavaScript的內部編譯的提升機制。
而且這個機制不止適應於變數宣告提升,還適應與函式宣告提升。請看以下程式碼:
function foo(){ fn(a);//輸出undefined var a = 2; fn(a);//輸出2 function fn(sum){ console.log(sum); } }
foo();
這段程式碼不僅說明了函式宣告也適應與提升機制,而且還說明了變數宣告適應於提升機制,但是賦值還是會留在原地。
但是這並沒有結束,請看以下程式碼:
function foo(){ var a = 2; function a(){ console.log("aaa"); }
console.log(a)//2 a();//TypeError: a is not a function } foo();
別急,我再把這段程式碼稍微修改以下,你會發現驚喜的。
function foo(){ a();//aaa var a = 2; a();//TypeError: a is not a function function a(){ console.log("aaa"); } } foo();
是不是感覺這兩段程式碼瞬間摧毀了我們之前通過提升機制理解的內部機制,之前的提升機制好像在正常的情況下(命名不衝突的情況下)能為我們解決函式的內部執行問題,但是當遇到變數宣告與函式宣告衝突時就會讓這個機制變得脆弱不堪,可能有的人會說,我們可開始規範點就好了,不要把命名混淆著用就是了。但是,在實際的開發中,我們有時候會需要利用同一個宣告同時做變數和函式的載體,而且遇到問題就解決問題是我們開發人員的價值所在,所以我們有必要理解這種情況下,函式內部的編譯到底發生了什麼?
Variable Object:
很明顯變數提升機制已經不足以解釋我們的疑惑,通常我們都知道資料資訊在編譯的時候就是向記憶體寫入資料,再在呼叫引數和方法時從記憶體中讀取出來,也就是說變數和函式的宣告都是在記憶體中開闢一個記憶體空間,然後在賦值的時候根據引擎提供的實體地址,將值儲存到對應的記憶體空間裡。然後在程式需要引用變數的值或者執行某個函式時再去到對應的記憶體地址取出這些資料,提供給引擎來執行。那麼這裡就會有一套管理這種資料讀寫的機制,這種機制成為變數物件(Variable Object)。
變數物件是一種特殊的物件,這個物件用來儲存和管理函式的內部變數和函式,以及與內外巢狀作用域的關係。我們暫且不管它為什麼特殊,先通過物件這個特性來理解函式的內部資料的讀寫機制。用下面這段程式碼來理解變數物件:
function foo(){ a();//aaa var a = 2; function a(){ console.log("aaa"); } var b; function b(){ console.log("bbb"); } b();//bbb b = 4; console.log(a);//2 console.log(b);//4 } foo();
在這裡,我想重申一次,函式內部的提升機制任然存在,前面的程式碼出現的混亂情況只是記憶體管理機制所導致的,我們可以先模仿變數物件機制來處理變數宣告。
var VO = { a:undefined, b:undefined }
當變數提升後,上面的示例程式碼就會進行下一步操作,函式宣告提升,變數物件的內部就會發生如下變化:
var VO = { a:function(){...}, b:function(){...} }
函式宣告的內部提升與函式體被儲存到記憶體可以看做是同步進行的,當函式名與之前提升的變數名相同時,變數會被覆蓋成函式。然後就到了程式碼執行階段。
函式執行的第一條程式碼就是a()執行,這時候執行的是被提升的a函式,所以打印出字串aaa。
然後緊接著執行第二行程式碼var a = 2,而實際上因為變數名提升的機制,這行程式碼在引擎看來只是a = 2這樣的宣告存在了。所以VO會發生如下變化:
var VO = { a:2, b:function(){...} }
因為提升機制,後面的a函式宣告和b變數宣告再到b函式宣告都會跳過,因為這三行程式碼在之前的提升機制中被提升到了函式的最上方。所以會直接執行b()函式,打印出字串bbb,然後緊接著又執行b = 4 賦值操作,變數物件內部的b屬性的值會被修改成4,所以後面列印a,b的結果分別是2,4。
到這裡應該就會很清楚之前程式碼的報錯原因了,但是上面的例子中還缺了一點東西,就是如果函式帶有引數怎麼辦?
其實有了上面的資料讀寫機制的流程解釋就很好理解了,當函式帶有引數時,因為js的引數沒有嚴格的限制,形參和實參會有很大的區別,但是js的編譯器的容錯性很強,引數不對稱並不會發生錯誤。而是會被統一看成是變數,如果有實際傳入引數就相當於宣告變數並賦值的操作。而這個賦值會在函式提升前操作,所以如果出現同名的函式宣告也會被覆蓋。
最後函式的內部提升機制和變數物件的讀寫機制做一個瀏覽總結,可以總體上分為四個步驟:
1.當函式執行時(準確說是執行的前一刻),內部會建立一個變數物件;
2.然後將形參和變數宣告提升,作為VO的屬性名,並賦值undefined
3.將實參的引數賦給對應的形參(實際上賦給變數物件對應變數的屬性)
4.在函式體裡面找到函式宣告提升,然後將函式體作為值賦給在變數物件內對應的屬性。
接下來就是函式真正執行的時刻了,再執行的時候就是對VO進行修改和查詢操作了。