你不知道的JavaScript 上卷 第二部分 this和對象原型

第一章 關於this

this 關鍵字是JavaScript 中最複雜的機制之一。它是一個很特別的關鍵字,被自動定義在
所有函數的作用域中。但是即使是非常有經驗的JavaScript 開發者也很難說清它到底指向
什麼。

任何足夠先進的技術都和魔法無異。 ——Arthur C. Clarke

實際上,JavaScript 中this 的機制並沒有那麼先進,但是開發者往往會把理解過程複雜化,
毫無疑問,在缺乏清晰認識的情況下,this 對你來說完全就是一種魔法。

“this”是溝通過程中極其常見的一個代詞。所以,在交流過程中很難區分
我們到底把“this”當作代詞還是當作關鍵字。清晰起見,我總一直使用
this 表示關鍵字,使用“this”或者this 來表示代詞。

1.1 爲什麼要用this

如果對於有經驗的JavaScript 開發者來說this 都是一種非常複雜的機制,那它到底有用在
哪裏呢?真的值得我們付出這麼大的代價學習嗎?的確,在介紹怎麼做之前我們需要先明
白爲什麼。
下面我們來解釋一下爲什麼要使用this:

function identify() {
return this.name.toUpperCase();
}
function speak() {
var greeting = "Hello, I'm " + identify.call( this );
console.log( greeting );
}
var me = {
name: "Kyle"
};
var you = {
name: "Reader"
};
identify.call( me ); // KYLE
identify.call( you ); // READER
speak.call( me ); // Hello, 我是KYLE
speak.call( you ); // Hello, 我是 READER

看不懂這段代碼?不用擔心!我們很快就會講解。現在請暫時拋開這些問題,專注於爲
什麼。
這段代碼可以在不同的上下文對象(me 和you)中重複使用函數identify() 和speak(),
不用針對每個對象編寫不同版本的函數。
如果不使用this,那就需要給identify() 和speak() 顯式傳入一個上下文對象。

function identify(context) {
return context.name.toUpperCase();
}
function speak(context) {
var greeting = "Hello, I'm " + identify( context );
console.log( greeting );
}
identify( you ); // READER
speak( me ); //hello, 我是KYLE

然而,this 提供了一種更優雅的方式來隱式“傳遞”一個對象引用,因此可以將API 設計
得更加簡潔並且易於複用。
隨着你的使用模式越來越複雜,顯式傳遞上下文對象會讓代碼變得越來越混亂,使用this
則不會這樣。當我們介紹對象和原型時,你就會明白函數可以自動引用合適的上下文對象
有多重要。

1.2 誤解

我們之後會解釋this 到底是如何工作的,但是首先需要消除一些關於this 的錯誤認識。
太拘泥於“this”的字面意思就會產生一些誤解。有兩種常見的對於this 的解釋,但是它
們都是錯誤的。

1.2.1 指向自身

人們很容易把this 理解成指向函數自身,這個推斷從英語的語法角度來說是說得通的。
那麼爲什麼需要從函數內部引用函數自身呢?常見的原因是遞歸(從函數內部調用這個函
數)或者可以寫一個在第一次被調用後自己解除綁定的事件處理器。
JavaScript 的新手開發者通常會認爲,既然函數看作一個對象(JavaScript 中的所有函數都
是對象),那就可以在調用函數時存儲狀態(屬性的值)。這是可行的,有些時候也確實有
用,但是在本書即將介紹的許多模式中你會發現,除了函數對象還有許多更合適存儲狀態
的地方。
不過現在我們先來分析一下這個模式,讓大家看到this 並不像我們所想的那樣指向函數
本身。
我們想要記錄一下函數foo 被調用的次數,思考一下下面的代碼:

