你不懂JS: this 與對(duì)象原型
第一章: this 是什么?
JavaScript 中最令人困惑的機(jī)制之一就是 this 關(guān)鍵字。它是一個(gè)在每個(gè)函數(shù)作用域中自動(dòng)定義的特殊標(biāo)識(shí)符關(guān)鍵字,但即便是一些老練的 JavaScript 開發(fā)者也對(duì)它到底指向什么感到困擾。
任何足夠 先進(jìn) 的技術(shù)都跟魔法沒有區(qū)別。-- Arthur C. Clarke
JavaScript 的 this 機(jī)制實(shí)際上沒有 那么 先進(jìn),但是開發(fā)者們總是在大腦中插入“復(fù)雜”和“混亂”來解釋這句話,毫無(wú)疑問,如果沒有清晰的理解,在 你的 困惑中 this 可能看起來就是徹頭徹尾的魔法。
注意: “this”這個(gè)詞是在一般的論述中極常用的代詞。所以,特別是在口頭論述中,很難確定我們是在將“this”作為一個(gè)代詞使用,還是在將它作為一個(gè)實(shí)際的關(guān)鍵字標(biāo)識(shí)符使用。為了表意清晰,我會(huì)總是使用 this 來代表特殊的關(guān)鍵字,而在其他情況下使用“this”或 this 或 this。
為什么要用 this?
如果對(duì)于那些老練的 JavaScript 開發(fā)者來說 this 機(jī)制都是如此的令人費(fèi)解,那么有人會(huì)問為什么這種機(jī)制會(huì)有用?它帶來的麻煩不是比好處多嗎?在講解 如何 有用之前,我們應(yīng)當(dāng)先來看看 為什么 有用。
讓我們?cè)囍故疽幌?this 的動(dòng)機(jī)和用途:
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, I'm KYLE
speak.call( you ); // Hello, I'm READER
如果這個(gè)代碼段 如何 工作讓你困惑,不要擔(dān)心!我們很快就會(huì)講解它。只是簡(jiǎn)要地將這些問題放在旁邊,以便于我們可以更清晰的探究 為什么。
這個(gè)代碼片段允許 identify() 和 speak() 函數(shù)對(duì)多個(gè) 環(huán)境 對(duì)象(me 和 you)進(jìn)行復(fù)用,而不是針對(duì)每個(gè)對(duì)象定義函數(shù)的分離版本。
與使用 this 相反地,你可以明確地將環(huán)境對(duì)象傳遞給 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, I'm KYLE
然而,this 機(jī)制提供了更優(yōu)雅的方式來隱含地“傳遞”一個(gè)對(duì)象引用,導(dǎo)致更加干凈的API設(shè)計(jì)和更容易的復(fù)用。
你的使用模式越復(fù)雜,你就會(huì)越清晰地看到:將執(zhí)行環(huán)境作為一個(gè)明確參數(shù)傳遞,通常比傳遞 this 執(zhí)行環(huán)境要亂。當(dāng)我們探索對(duì)象和原型時(shí),你將會(huì)看到一組可以自動(dòng)引用恰當(dāng)執(zhí)行環(huán)境對(duì)象的函數(shù)是多么有用。
困惑
我們很快就要開始講解 this 是如何 實(shí)際 工作的,但我們首先要摒棄一些誤解——它實(shí)際上 不是 如何工作的。
在開發(fā)者們用太過于字面的方式考慮“this”這個(gè)名字時(shí)就會(huì)產(chǎn)生困惑。這通常會(huì)產(chǎn)生兩種臆測(cè),但都是不對(duì)的。
它自己
第一種常見的傾向是認(rèn)為 this 指向函數(shù)自己。至少,這是一種語(yǔ)法上的合理推測(cè)。
為什么你想要在函數(shù)內(nèi)部引用它自己?最常見的理由是遞歸(在函數(shù)內(nèi)部調(diào)用它自己)這樣的情形,或者是一個(gè)在第一次被調(diào)用時(shí)會(huì)解除自己綁定的事件處理器。
初次接觸 JS 機(jī)制的開發(fā)者們通常認(rèn)為,將函數(shù)作為一個(gè)對(duì)象(JavaScript 中所有的函數(shù)都是對(duì)象?。?,可以讓你在方法調(diào)用之間儲(chǔ)存 狀態(tài)(屬性中的值)。這當(dāng)然是可能的,而且有一些有限的用處,但這本書的其余部分將會(huì)闡述許多其他的模式,提供比函數(shù)對(duì)象 更好 的地方來存儲(chǔ)狀態(tài)。
過一會(huì)兒我們將探索一個(gè)模式,來展示 this 是如何不讓一個(gè)函數(shù)像我們可能假設(shè)的那樣,得到它自身的引用的。
考慮下面的代碼,我們?cè)噲D追蹤函數(shù)(foo)被調(diào)用了多少次:
function foo(num) {
console.log( "foo: " + num );
// 追蹤 `foo` 被調(diào)用了多少次
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` 被調(diào)用了多少次?
console.log( foo.count ); // 0 -- 這他媽怎么回事……?
foo.count 依然 是 0, 即便四個(gè) console.log 語(yǔ)句明明告訴我們 foo(..) 實(shí)際上被調(diào)用了四次。這種挫敗來源于對(duì)于 this(在 this.count++ 中)的含義進(jìn)行了 過于字面化 的解釋。
當(dāng)代碼執(zhí)行 foo.count = 0 時(shí),它確實(shí)向函數(shù)對(duì)象 foo 添加了一個(gè) count 屬性。但是對(duì)于函數(shù)內(nèi)部的 this.count 引用,this 其實(shí) 根本就不 指向那個(gè)函數(shù)對(duì)象,即便屬性名稱一樣,但根對(duì)象也不同,因而產(chǎn)生了混淆。
注意: 一個(gè)負(fù)責(zé)任的開發(fā)者 應(yīng)當(dāng) 在這里提出一個(gè)問題:“如果我遞增的 count 屬性不是我以為的那個(gè),那是哪個(gè) count 被我遞增了?”。實(shí)際上,如果他再挖的深一些,他會(huì)發(fā)現(xiàn)自己不小心創(chuàng)建了一個(gè)全局變量 count(第二章解釋了這是 如何 發(fā)生的?。宜?dāng)前的值是 NaN。當(dāng)然,一旦他發(fā)現(xiàn)這個(gè)不尋常的結(jié)果后,他會(huì)有一堆其他的問題:“它怎么是全局的?為什么它是 NaN 而不是某個(gè)正確的計(jì)數(shù)值?”。(見第二章)
與停在這里來深究為什么 this 引用看起來不是如我們 期待 的那樣工作,并且回答那些尖銳且重要的問題相反,許多開發(fā)者簡(jiǎn)單地完全回避這個(gè)問題,轉(zhuǎn)向一些其他的另類解決方法,比如創(chuàng)建另一個(gè)對(duì)象來持有 count 屬性:
function foo(num) {
console.log( "foo: " + num );
// 追蹤 `foo` 被調(diào)用了多少次
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` 被調(diào)用了多少次?
console.log( data.count ); // 4
雖然這種方式“解決”了問題是事實(shí),但不幸的是它簡(jiǎn)單地忽略了真正的問題 —— 缺乏對(duì)于 this 的含義和其工作方式上的理解 —— 反而退回到了一個(gè)他更加熟悉的機(jī)制的舒適區(qū):詞法作用域。
注意: 詞法作用域是一個(gè)完善且有用的機(jī)制;我不是在用任何方式貶低它的作用(參見本系列的 "作用域與閉包")。但在如何使用 this 這個(gè)問題上總是靠 猜,而且通常都猜 錯(cuò),并不是一個(gè)退回到詞法作用域,而且從不學(xué)習(xí) 為什么 this 不跟你合作的好理由。
為了從函數(shù)對(duì)象內(nèi)部引用它自己,一般來說通過 this 是不夠的。你通常需要通過一個(gè)指向它的詞法標(biāo)識(shí)符(變量)得到函數(shù)對(duì)象的引用。
考慮這兩個(gè)函數(shù):
function foo() {
foo.count = 4; // `foo` 引用它自己
}
setTimeout( function(){
// 匿名函數(shù)(沒有名字)不能引用它自己
}, 10 );
第一個(gè)函數(shù),稱為“命名函數(shù)”,foo 是一個(gè)引用,可以用于在它內(nèi)部引用自己。
但是在第二個(gè)例子中,傳遞給 setTimeout(..) 的回調(diào)函數(shù)沒有名稱標(biāo)識(shí)符(所以被稱為“匿名函數(shù)”),所以沒有合適的辦法引用函數(shù)對(duì)象自己。
注意: 在函數(shù)中有一個(gè)老牌兒但是現(xiàn)在被廢棄的,而且令人皺眉頭的 arguments.callee 引用 也 指向當(dāng)前正在執(zhí)行的函數(shù)的函數(shù)對(duì)象。這個(gè)引用通常是匿名函數(shù)在自己內(nèi)部訪問函數(shù)對(duì)象的唯一方法。然而,最佳的辦法是完全避免使用匿名函數(shù),至少是對(duì)于那些需要自引用的函數(shù),而使用命名函數(shù)(表達(dá)式)。arguments.callee 已經(jīng)被廢棄而且不應(yīng)該再使用。
對(duì)于當(dāng)前我們的例子來說,另一個(gè) 好用的 解決方案是在每一個(gè)地方都使用 foo 標(biāo)識(shí)符作為函數(shù)對(duì)象的引用,而根本不用this:
function foo(num) {
console.log( "foo: " + num );
// 追蹤 `foo` 被調(diào)用了多少次
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` 被調(diào)用了多少次?
console.log( foo.count ); // 4
然而,這種方法也類似地回避了對(duì) this 的 真正 理解,而且完全依靠變量 foo 的詞法作用域。
另一種解決這個(gè)問題的方法是強(qiáng)迫 this 指向 foo 函數(shù)對(duì)象:
function foo(num) {
console.log( "foo: " + num );
// 追蹤 `foo` 被調(diào)用了多少次
// 注意:由于 `foo` 的被調(diào)用方式(見下方),`this` 現(xiàn)在確實(shí)是 `foo`
this.count++;
}
foo.count = 0;
var i;
for (i=0; i<10; i++) {
if (i > 5) {
// 使用 `call(..)`,我們可以保證 `this` 指向函數(shù)對(duì)象(`foo`)
foo.call( foo, i );
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// `foo` 被調(diào)用了多少次?
console.log( foo.count ); // 4
與回避 this 相反,我們接受它。 我們馬上將會(huì)更完整地講解這樣的技術(shù) 如何 工作,所以如果你依然有點(diǎn)兒糊涂,不要擔(dān)心!
它的作用域
對(duì) this 的含義第二常見的誤解,是它不知怎的指向了函數(shù)的作用域。這是一個(gè)刁鉆的問題,因?yàn)樵谀骋环N意義上它有正確的部分,而在另外一種意義上,它是嚴(yán)重的誤導(dǎo)。
明確地說,this 不會(huì)以任何方式指向函數(shù)的 詞法作用域。作用域好像是一個(gè)將所有可用標(biāo)識(shí)符作為屬性的對(duì)象,這從內(nèi)部來說是對(duì)的。但是 JavasScript 代碼不能訪問作用域“對(duì)象”。它是 引擎 的內(nèi)部實(shí)現(xiàn)。
考慮下面代碼,它(失敗的)企圖跨越這個(gè)邊界,用 this 來隱含地引用函數(shù)的詞法作用域:
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo(); //undefined
這個(gè)代碼段里不只有一個(gè)錯(cuò)誤。雖然它看起來是在故意瞎搞,但你看到的這段代碼,提取自在公共社區(qū)的幫助論壇中被交換的真實(shí)代碼。真是難以想象對(duì) this 的臆想是多么的誤導(dǎo)人。
首先,試圖通過 this.bar() 來引用 bar() 函數(shù)。它幾乎可以說是 碰巧 能夠工作,我們過一會(huì)兒再解釋它是 如何 工作的。調(diào)用 bar() 最自然的方式是省略開頭的 this.,而僅使用標(biāo)識(shí)符進(jìn)行詞法引用。
然而,寫下這段代碼的開發(fā)者試圖用 this 在 foo() 和 bar() 的詞法作用域間建立一座橋,使得bar() 可以訪問 foo()內(nèi)部作用域的變量 a。這樣的橋是不可能的。 你不能使用 this 引用在詞法作用域中查找東西。這是不可能的。
每當(dāng)你感覺自己正在試圖使用 this 來進(jìn)行詞法作用域的查詢時(shí),提醒你自己:這里沒有橋。
什么是 this?
我們已經(jīng)列舉了各種不正確的臆想,現(xiàn)在讓我們把注意力轉(zhuǎn)移到 this 機(jī)制是如何真正工作的。
我們?cè)缦日f過,this 不是編寫時(shí)綁定,而是運(yùn)行時(shí)綁定。它依賴于函數(shù)調(diào)用的上下文條件。this 綁定與函數(shù)聲明的位置沒有任何關(guān)系,而與函數(shù)被調(diào)用的方式緊密相連。
當(dāng)一個(gè)函數(shù)被調(diào)用時(shí),會(huì)建立一個(gè)稱為執(zhí)行環(huán)境的活動(dòng)記錄。這個(gè)記錄包含函數(shù)是從何處(調(diào)用棧 —— call-stack)被調(diào)用的,函數(shù)是 如何 被調(diào)用的,被傳遞了什么參數(shù)等信息。這個(gè)記錄的屬性之一,就是在函數(shù)執(zhí)行期間將被使用的 this 引用。
下一章中,我們將會(huì)學(xué)習(xí)尋找函數(shù)的 調(diào)用點(diǎn)(call-site) 來判定它的執(zhí)行如何綁定 this。
復(fù)習(xí)
對(duì)于那些沒有花時(shí)間學(xué)習(xí) this 綁定機(jī)制如何工作的 JavaScript 開發(fā)者來說,this 綁定一直是困惑的根源。對(duì)于 this 這么重要的機(jī)制來說,猜測(cè)、試錯(cuò)、或者盲目地從 Stack Overflow 的回答中復(fù)制粘貼,都不是有效或正確利用它的方法。
為了學(xué)習(xí) this,你必須首先學(xué)習(xí) this不是 什么,不論是哪種把你誤導(dǎo)至何處的臆測(cè)或誤解。this 既不是函數(shù)自身的引用,也不是函數(shù) 詞法 作用域的引用。
this 實(shí)際上是在函數(shù)被調(diào)用時(shí)建立的一個(gè)綁定,它指向 什么 是完全由函數(shù)被調(diào)用的調(diào)用點(diǎn)來決定的。