本文是翻譯 http://dmitrysoshnikov.com/ecmascript/chapter-3-this/
概要
本文將進一步討論與執行內容密切相關的概念——this關鍵字。
事實證明,this這塊的內容非常的複雜,它在不同執行內容的情況下其值都會不同,並且會相應的引發一些問題。
很多程式員一看到this關鍵字,就會把它和物件導向的編程方式聯絡在一起,它指向利用構造器新建立出來的對象。在ECMAScript中,也支援this,然而, 正如大家所熟知的,this不僅僅只用來表示建立出來的對象。
接下來給大家揭開在ECMAScript中this神秘的面紗。
定義
This是執行內容的一個屬性:
activeExecutionContext = { VO: {...}, this: thisValue};
這裡的VO就是前一章介紹的變數對象。
This與內容相關的可執行代碼類型有關,其值在進入上下文階段就確定了,並且在執行代碼階段是不能改變的。
讓我們來詳細的看看this在ECMAScript中式如何表現的。
全域代碼中This的值
這種情況下,一切都變得非常簡單,this的值總是全域對象本身;因此,可以間接地擷取引用:
// 顯式定義全域對象的屬性this.a = 10; // global.a = 10alert(a); // 10 // 通過賦值給不受限的標識符來進行隱式定義b = 20;alert(this.b); // 20 // 通過變數聲明來進行隱式定義// 因為全域上下文中的變數對象就是全域對象本身var c = 30;alert(this.c); // 30
函數代碼中This的值
當this在函數代碼中的時候,事情就變得有趣多了。這種情況下是最複雜的,並且會引發很多的問題。
函數代碼中this值的第一個特性(同時也是最主要的特性)就是:它並非靜態綁定在函數上。
正如此前提到的,this的值是在進入內容相關的階段確定的,並且在函數代碼中的話,其值每次都會大不相同。
然而,一旦進入執行代碼階段,其值就不能改變了。比方說,要想給this賦一個新的值是不可能的,因為this根本就不是變數(相反的,在Python語言中,它顯示定義的self對象是可以在運行時隨意更改的):
var foo = {x: 10}; var bar = { x: 20, test: function () { alert(this === bar); // true alert(this.x); // 20 this = foo; // error, 不能更改this的值 alert(this.x); // 如果沒有錯誤,則其值為10而不是20 } }; // 在進入內容相關的時候,this的值就確定了是“bar”對象// 至於為什麼,會在後面作詳細介紹 bar.test(); // true, 20 foo.test = bar.test; // 但是,這個時候,this的值又會變成“foo”// 縱然我們調用的是同一個函數 foo.test(); // false, 10
因此,在函數代碼中影響this值的因素是有很多的。
首先,在一般的函數調用中,this的值是由啟用上下文代碼的調用者決定的,比如說,調用函數的外層上下文。this的值是由調用運算式的形式決定的。
理解並謹記這一點是非常必要的,有利於在任何上下文中都能準確的確定this的值。
影響調用上下文中的this的值的只有可能是調用運算式的形式,也就是調用函數的方式。 (一些關於JavaScript的文章和書籍中指出的“this的值取決於函數的定義方式,如果是全域函數,則this的值就會設定為全域對象,如果是某個對象的方法,則this的值就會設定為該對象”——這純屬扯淡,根本就是在誤人子弟)。 正如此前大家看到的,縱然是全域函數,this的值也會隨著函數調用方式的不同而不同:
function foo() { alert(this);} foo(); // global alert(foo === foo.prototype.constructor); // true // 然而,同樣的函數,以另外一種調用方式的話,this的值就不同了 foo.prototype.constructor(); // foo.prototype
調用一個對象的某個方法的時候,this的值也有可能不是該對象的:
var foo = { bar: function () { alert(this); alert(this === foo); }}; foo.bar(); // foo, true var exampleFunc = foo.bar; alert(exampleFunc === foo.bar); // true // 同樣地,相同的函數以不同的調用方式,this的值也就不同了 exampleFunc(); // global, false
那麼,究竟調用運算式的方式是如何影響this的值的呢?為了完全搞明白這其中的奧妙,首先,這裡有必要先介紹一種內部類型——參考型別(the Reference type)。
參考型別
參考型別的值可以用虛擬碼表示為一個擁有兩個屬性的對象——base屬性(屬性所屬的對象)以及該base對象中的propertyName屬性:
var valueOfReferenceType = { base: , propertyName: };
參考型別的值只有可能是以下兩種情況:
- 當處理一個標識符的時候
- 或者進行屬性訪問的時候
關於標識符的處理會在第四章——所用域鏈中作介紹,這裡我們只要注意的是,此演算法總返回一個參考型別的值(這對this的值是至關重要的)。
標識符其實就是變數名,函數名,函數參數名以及全域對象的未受限的屬性。如下所示:
var foo = 10;function bar() {}
中間過程中,對應的參考型別的值如下所示:
var fooReference = { base: global, propertyName: 'foo'}; var barReference = { base: global, propertyName: 'bar'};
要從參考型別的值中擷取一個對象實際的值需要GetValue方法,該方法用虛擬碼可以描述成如下形式:
function GetValue(value) { if (Type(value) != Reference) { return value; } var base = GetBase(value); if (base === null) { throw new ReferenceError; } return base.[[Get]](GetPropertyName(value)); }
上述代碼中的[[Get]]方法返回了對象屬性實際的值,包括從原型鏈中繼承的屬性:
GetValue(fooReference); // 10GetValue(barReference); // function object "bar"
對於屬性訪問來說,有兩種方式: 點符號(這時屬性名稱是正確的標識符並且提前已經知道了)或者中括弧符號:
foo.bar();foo['bar']();
中間過程中,得到如下的參考型別的值:
var fooBarReference = { base: foo, propertyName: 'bar'}; GetValue(fooBarReference); // function object "bar"
問題又來了,參考型別的值又是如何影響函數上下文中this的值的呢?——非常重要。這也是本文的重點。總的來說,決定函數上下文中this的值的規則如下所示:
函數上下文中this的值是函數調用者提供並且由當前調用運算式的形式而定的。 如果在調用括弧()的左邊,有參考型別的值,那麼this的值就會設定為該參考型別值的base對象。 所有其他情況下(非參考型別),this的值總是null。然而,由於null對於this來說沒有任何意義,因此會隱式轉換為全域對象。
如下所示:
function foo() { return this;} foo(); // global
上述代碼中,調用括弧的左側是參考型別的值(因為foo是標識符):
var fooReference = { base: global, propertyName: 'foo'};
相應的,this的值會設定為參考型別值的base對象,這裡就是全域對象。
屬性訪問也是類似的:
var foo = { bar: function () { return this; }}; foo.bar(); // foo
同樣的,也是參考型別的值,它的base對象是foo對象,啟用bar函數的時候,this的值就設定為foo對象了:
var fooBarReference = { base: foo, propertyName: 'bar'};
然而,同樣的函數以不同的啟用方式的話,this的值就完全不同了:
var test = foo.bar;test(); // global
因為test也是標識符,這樣就產生了另外的參考型別的值,其中base對象(全域對象)就是this的值:
var testReference = { base: global, propertyName: 'test'};
至此,我們就可以精確的解釋,為什麼同樣的函數,以不同的調用方式啟用,this的值也會不同了——答案就是處理過程中,是不同的參考型別的值:
function foo() { alert(this);} foo(); // global, 因為 var fooReference = { base: global, propertyName: 'foo'}; alert(foo === foo.prototype.constructor); // true // 另一種調用方式 foo.prototype.constructor(); // foo.prototype, 因為 var fooPrototypeConstructorReference = { base: foo.prototype, propertyName: 'constructor'};
如下是另外一種(典型的)利用調用運算式來動態決定this值的例子:
function foo() { alert(this.bar);} var x = {bar: 10};var y = {bar: 20}; x.test = foo;y.test = foo; x.test(); // 10y.test(); // 20
函數調用以及非參考型別
正如此前提到過的,當調用括弧左側為非參考型別的時候,this的值會設定為null,並最終變成全域對象。
我們來考慮下如下運算式:
(function () { alert(this); // null => global})();
上述例子中,有函數對象,但非參考型別對象(因為它不既不是標識符也不屬於屬性訪問),因此,this的值最終設定為全域對象。
如下是更為複雜的例子:
var foo = { bar: function () { alert(this); }}; foo.bar(); // Reference, OK => foo(foo.bar)(); // Reference, OK => foo (foo.bar = foo.bar)(); // global?(false || foo.bar)(); // global?(foo.bar, foo.bar)(); // global?
看了上述代碼,你可能又有疑問了:為什麼明明是屬性訪問,但是最終this的值不是base對象而是全域對象呢?
這裡主要疑問在最後三個運算式,這三個運算式添加了特定的操作之後,調用括弧左側就不再是參考型別的值了。
第一種情況——非常明確,是參考型別,最終this的值設定為base對象,foo。
第二種情況有一個組操作符(grouping operator),該操作符不會觸發調用擷取參考型別實際值的方法,比如:GetValue方法。 相應的,處理組操作符中間過程中——獲得的仍然是一個參考型別的值,這也就解釋了為什麼this的值設定成了base對象,foo。
第三種情況是一個賦值操作符(assignment operator),與組操作符不同的是,它會觸發調用GetValue方法(參見11.13.1中的第三步)。 最後返回的時候就是一個函數對象了(而不是參考型別的值了),這就意味著this的值會設定為null,最終會變成全域對象。
第四和第五種情況也是類似的——逗號操作符和OR邏輯運算式都會觸發調用GetValue方法,於是相應地就會丟失原先的參考型別值,變成了函數類型,this的值就變成了全域對象了。
參考型別以及null(this的值)
有這麼一種情況下,當調用運算式左側是參考型別的值,但是this的值卻是null,最終變為全域對象。 發生這種情況的條件是當參考型別值的base對象恰好為活躍對象。
當內部子函數在父函數中被調用的時候就會發生這種情況。正如第二章介紹的, 局部變數,內建函式以及函數的形參都會儲存在指定函數的活躍對象中:
function foo() { function bar() { alert(this); // global } bar(); // 和AO.bar()是一樣的}
活躍對象總是會返回this值為——null(用虛擬碼來表示,AO.bar()就相當於null.bar())。然後,如此前描述的,this的值最終會由null變為全域對象。
當函數調用包含在with語句的代碼塊中,並且with對象包含一個函數屬性的時候,就會出現例外的情況。with語句會將該對象添加到範圍鏈的最前面,在活躍對象的之前。 相應地,在參考型別的值(標識符或者屬性訪問)的情況下,base對象就不再是活躍對象了,而是with語句的對象。另外,值得一提的是,它不僅僅只針對內建函式,全域函數也是如此, 原因就是with對象掩蓋了範圍鏈中更高層的對象(全域對象或者活躍對象):
var x = 10; with ({ foo: function () { alert(this.x); }, x: 20 }) { foo(); // 20 } // because var fooReference = { base: __withObject, propertyName: 'foo'};
當調用的函數恰好是catch從句的參數時,情況也是類似的:在這種情況下,catch對象也會添加到範圍鏈的最前面,在活躍對象和全域對象之前。 然而,這個行為在ECMA-262-3中被指出是個bug,並且已經在ECMA-262-5中修正了;因此,在這種情況下,this的值應該設定為全域對象,而不是catch對象。
try { throw function () { alert(this); };} catch (e) { e(); // __catchObject - in ES3, global - fixed in ES5} // on idea var eReference = { base: __catchObject, propertyName: 'e'}; // 然而,既然這是個bug// 那就應該強制設定為全域對象// null => global var eReference = { base: global, propertyName: 'e'};
同樣的情況還會在遞迴調用一個非匿名函數的時候發生(函數相關的內容會在第五章作相應的介紹)。在第一次函數調用的時候,base對象是外層的活躍對象(或者全域對象), 在接下來的遞迴調用的時候——base對象應當是一個儲存了可選的函數運算式名字的特殊對象,然而,事實卻是,在這種情況下,this的值永遠都是全域對象:
(function foo(bar) { alert(this); !bar && foo(1); // "should" be special object, but always (correct) global })(); // global
當函數作為構造器被調用時this的值
這裡要介紹的是函數上下文中關於this值的另外一種情況——當函數作為構造器被調用的時候:
function A() { alert(this); // newly created object, below - "a" object this.x = 10;} var a = new A();alert(a.x); // 10
在這種情況下,new操作符會調用“A”函數的內部[[Construct]]。 在對象建立之後,會調用內部的[[Call]]函數,然後所有“A”函數中this的值會設定為新建立的對象。
手動設定函數調用時this的值
Function.prototype上定義了兩個方法(因此,它們對所有函數而言都是可訪問的),允許手動指定函數調用時this的值。這兩個方法是:.apply和.call; 它們都接受第一個參數作為調用上下文中this的值。而它們的不同點其實無關緊要:對於.apply來說,第二個參數接受數群組類型(或者是類數組的對象,比如arguments), 而.call方法接受任意多的參數。這兩個方法只有第一個參數是必要的——this的值。
如下所示:
var b = 10; function a(c) { alert(this.b); alert(c);} a(20); // this === global, this.b == 10, c == 20 a.call({b: 20}, 30); // this === {b: 20}, this.b == 20, c == 30a.apply({b: 30}, [40]) // this === {b: 30}, this.b == 30, c == 40
總結
本文我們討論了ECMAScript中this關鍵字的特性(相對C++或者Java而言,真的可以說是特性)。洗完此文對大家理解this關鍵字在ECMAScript中的工作原理有所協助。
擴充閱讀