JavaScript內部原理之變量對象
我們總是會在程序中定義一些函數和變量,之后會使用這些函數和變量來構建我們的系統。
然而,對于解釋器來說,它又是如何以及從哪里找到這些數據的(函數,變量)?當引用一個對象的時候,在解釋器內部又發生了什么?
許多ECMA腳本程序員都知道,變量和執行上下文是密切相關的:
- var a = 10; // 全局上下文中的變量
- (function () {
- var b = 20; // 函數上下文中的本地變量
- })();
- alert(a); // 10
- alert(b); // "b" is not defined
不僅如此,許多程序員也都知道,ECMAScript標準中指出獨立的作用域只有通過“函數代碼”(可執行代碼類型中的一種)才能創建出來。比方說,與C/C++不同的是,在ECMAScript中for循環的代碼塊是無法創建本地上下文的:
- for (var k in {a: 1, b: 2}) {
- alert(k);
- }
- alert(k); // 盡管循環已經結束,但是變量“k”仍然在作用域中
下面就來詳細介紹下,當申明變量和函數的時候,究竟發生了什么。
數據申明
既然變量和執行上下文有關,那它就該知道數據存儲在哪里以及如何獲取。這種機制就稱作變量對象:
A variable object (in abbreviated form — VO) is a special object related with an execution context and which stores:
◆ variables (var, VariableDeclaration);
◆ function declarations (FunctionDeclaration, in abbreviated form FD);
◆ and function formal parameters
◆ declared in the context.
舉個例子,可以用ECMAScript的對象來表示變量對象:
- VO = {};
VO同時也是一個執行上下文的屬性:
- activeExecutionContext = {
- VO: {
- // 上下文中的數據 (變量申明(var), 函數申明(FD), 函數形參(function arguments))
- }
- };
對變量的間接引用(通過VO的屬性名)只允許發生在全局上下文中的變量對象上(全局對象本身就是變量對象,這部分會在后續作相應的介紹)。 對于其他的上下文而言,是無法直接引用VO的,因為VO是實現層的。
申明新的變量和函數的過程其實就是在VO中創建新的和變量以及函數名對應的屬性和屬性值的過程。
如下所示:
- var a = 10;
- function test(x) {
- var b = 20;
- };
- test(30);
上述代碼對應的變量對象則如下所示:
- // 全局上下文中的變量對象
- VO(globalContext) = {
- a: 10,
- test:
- };
- // “test”函數上下文中的變量對象
- VO(test functionContext) = {
- x: 30,
- b: 20
- };
但是,在實現層(標準中定義的),變量對象只是一個抽象的概念。在實際執行上下文中,VO可能完全不叫VO,并且初始的結構也可能完全不同。
不同執行上下文中的變量對象
變量對象上的一些操作(比如:變量的初始化)和行為對于所有的執行上下文類型來說都已一樣的。從這一點來說,將變量對象表示成抽象的概念更加合適。 函數上下文還能定義額外的與變量對象相關的信息。
- AbstractVO (generic behavior of the variable instantiation process)
- ║
- ╠══> GlobalContextVO
- ║ (VO === this === global)
- ║
- ╚══> FunctionContextVO
- (VO === AO, object and are added)
接下來對這塊內容進行詳細介紹。
全局上下文中的變量對象
首先,有必要對全局對象(Global object)作個定義。
全局對象是一個在進入任何執行上下文前就創建出來的對象;此對象以單例形式存在;它的屬性在程序任何地方都可以直接訪問,其生命周期隨著程序的結束而終止。
全局對象在創建的時候,諸如Math,String,Date,parseInt等等屬性也會被初始化,同時,其中一些對象會指向全局對象本身——比如,DOM中,全局對象上的window屬性就指向了全局對象(但是,并非所有的實現都是如此):
- global = {
- Math: ,
- String:
- ...
- ...
- window: global
- };
在引用全局對象的屬性時,前綴通常可以省略,因為全局對象是不能通過名字直接訪問的。然而,通過全局對象上的this值,以及通過如DOM中的window對象這樣遞歸引用的方式都可以訪問到全局對象:
- String(10); // 等同于 global.String(10);
- // 帶前綴
- window.a = 10; // === global.window.a = 10 === global.a = 10;
- this.b = 20; // global.b = 20;
回到全局上下文的變量對象上——這里變量對象就是全局對象本身:
- VO(globalContext) === global;
準確地理解這個事實是非常必要的:正是由于這個原因,當在全局上下文中申明一個變量時,可以通過全局對象上的屬性來間地引用該變量(比方說,當變量名提前未知的情況下)
- var a = new String('test');
- alert(a); // directly, is found in VO(globalContext): "test"
- alert(window['a']); // indirectly via global === VO(globalContext): "test"
- alert(a === this.a); // true
- var aKey = 'a';
- alert(window[aKey]); // indirectly, with dynamic property name: "test"
函數上下文中的變量對象
在函數的執行上下文中,VO是不能直接訪問的。它主要扮演被稱作活躍對象(activation object)(簡稱:AO)的角色。
- VO(functionContext) === AO;
活躍對象會在進入函數上下文的時候創建出來,初始化的時候會創建一個arguments屬性,其值就是Arguments對象:
- AO = {
- arguments:
- };
Arguments對象是活躍對象上的屬性,它包含了如下屬性:
◆ callee —— 對當前函數的引用
◆ length —— 實參的個數
◆ properties-indexes(數字,轉換成字符串)其值是函數參數的值(參數列表中,從左到右)。properties-indexes的個數 == arguments.length;
arguments對象的properties-indexes的值和當前(實際傳遞的)形參是共享的。
如下所示:
- function foo(x, y, z) {
- // 定義的函數參數(x,y,z)的個數
- alert(foo.length); // 3
- // 實際傳遞的參數個數
- alert(arguments.length); // 2
- // 引用函數自身
- alert(arguments.callee === foo); // true
- // 參數互相共享
- alert(x === arguments[0]); // true
- alert(x); // 10
- arguments[0] = 20;
- alert(x); // 20
- x = 30;
- alert(arguments[0]); // 30
- // 然而,對于沒有傳遞的參數z,
- // 相關的arguments對象的index-property是不共享的
- z = 40;
- alert(arguments[2]); // undefined
- arguments[2] = 50;
- alert(z); // 40
- }
- foo(10, 20);
上述例子,在當前的Google Chrome瀏覽器中有個bug——參數z和arguments[2]也是互相共享的。
處理上下文代碼的幾個階段
至此,也就到了本文最核心的部分了。處理執行上下文代碼分為兩個階段:
進入執行上下文
執行代碼
對變量對象的修改和這兩個階段密切相關。
要注意的是,這兩個處理階段是通用的行為,與上下文類型無關(不管是全局上下文還是函數上下文都是一致的)。
進入執行上下文
一旦進入執行上下文(在執行代碼之前),VO就會被一些屬性填充(在此前已經描述過了):
◆ 函數的形參(當進入函數執行上下文時)
—— 變量對象的一個屬性,其屬性名就是形參的名字,其值就是實參的值;對于沒有傳遞的參數,其值為undefined
◆ 函數申明(FunctionDeclaration, FD) —— 變量對象的一個屬性,其屬性名和值都是函數對象創建出來的;如果變量對象已經包含了相同名字的屬性,則替換它的值
◆ 變量申明(var,VariableDeclaration) —— 變量對象的一個屬性,其屬性名即為變量名,其值為undefined;如果變量名和已經申明的函數名或者函數的參數名相同,則不會影響已經存在的屬性。
看下面這個例子:
- function test(a, b) {
- var c = 10;
- function d() {}
- var e = function _e() {};
- (function x() {});
- }
- test(10); // call
當以10為參數進入“test”函數上下文的時候,對應的AO如下所示:
- AO(test) = {
- a: 10,
- b: undefined,
- c: undefined,
- d: <reference to FunctionDeclaration "d">
- e: undefined
- };
注意了,上面的AO并不包含函數“x”。這是因為這里的“x”并不是函數申明而是函數表達式(FunctionExpression,簡稱FE),函數表達式不會對VO造成影響。 盡管函數“_e”也是函數表達式,然而,正如我們所看到的,由于它被賦值給了變量“e”,因此它可以通過“e”來訪問到。關于函數申明和函數表達式的區別會在第五章——函數作具體介紹。
至此,處理上下文代碼的第一階段介紹完了,接下來介紹第二階段——執行代碼階段。
執行代碼
此時,AO/VO的屬性已經填充好了。(盡管,大部分屬性都還沒有賦予真正的值,都只是初始化時候的undefined值)。
繼續以上一例子為例,到了執行代碼階段,AO/VO就會修改成為如下形式:
- AO['c'] = 10;
- AO['e'] = ;
再次注意到,這里函數表達式“_e”仍在內存中,這是因為它被保存在申明的變量“e”中,而同樣是函數表達式的“x”卻不在AO/VO中: 如果嘗試在定義前或者定義后調用“x”函數,這時會發生“x為定義”的錯誤。未保存的函數表達式只有在定義或者遞歸時才能調用。
如下是更加典型的例子:
- alert(x); // function
- var x = 10;
- alert(x); // 10
- x = 20;
- function x() {};
- alert(x); // 20
上述例子中,為何“x”打印出來是函數呢?為何在申明前就可以訪問到?又為何不是10或者20呢?原因在于,根據規則——在進入上下文的時候,VO會被填充函數申明; 同一階段,還有變量申明“x”,但是,正如此前提到的,變量申明是在函數申明和函數形參之后,并且,變量申明不會對已經存在的同樣名字的函數申明和函數形參發生沖突, 因此,在進入上下文的階段,VO填充為如下形式:
- VO = {};
- VO['x'] =
- // 發現var x = 10;
- // 如果函數“x”還未定義
- // 則 "x" 為undefined, 但是,在我們的例子中
- // 變量申明并不會影響同名的函數值
- VO['x'] =
隨后,在執行代碼階段,VO被修改為如下所示:
- VO['x'] = 10;
- VO['x'] = 20;
正如在第二個和第三個alert顯示的那樣。
如下例子再次看到在進入上下文階段,變量存儲在VO中(因此,盡管else的代碼塊永遠都不會執行到,而“b”卻仍然在VO中):
- if (true) {
- var a = 1;
- } else {
- var b = 2;
- }
- alert(a); // 1
- alert(b); // undefined, but not "b is not defined"
關于變量
大多數講JavaScript的文章甚至是JavaScript的書通常都會這么說:“申明全局變量的方式有兩種,一種是使用var關鍵字(在全局上下文中),另外一種是不用var關鍵字(在任何位置)”。 而這樣的描述是錯誤的。要記住的是:
使用var關鍵字是申明變量的唯一方式
如下賦值語句:
- a = 10;
僅僅是在全局對象上創建了新的屬性(而不是變量)。“不是變量”并不意味著它無法改變,它是ECMAScript中變量的概念(它之后可以變為全局對象的屬性,因為VO(globalContext) === global,還記得吧?)
不同點如下所示:
- alert(a); // undefined
- alert(b); // "b" is not defined
- b = 10;
- var a = 20;
接下來還是要談到VO和在不同階段對VO的修改(進入上下文階段和執行代碼階段):
進入上下文:
- VO = {
- a: undefined
- };
我們看到,這個階段并沒有任何“b”,因為它不是變量,“b”在執行代碼階段才出現。(但是,在我們這個例子中也不會出現,因為在“b”出現前就發生了錯誤)
將上述代碼稍作改動:
- alert(a); // undefined, we know why
- b = 10;
- alert(b); // 10, created at code execution
- var a = 20;
- alert(a); // 20, modified at code execution
這里關于變量還有非常重要的一點:與簡單屬性不同的是,變量是不能刪除的{DontDelete},這意味著要想通過delete操作符來刪除一個變量是不可能的。
- a = 10;
- alert(window.a); // 10
- alert(delete a); // true
- alert(window.a); // undefined
- var b = 20;
- alert(window.b); // 20
- alert(delete b); // false
- alert(window.b); // still 20
但是,這里有個例外,就是“eval”執行上下文中,是可以刪除變量的:
- eval('var a = 10;');
- alert(window.a); // 10
- alert(delete a); // true
- alert(window.a); // undefined
利用某些debug工具,在終端測試過這些例子的童鞋要注意了:其中Firebug也是使用了eval來執行終端的代碼。因此,這個時候var也是可以刪除的。
實現層的特性:__parent__屬性
正如此前介紹的,標準情況下,是無法直接訪問激活對象的。然而,在某些實現中,比如知名的SpiderMonkey和Rhino,函數有個特殊的屬性__parent__, 該屬性是對該函數創建所在的激活對象的引用(或者全局變量對象)。
如下所示(SpiderMonkey,Rhino):
- var global = this;
- var a = 10;
- function foo() {}
- alert(foo.__parent__); // global
- var VO = foo.__parent__;
- alert(VO.a); // 10
- alert(VO === global); // true
上述例子中,可以看到函數foo是在全局上下文中創建的,相應的,它的__parent__屬性設置為全局上下文的變量對象,比如說:全局對象。
然而,在SpiderMonkey中以相同的方式獲取激活對象是不可能的:不同的版本表現都不同,內部函數的__parent__屬性會返回null或者全局對象。
在Rhino中,以相同的方式獲取激活對象是允許的:
如下所示(Rhino):
- var global = this;
- var x = 10;
- (function foo() {
- var y = 20;
- // the activation object of the "foo" context
- var AO = (function () {}).__parent__;
- print(AO.y); // 20
- // __parent__ of the current activation
- // object is already the global object,
- // i.e. the special chain of variable objects is formed,
- // so-called, a scope chain
- print(AO.__parent__ === global); // true
- print(AO.__parent__.x); // 10
- })();
總結
本文,我們介紹了與執行上下文相關的對象。希望,本文能夠對大家有所幫助,同時也希望本文能夠起到解惑的作用。
說明
此文譯自Dmitry A.Soshnikov 的文章Variable object 。另,此文還有另外一位同事(宋珍珍)共同參譯
原文:http://goddyzhao.tumblr.com/post/11141710441/variable-object
【編輯推薦】