function foo(num) {
console.log( "foo: " + num );
// 記錄foo 被調用的次數
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被調用了多少次?
console.log( foo.count ); // 0 -- WTF?

console.log 語句產生了4 條輸出,證明foo(..) 確實被調用了4 次,但是foo.count 仍然
是0。顯然從字面意思來理解this 是錯誤的。
執行foo.count = 0 時,的確向函數對象foo 添加了一個屬性count。但是函數內部代碼
this.count 中的this 並不是指向那個函數對象,所以雖然屬性名相同,根對象卻並不相
同,困惑隨之產生。

負責的開發者一定會問“如果我增加的count 屬性和預期的不一樣,那我增
加的是哪個count ?”實際上,如果他深入探索的話,就會發現這段代碼在
無意中創建了一個全局變量count(原理參見第2 章),它的值爲NaN。當然,
如果他發現了這個奇怪的結果,那一定會接着問:“爲什麼它是全局的,爲
什麼它的值是NaN 而不是其他更合適的值?”(參見第2 章。)

遇到這樣的問題時,許多開發者並不會深入思考爲什麼this 的行爲和預期的不一致,也不
會試圖回答那些很難解決但卻非常重要的問題。他們只會迴避這個問題並使用其他方法來
達到目的,比如創建另一個帶有count 屬性的對象。

function foo(num) {
console.log( "foo: " + num );
// 記錄foo 被調用的次數
data.count++;
}
var data = {
count: 0
};
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被調用了多少次?
console.log( data.count ); // 4

從某種角度來說這個方法確實“解決”了問題,但可惜它忽略了真正的問題——無法理解
this 的含義和工作原理——而是返回舒適區,使用了一種更熟悉的技術:詞法作用域。

詞法作用域是一種非常優秀並且有用的技術。我絲毫沒有貶低它的意思(可
以參考本書第一部分“作用域和閉包”)。但是如果你僅僅是因爲無法猜對
this 的用法,就放棄學習this 而去使用詞法作用域,就不能算是一種很好
的解決辦法了。

如果要從函數對象內部引用它自身,那隻使用this 是不夠的。一般來說你需要通過一個指
向函數對象的詞法標識符(變量)來引用它。
思考一下下面這兩個函數:

function foo() {
foo.count = 4; // foo 指向它自身
}
setTimeout( function(){
// 匿名(沒有名字的)函數無法指向自身
}, 10 );

第一個函數被稱爲具名函數,在它內部可以使用foo 來引用自身。
但是在第二個例子中,傳入setTimeout(..) 的回調函數沒有名稱標識符(這種函數被稱爲
匿名函數),因此無法從函數內部引用自身。

還有一種傳統的但是現在已經被棄用和批判的用法,是使用arguments.
callee 來引用當前正在運行的函數對象。這是唯一一種可以從匿名函數對象
內部引用自身的方法。然而,更好的方式是避免使用匿名函數,至少在需要
自引用時使用具名函數(表達式)。arguments.callee 已經被棄用,不應該再
使用它。

所以,對於我們的例子來說,另一種解決方法是使用foo 標識符替代this 來引用函數
對象:

function foo(num) {
console.log( "foo: " + num );
// 記錄foo 被調用的次數
foo.count++;
}
foo.count=0
var i;
for (i=0; i<10; i++) {
if (i > 5) {
foo( i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被調用了多少次?
console.log( foo.count ); // 4

然而,這種方法同樣迴避了this 的問題,並且完全依賴於變量foo 的詞法作用域。
另一種方法是強制this 指向foo 函數對象:

function foo(num) {
console.log( "foo: " + num );
// 記錄foo 被調用的次數
// 注意,在當前的調用方式下(參見下方代碼),this 確實指向foo
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用call(..) 可以確保this 指向函數對象foo 本身
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被調用了多少次?
console.log( foo.count ); // 4

這次我們接受了this,沒有迴避它。如果你仍然感到困惑的話,不用擔心,之後我們會詳
細解釋具體的原理。

1.2.2 它的作用域

第二種常見的誤解是,this 指向函數的作用域。這個問題有點複雜,因爲在某種情況下它
是正確的,但是在其他情況下它卻是錯誤的。
需要明確的是,this 在任何情況下都不指向函數的詞法作用域。在JavaScript 內部,作用
域確實和對象類似,可見的標識符都是它的屬性。但是作用域“對象”無法通過JavaScript
代碼訪問,它存在於JavaScript 引擎內部。
思考一下下面的代碼,它試圖(但是沒有成功)跨越邊界,使用this 來隱式引用函數的詞
法作用域:

function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo(); // ReferenceError: a is not defined

這段代碼中的錯誤不止一個。雖然這段代碼看起來好像是我們故意寫出來的例子,但是實
際上它出自一個公共社區中互助論壇的精華代碼。這段代碼非常完美(同時也令人傷感)
地展示了this 多麼容易誤導人。
首先,這段代碼試圖通過this.bar() 來引用bar() 函數。這是絕對不可能成功的,我們之
後會解釋原因。調用bar() 最自然的方法是省略前面的this,直接使用詞法引用標識符。
此外,編寫這段代碼的開發者還試圖使用this 聯通foo() 和bar() 的詞法作用域,從而讓
bar() 可以訪問foo() 作用域裏的變量a。這是不可能實現的,你不能使用this 來引用一
個詞法作用域內部的東西。
每當你想要把this 和詞法作用域的查找混合使用時,一定要提醒自己,這是無法實現的。

1.3 this到底是什麼

排除了一些錯誤理解之後,我們來看看this 到底是一種什麼樣的機制。
之前我們說過this 是在運行時進行綁定的,並不是在編寫時綁定,它的上下文取決於函數調
用時的各種條件。this 的綁定和函數聲明的位置沒有任何關係,只取決於函數的調用方式。
當一個函數被調用時,會創建一個活動記錄(有時候也稱爲執行上下文)。這個記錄會包
含函數在哪裏被調用(調用棧)、函數的調用方法、傳入的參數等信息。this 就是記錄的
其中一個屬性,會在函數執行的過程中用到。
在下一章我們會學習如何尋找函數的調用位置,從而判斷函數在執行過程中會如何綁定
this。

1.4 小結

對於那些沒有投入時間學習this 機制的JavaScript 開發者來說,this 的綁定一直是一件非
常令人困惑的事。this 是非常重要的,但是猜測、嘗試並出錯和盲目地從Stack Overflow
上覆制和粘貼答案並不能讓你真正理解this 的機制。
學習this 的第一步是明白this 既不指向函數自身也不指向函數的詞法作用域,你也許被
這樣的解釋誤導過,但其實它們都是錯誤的。
this 實際上是在函數被調用時發生的綁定,它指向什麼完全取決於函數在哪裏被調用。

第二章 this全面解析

在第1 章中,我們排除了一些對於this 的錯誤理解並且明白了每個函數的this 是在調用
時被綁定的,完全取決於函數的調用位置(也就是函數的調用方法)。

2.1 調用位置

在理解this 的綁定過程之前,首先要理解調用位置:調用位置就是函數在代碼中被調用的
位置(而不是聲明的位置)。只有仔細分析調用位置才能回答這個問題:這個this 到底引
用的是什麼?
通常來說,尋找調用位置就是尋找“函數被調用的位置”,但是做起來並沒有這麼簡單,
因爲某些編程模式可能會隱藏真正的調用位置。
最重要的是要分析調用棧(就是爲了到達當前執行位置所調用的所有函數)。我們關心的
調用位置就在當前正在執行的函數的前一個調用中。
下面我們來看看到底什麼是調用棧和調用位置:

function baz() {
// 當前調用棧是:baz
// 因此,當前調用位置是全局作用域
console.log( "baz" );
bar(); // <-- bar 的調用位置
}
function bar() {
    // 當前調用棧是baz -> bar
// 因此,當前調用位置在baz 中
console.log( "bar" );
foo(); // <-- foo 的調用位置
}
function foo() {
// 當前調用棧是baz -> bar -> foo
// 因此,當前調用位置在bar 中
console.log( "foo" );
}
baz(); // <-- baz 的調用位置

注意我們是如何(從調用棧中)分析出真正的調用位置的,因爲它決定了this 的綁定。

你可以把調用棧想象成一個函數調用鏈,就像我們在前面代碼段的註釋中所
寫的一樣。但是這種方法非常麻煩並且容易出錯。另一個查看調用棧的方法
是使用瀏覽器的調試工具。絕大多數現代桌面瀏覽器都內置了開發者工具,
其中包含JavaScript 調試器。就本例來說,你可以在工具中給foo() 函數的
第一行代碼設置一個斷點,或者直接在第一行代碼之前插入一條debugger;
語句。運行代碼時,調試器會在那個位置暫停,同時會展示當前位置的函數
調用列表,這就是你的調用棧。因此,如果你想要分析this 的綁定,使用開
發者工具得到調用棧,然後找到棧中第二個元素,這就是真正的調用位置。

2.2 綁定規則

我們來看看在函數的執行過程中調用位置如何決定this 的綁定對象。
你必須找到調用位置,然後判斷需要應用下面四條規則中的哪一條。我們首先會分別解釋
這四條規則,然後解釋多條規則都可用時它們的優先級如何排列。

2.2.1 默認綁定

首先要介紹的是最常用的函數調用類型:獨立函數調用。可以把這條規則看作是無法應用
其他規則時的默認規則。
思考一下下面的代碼:

function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2

你應該注意到的第一件事是,聲明在全局作用域中的變量(比如var a = 2)就是全局對
象的一個同名屬性。它們本質上就是同一個東西,並不是通過複製得到的,就像一個硬幣
的兩面一樣。
接下來我們可以看到當調用foo() 時,this.a 被解析成了全局變量a。爲什麼?因爲在本
例中,函數調用時應用了this 的默認綁定,因此this 指向全局對象。
那麼我們怎麼知道這裏應用了默認綁定呢?可以通過分析調用位置來看看foo() 是如何調
用的。在代碼中,foo() 是直接使用不帶任何修飾的函數引用進行調用的,因此只能使用
默認綁定,無法應用其他規則。
如果使用嚴格模式(strict mode),那麼全局對象將無法使用默認綁定,因此this 會綁定
到undefined:

function foo() {
"use strict";
console.log( this.a );
}
var a = 2;
foo(); // TypeError: this is undefined

這裏有一個微妙但是非常重要的細節,雖然this 的綁定規則完全取決於調用位置,但是隻
有foo() 運行在非strict mode 下時,默認綁定才能綁定到全局對象;嚴格模式下與foo()
的調用位置無關:

function foo() {
console.log( this.a );
}
var a = 2;
(function(){
"use strict";
foo(); // 2
})();

通常來說你不應該在代碼中混合使用strict mode 和non-strict mode。整個
程序要麼嚴格要麼非嚴格。然而,有時候你可能會用到第三方庫,其嚴格程
度和你的代碼有所不同,因此一定要注意這類兼容性細節。

2.2.2 隱式綁定

另一條需要考慮的規則是調用位置是否有上下文對象,或者說是否被某個對象擁有或者包
含,不過這種說法可能會造成一些誤導。
思考下面的代碼:

function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

首先需要注意的是foo() 的聲明方式,及其之後是如何被當作引用屬性添加到obj 中的。
但是無論是直接在obj 中定義還是先定義再添加爲引用屬性,這個函數嚴格來說都不屬於
obj 對象。
然而,調用位置會使用obj 上下文來引用函數,因此你可以說函數被調用時obj 對象“擁
有”或者“包含”它。
無論你如何稱呼這個模式,當foo() 被調用時,它的落腳點確實指向obj 對象。當函數引
用有上下文對象時,隱式綁定規則會把函數調用中的this 綁定到這個上下文對象。因爲調
用foo() 時this 被綁定到obj,因此this.a 和obj.a 是一樣的。
對象屬性引用鏈中只有最頂層或者說最後一層會影響調用位置。舉例來說:

function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
  • 隱式丟失

一個最常見的this 綁定問題就是被隱式綁定的函數會丟失綁定對象,也就是說它會應用默
認綁定,從而把this 綁定到全局對象或者undefined 上,取決於是否是嚴格模式。
思考下面的代碼:

function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函數別名!
var a = "oops, global"; // a 是全局對象的屬性
bar(); // "oops, global"

雖然bar 是obj.foo 的一個引用,但是實際上,它引用的是foo 函數本身,因此此時的
bar() 其實是一個不帶任何修飾的函數調用,因此應用了默認綁定。
一種更微妙、更常見並且更出乎意料的情況發生在傳入回調函數時:

function foo() {
console.log( this.a );
}
function doFoo(fn) {
// fn 其實引用的是foo
fn(); // <-- 調用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局對象的屬性
doFoo( obj.foo ); // "oops, global"

參數傳遞其實就是一種隱式賦值,因此我們傳入函數時也會被隱式賦值,所以結果和上一
個例子一樣。
如果把函數傳入語言內置的函數而不是傳入你自己聲明的函數,會發生什麼呢?結果是一
樣的,沒有區別:

function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局對象的屬性
setTimeout( obj.foo, 100 ); // "oops, global"

JavaScript 環境中內置的setTimeout() 函數實現和下面的僞代碼類似:

function setTimeout(fn,delay) {
// 等待delay 毫秒
fn(); // <-- 調用位置!
}

就像我們看到的那樣,回調函數丟失this 綁定是非常常見的。除此之外,還有一種情
況this 的行爲會出乎我們意料:調用回調函數的函數可能會修改this。在一些流行的
JavaScript 庫中事件處理器常會把回調函數的this 強制綁定到觸發事件的DOM 元素上。
這在一些情況下可能很有用,但是有時它可能會讓你感到非常鬱悶。遺憾的是,這些工具
通常無法選擇是否啓用這個行爲。
無論是哪種情況,this 的改變都是意想不到的,實際上你無法控制回調函數的執行方式,
因此就沒有辦法控制會影響綁定的調用位置。之後我們會介紹如何通過固定this 來修復
(這裏是雙關,“修復”和“固定”的英語單詞都是fixing)這個問題。

2.2.3 顯式綁定

就像我們剛纔看到的那樣,在分析隱式綁定時,我們必須在一個對象內部包含一個指向函
數的屬性,並通過這個屬性間接引用函數,從而把this 間接(隱式)綁定到這個對象上。
那麼如果我們不想在對象內部包含函數引用,而想在某個對象上強制調用函數,該怎麼
做呢?
JavaScript 中的“所有”函數都有一些有用的特性(這和它們的[[ 原型]] 有關——之後我
們會詳細介紹原型),可以用來解決這個問題。具體點說,可以使用函數的call(..) 和
apply(..) 方法。嚴格來說,JavaScript 的宿主環境有時會提供一些非常特殊的函數,它們
並沒有這兩個方法。但是這樣的函數非常罕見,JavaScript 提供的絕大多數函數以及你自
己創建的所有函數都可以使用call(..) 和apply(..) 方法。
這兩個方法是如何工作的呢?它們的第一個參數是一個對象,它們會把這個對象綁定到
this,接着在調用函數時指定這個this。因爲你可以直接指定this 的綁定對象,因此我
們稱之爲顯式綁定。
思考下面的代碼:

function foo() {
console.log( this.a );
}
var obj = {
a:2
};
foo.call( obj ); // 2

通過foo.call(..),我們可以在調用foo 時強制把它的this 綁定到obj 上。
如果你傳入了一個原始值(字符串類型、布爾類型或者數字類型)來當作this 的綁定對
象,這個原始值會被轉換成它的對象形式(也就是new String(..)、new Boolean(..) 或者
new Number(..))。這通常被稱爲“裝箱”。

從this 綁定的角度來說,call(..) 和apply(..) 是一樣的,它們的區別體現
在其他的參數上,但是現在我們不用考慮這些。

可惜,顯式綁定仍然無法解決我們之前提出的丟失綁定問題。

  1. 硬綁定

但是顯式綁定的一個變種可以解決這個問題。
思考下面的代碼:

function foo() {
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬綁定的bar 不可能再修改它的this
bar.call( window ); // 2

我們來看看這個變種到底是怎樣工作的。我們創建了函數bar(),並在它的內部手動調用
了foo.call(obj),因此強制把foo 的this 綁定到了obj。無論之後如何調用函數bar,它
總會手動在obj 上調用foo。這種綁定是一種顯式的強制綁定,因此我們稱之爲硬綁定。
硬綁定的典型應用場景就是創建一個包裹函數,傳入所有的參數並返回接收到的所有值:

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5

另一種使用方法是創建一個i 可以重複使用的輔助函數:

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
// 簡單的輔助綁定函數
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由於硬綁定是一種非常常用的模式,所以在ES5 中提供了內置的方法Function.prototype.
bind,它的用法如下:

function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

bind(..) 會返回一個硬編碼的新函數,它會把參數設置爲this 的上下文並調用原始函數。

  1. API調用的“上下文”

第三方庫的許多函數,以及JavaScript 語言和宿主環境中許多新的內置函數,都提供了一
個可選的參數,通常被稱爲“上下文”(context),其作用和bind(..) 一樣,確保你的回調
函數使用指定的this。
舉例來說:

function foo(el) {
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 調用foo(..) 時把this 綁定到obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome

這些函數實際上就是通過call(..) 或者apply(..) 實現了顯式綁定,這樣你可以少些一些
代碼。

2.2.4 new綁定

這是第四條也是最後一條this 的綁定規則,在講解它之前我們首先需要澄清一個非常常見
的關於JavaScript 中函數和對象的誤解。
在傳統的面向類的語言中,“構造函數”是類中的一些特殊方法,使用new 初始化類時會
調用類中的構造函數。通常的形式是這樣的:

something = new MyClass(..);

JavaScript 也有一個new 操作符,使用方法看起來也和那些面向類的語言一樣,絕大多數開
發者都認爲JavaScript 中new 的機制也和那些語言一樣。然而,JavaScript 中new 的機制實
際上和麪向類的語言完全不同。
首先我們重新定義一下JavaScript 中的“構造函數”。在JavaScript 中,構造函數只是一些
使用new 操作符時被調用的函數。它們並不會屬於某個類,也不會實例化一個類。實際上,
它們甚至都不能說是一種特殊的函數類型,它們只是被new 操作符調用的普通函數而已。
舉例來說,思考一下Number(..) 作爲構造函數時的行爲,ES5.1 中這樣描述它:

15.7.2 Number 構造函數
當Number 在new 表達式中被調用時,它是一個構造函數:它會初始化新創建的
對象。

所以,包括內置對象函數(比如Number(..),詳情請查看第3 章)在內的所有函數都可
以用new 來調用,這種函數調用被稱爲構造函數調用。這裏有一個重要但是非常細微的區
別:實際上並不存在所謂的“構造函數”,只有對於函數的“構造調用”。
使用new 來調用函數,或者說發生構造函數調用時,會自動執行下面的操作。

  • 創建(或者說構造)一個全新的對象。
  • 這個新對象會被執行[[ 原型]] 連接。
  • 這個新對象會綁定到函數調用的this。
  • 如果函數沒有返回其他對象,那麼new 表達式中的函數調用會自動返回這個新對象。

我們現在關心的是第1 步、第3 步、第4 步,所以暫時跳過第2 步,第5 章會詳細介紹它。
思考下面的代碼:

function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2

使用new 來調用foo(..) 時,我們會構造一個新對象並把它綁定到foo(..) 調用中的this
上。new 是最後一種可以影響函數調用時this 綁定行爲的方法,我們稱之爲new 綁定。

2.3 優先級

現在我們已經瞭解了函數調用中this 綁定的四條規則,你需要做的就是找到函數的調用位
置並判斷應當應用哪條規則。但是,如果某個調用位置可以應用多條規則該怎麼辦?爲了
解決這個問題就必須給這些規則設定優先級,這就是我們接下來要介紹的內容。
毫無疑問,默認綁定的優先級是四條規則中最低的,所以我們可以先不考慮它。
隱式綁定和顯式綁定哪個優先級更高?我們來測試一下:

function foo() {
console.log( this.a );
}
var obj1 = {
a: 2,
foo: foo
};
var obj2 = {
a: 3,
foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

可以看到,顯式綁定優先級更高,也就是說在判斷時應當先考慮是否可以應用顯式綁定。
現在我們需要搞清楚new 綁定和隱式綁定的優先級誰高誰低:

function foo(something) {
this.a = something;
}
var obj1 = {
foo: foo
};
var obj2 = {};
obj1.foo( 2 );
console.log( obj1.a ); // 2
obj1.foo.call( obj2, 3 );
console.log( obj2.a ); // 3
var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

可以看到new 綁定比隱式綁定優先級高。但是new 綁定和顯式綁定誰的優先級更高呢?

new 和call/apply 無法一起使用,因此無法通過new foo.call(obj1) 來直接
進行測試。但是我們可以使用硬綁定來測試它倆的優先級。

在看代碼之前先回憶一下硬綁定是如何工作的。Function.prototype.bind(..) 會創建一個
新的包裝函數,這個函數會忽略它當前的this 綁定(無論綁定的對象是什麼),並把我們
提供的對象綁定到this 上。
這樣看起來硬綁定(也是顯式綁定的一種)似乎比new 綁定的優先級更高,無法使用new
來控制this 綁定。
我們看看是不是這樣:

function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2
var baz = new bar(3);
console.log( obj1.a ); // 2

出乎意料! bar 被硬綁定到obj1 上,但是new bar(3) 並沒有像我們預計的那樣把obj1.a
修改爲3。相反,new 修改了硬綁定(到obj1 的)調用bar(..) 中的this。因爲使用了
new 綁定,我們得到了一個名字爲baz 的新對象,並且baz.a 的值是3。
再來看看我們之前介紹的“裸”輔助函數bind:

function bind(fn, obj) {
return function() {
fn.apply( obj, arguments );
};
}

非常令人驚訝,因爲看起來在輔助函數中new 操作符的調用無法修改this 綁定,但是在剛
才的代碼中new 確實修改了this 綁定。
實際上,ES5 中內置的Function.prototype.bind(..) 更加複雜。下面是MDN 提供的一種
bind(..) 實現,爲了方便閱讀我們對代碼進行了排版:

if (!Function.prototype.bind) {
Function.prototype.bind = function(oThis) {
if (typeof this !== "function") {
// 與 ECMAScript 5 最接近的
// 內部 IsCallable 函數
throw new TypeError(
"Function.prototype.bind - what is trying " +
"to be bound is not callable"
);
}
var aArgs = Array.prototype.slice.call( arguments, 1 ),
fToBind = this,
fNOP = function(){},
fBound = function(){
return fToBind.apply(
(
this instanceof fNOP &&
oThis ? this : oThis
),
aArgs.concat(
Array.prototype.slice.call( arguments )
);
}
;
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}

這種bind(..) 是一種polyfill 代碼(polyfill 就是我們常說的刮牆用的膩
子,polyfill 代碼主要用於舊瀏覽器的兼容,比如說在舊的瀏覽器中並沒
有內置bind 函數,因此可以使用polyfill 代碼在舊瀏覽器中實現新的功
能),對於new 使用的硬綁定函數來說,這段polyfill 代碼和ES5 內置的
bind(..) 函數並不完全相同(後面會介紹爲什麼要在new 中使用硬綁定函
數)。由於polyfill 並不是內置函數,所以無法創建一個不包含.prototype
的函數,因此會具有一些副作用。如果你要在new 中使用硬綁定函數並且依
賴polyfill 代碼的話,一定要非常小心。

下面是new 修改this 的相關代碼:

this instanceof fNOP &&
oThis ? this : oThis
// ... 以及:
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();

我們並不會詳細解釋這段代碼做了什麼(這非常複雜並且不在我們的討論範圍之內),不
過簡單來說,這段代碼會判斷硬綁定函數是否是被new 調用,如果是的話就會使用新創建
的this 替換硬綁定的this。
那麼,爲什麼要在new 中使用硬綁定函數呢?直接使用普通函數不是更簡單嗎?
之所以要在new 中使用硬綁定函數,主要目的是預先設置函數的一些參數,這樣在使用
new 進行初始化時就可以只傳入其餘的參數。bind(..) 的功能之一就是可以把除了第一個
參數(第一個參數用於綁定this)之外的其他參數都傳給下層的函數(這種技術稱爲“部
分應用”,是“柯里化”的一種)。舉例來說:

function foo(p1,p2) {
this.val = p1 + p2;
}
// 之所以使用null 是因爲在本例中我們並不關心硬綁定的this 是什麼
// 反正使用new 時this 會被修改
var bar = foo.bind( null, "p1" );
var baz = new bar( "p2" );
baz.val; // p1p2
  • 判斷this

現在我們可以根據優先級來判斷函數在某個調用位置應用的是哪條規則。可以按照下面的
順序來進行判斷:

  • 函數是否在new 中調用(new 綁定)?如果是的話this 綁定的是新創建的對象。
var bar = new foo()
  • 函數是否通過call、apply(顯式綁定)或者硬綁定調用?如果是的話,this 綁定的是
    指定的對象。
var bar = foo.call(obj2)
  • 函數是否在某個上下文對象中調用(隱式綁定)?如果是的話,this 綁定的是那個上
    下文對象。
6.6 小結

在軟件架構中你可以選擇是否使用類和繼承設計模式。大多數開發者理所當然地認爲類是
唯一(合適)的代碼組織方式,但是本章中我們看到了另一種更少見但是更強大的設計模
式:行爲委託。
行爲委託認爲對象之間是兄弟關係,互相委託,而不是父類和子類的關係。JavaScript 的
[[Prototype]] 機制本質上就是行爲委託機制。也就是說,我們可以選擇在JavaScript 中努
力實現類機制(參見第4 和第5 章),也可以擁抱更自然的[[Prototype]] 委託機制。
當你只用對象來設計代碼時,不僅可以讓語法更加簡潔,而且可以讓代碼結構更加清晰。
對象關聯(對象之前互相關聯)是一種編碼風格,它倡導的是直接創建和關聯對象,不把
它們抽象成類。對象關聯可以用基於[[Prototype]] 的行爲委託非常自然地實現。


《你不知道的JavaScript 上卷》下載地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章