JavaScript this繫結過程
在理解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 的繫結,使用開發者工具得到呼叫棧,然後找到棧中第二個元素,這就是真正的呼叫位置。
前端全棧學習交流圈:866109386,面向1-3經驗年前端開發人員,幫助突破技術瓶頸,提升思維能力,群內有大量PDF可供自取,更有乾貨實戰專案視訊進群免費領取。
繫結規則
我們來看看在函式的執行過程中呼叫位置如何決定this 的繫結物件。你必須找到呼叫位置,然後判斷需要應用下面四條規則中的哪一條。
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
前端全棧學習交流圈:866109386,面向1-3經驗年前端開發人員,幫助突破技術瓶頸,提升思維能力,群內有大量PDF可供自取,更有乾貨實戰專案視訊進群免費領取。
這裡有一個微妙但是非常重要的細節,雖然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.隱式繫結
另一條需要考慮的規則是呼叫位置是否有上下文物件,或者說是否被某個物件擁有或者包含,不過這種說法可能會造成一些誤導。
思考下面的程式碼:
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)這個問題。
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
前端全棧學習交流圈:866109386,面向1-3經驗年前端開發人員,幫助突破技術瓶頸,提升思維能力,群內有大量PDF可供自取,更有乾貨實戰專案視訊進群免費領取。
bind(…) 會返回一個硬編碼的新函式,它會把引數設定為this 的上下文並呼叫原始函式。
2. 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(…) 實現了顯式繫結,這樣你可以少些一些程式碼。
4.new繫結
這是第四條也是最後一條this 的繫結規則,在講解它之前我們首先需要澄清一個非常常見的關於JavaScript 中函式和物件的誤解。在傳統的面向類的語言中,“建構函式”是類中的一些特殊方法,使用new 初始化類時會呼叫類中的建構函式。通常的形式是這樣的:
something = new MyClass(..);
JavaScript 也有一個new 操作符,使用方法看起來也和那些面向類的語言一樣,絕大多數開發者都認為JavaScript 中new 的機制也和那些語言一樣。然而JavaScript 中new 的機制實際上和麵向類的語言完全不同。
首先我們重新定義一下JavaScript 中的“建構函式”。在JavaScript 中,建構函式只是一些使用new 操作符時被呼叫的函式。它們並不會屬於某個類,也不會例項化一個類。實際上,它們甚至都不能說是一種特殊的函式型別,它們只是被new 操作符呼叫的普通函式而已。
舉例來說,思考一下Number(…) 作為建構函式時的行為,ES5.1 中這樣描述它:
Number 建構函式
當Number 在new 表示式中被呼叫時,它是一個建構函式:它會初始化新建立的物件。
所以,包括內建物件函式(比如Number(…))在內的所有函式都可以用new 來呼叫,這種函式呼叫被稱為建構函式呼叫。這裡有一個重要但是非常細微的區別:實際上並不存在所謂的“建構函式”,只有對於函式的“構造呼叫”。使用new 來呼叫函式,或者說發生建構函式呼叫時,會自動執行下面的操作。
- 建立(或者說構造)一個全新的物件。
- 這個新物件會被執行[[ 原型]] 連線。
- 這個新物件會繫結到函式呼叫的this。
- 如果函式沒有返回其他物件,那麼new 表示式中的函式呼叫會自動返回這個新物件。
思考下面的程式碼:
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
使用new 來呼叫foo(…) 時,我們會構造一個新物件並把它繫結到foo(…) 呼叫中的this上。new 是最後一種可以影響函式呼叫時this 繫結行為的方法,我們稱之為new 繫結。
最後
為了幫助大家讓學習變得輕鬆、高效,給大家免費分享一大批資料,幫助大家在成為全棧工程師,乃至架構師的路上披荊斬棘。在這裡給大家推薦一個前端全棧學習交流圈:866109386.歡迎大家進群交流討論,學習交流,共同進步。
當真正開始學習的時候難免不知道從哪入手,導致效率低下影響繼續學習的信心。
但最重要的是不知道哪些技術需要重點掌握,學習時頻繁踩坑,最終浪費大量時間,所以有有效資源還是很有必要的。
最後祝福所有遇到瓶疾且不知道怎麼辦的前端程式設計師們,祝福大家在往後的工作與面試中一切順利。