JavaScript開發教程:函數this全面解析

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 的綁定。

綁定規則

下面將會解釋,函數的執行過程中調用位置如何決定 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。

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"

參數傳遞其實就是一種隱式賦值,因此我們傳入函數時也會被隱式賦值,所以結果和上一個例子一樣。

如果把函數傳入語言內置的函數而不是傳入你自己聲明的函數,結果是一樣的,沒有區別。

就像我們看到的那樣,回調函數丟失 this 綁定是非常常見的。除此之外,還有一種情 況 this 的行為會出乎我們意料:調用回調函數的函數可能會修改 this。
在一些流行的 JavaScript 庫中事件處理器常會把回調函數的 this 強制綁定到觸發事件的 DOM 元素上。 這在一些情況下可能很有用,但是有時它可能會讓你感到非常鬱悶。遺憾的是,這些工具通常無法選擇是否啟用這個行為。

無論是哪種情況,this 的改變都是意想不到的,實際上你無法控制回調函數的執行方式,因此就沒有辦法控制會影響綁定的調用位置。之後我們會介紹如何通過固定 this 來修復這個問題。

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 上。

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

但是顯式綁定的一個變種可以解決這個問題。

硬綁定:

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

由於硬綁定是一種非常常用的模式,所以在 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 的上下文並調用原始函數。

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(..) 實現瞭顯式綁定,這樣你可以少些一些代碼。

new綁定

首先需要澄清一個非常常見的關於 JavaScript 中函數和對象的誤解。

在傳統的面向類的語言中,“構造函數”是類中的一些特殊方法,使用 new 初始化類時會
調用類中的構造函數。通常的形式是這樣的:something = new MyClass(..);

JavaScript 也有一個 new 操作符,使用方法看起來也和那些面向類的語言一樣,絕大多數開發者都認為 JavaScript 中 new 的機制也和那些語言一樣。然而,JavaScript 中 new 的機制實際上和面向類的語言完全不同。

首先我們重新定義一下 JavaScript 中的“構造函數”。在 JavaScript 中,構造函數隻是一些使用 new 操作符時被調用的函數。它們並不會屬於某個類,也不會實例化一個類。實際上,它們甚至都不能說是一種特殊的函數類型,它們隻是被 new 操作符調用的普通函數而已。

這裡有一個重要但是非常細微的區別:實際上並不存在所謂的“構造函數”,隻有對於函數的“構造調用”。

使用 new 來調用函數,或者說發生構造函數調用時,會自動執行下面的操作。

1、創建(或者說構造)一個全新的對象。
2、這個新對象會被執行[[原型]]連接。
3、這個新對象會綁定到函數調用的this。
4、如果函數沒有返回其他對象,那麼new表達式中的函數調用會自動返回這個新對象。
function foo(a) { 
    this.a = a;
}
var bar = new foo(2); 
console.log( bar.a ); // 2

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

3、優先級

毫無疑問,默認綁定的優先級是四條規則中最低的,所以我們可以先不考慮它。

隱式綁定和顯式綁定哪個優先級更高?我們來測試一下:

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 綁定和顯式綁定誰的優先級更高呢?

在看代碼之前先回憶一下硬綁定是如何工作的。Function.prototype.bind(..) 會創建一個新的包裝函數,這個函數會忽略它當前的 this 綁定(無論綁定的對象是什麼),並把我們提供的對象綁定到 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 
console.log( baz.a ); // 3

bar 被硬綁定到 obj1 上,但是 new bar(3) 並沒有像我們所預料的那樣把 obj1.a 修改為 3。相反,new 修改瞭硬綁定(到 obj1 的)調用 bar(..) 中的 this。因為使用瞭 new 綁定,我們得到瞭一個名字為 baz 的新對象,並且 baz.a 的值是 3。

判斷this

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

1、函數是否在new中調用(new綁定)?如果是的話this綁定的是新創建的對象。
var bar = new foo()
2、函數是否通過call、apply(顯式綁定)或者硬綁定調用?如果是的話,this綁定的是 指定的對象。
var bar = foo.call(obj2)
3、函數是否在某個上下文對象中調用(隱式綁定)?如果是的話,this綁定的是那個上 下文對象。
var bar = obj1.foo()
4、如果都不是的話,使用默認綁定。如果在嚴格模式下,就綁定到undefined,否則綁定到 全局對象。
var bar = foo()

綁定例外

被忽略的this

如果你把 null 或者 undefined 作為 this 的綁定對象傳入 call、apply 或者 bind,這些值在調用時會被忽略,實際應用的是默認綁定規則:

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

那麼什麼情況下你會傳入 null 呢?
一種非常常見的做法是使用 apply(..) 來“展開”一個數組,並當作參數傳入一個函數。類似地,bind(..) 可以對參數進行柯裡化(預先設置一些參數),這種方法有時非常有用:

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}
// 把數組“展開”成參數
foo.apply( null, [2, 3] ); // a:2, b:3
// 使用 bind(..) 進行柯裡化 
var bar = foo.bind( null, 2 ); 
bar( 3 ); // a:2, b:3

這兩種方法都需要傳入一個參數當作 this 的綁定對象。如果函數並不關心 this 的話,你仍然需要傳入一個占位值,這時 null 可能是一個不錯的選擇,就像代碼所示的那樣。

然而,總是使用 null 來忽略 this 綁定可能產生一些副作用。如果某個函數確實使用瞭 this(比如第三方庫中的一個函數),那默認綁定規則會把 this 綁定到全局對象(在瀏覽器中這個對象是 window),這將導致不可預計的後果(比如修改全局對象)。

更安全的this:

一種“更安全”的做法是傳入一個特殊的對象,把 this 綁定到這個對象不會對你的程序產生任何副作用。

在 JavaScript 中創建一個空對象最簡單的方法都是 Object.create(null)。
Object.create(null) 和 {} 很像,但是並不會創建 Object.prototype 這個委托,所以它比 {}“更空”:

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}
// 空對象
var ? = Object.create( null ); 
// 把數組展開成參數
foo.apply( ?, [2, 3] ); // a:2, b:3
// 使用 bind(..) 進行柯裡化 
var bar = foo.bind( ?, 2 ); 
bar( 3 ); // a:2, b:3

使用變量名 ? 不僅讓函數變得更加“安全”,而且可以提高代碼的可讀性,因為 ? 表示 “我希望 this 是空”,這比 null 的含義更清楚。

間接引用

另一個需要註意的是,你有可能(有意或者無意地)創建一個函數的“間接引用”,在這種情況下,調用這個函數會應用默認綁定規則。

function foo() { 
    console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo }; 
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

賦值表達式 p.foo = o.foo 的返回值是目標函數的引用,因此調用位置是 foo() 而不是 p.foo() 或者 o.foo()。根據我們之前說過的,這裡會應用默認綁定。

註意:對於默認綁定來說,決定 this 綁定對象的並不是調用位置是否處於嚴格模式,而是函數體是否處於嚴格模式。如果函數體處於嚴格模式,this 會被綁定到 undefined,否則 this 會被綁定到全局對象。

軟綁定

之前我們已經看到過,硬綁定這種方式可以把 this 強制綁定到指定的對象(除瞭使用 new 時),防止函數調用應用默認綁定規則。問題在於,硬綁定會大大降低函數的靈活性,使用硬綁定之後就無法使用隱式綁定或者顯式綁定來修改 this。

如果可以給默認綁定指定一個全局對象和 undefined 以外的值,那就可以實現和硬綁定相同的效果,同時保留隱式綁定或者顯式綁定修改 this 的能力。

可以通過一種被稱為軟綁定的方法來實現我們想要的效果:

if (!Function.prototype.softBind) { 
    Function.prototype.softBind = function(obj) {
        var fn = this;
        // 捕獲所有 curried 參數
        var curried = [].slice.call( arguments, 1 ); 
        var bound = function() {
            return fn.apply(
                (!this || this === (window || global)) ?
                    obj : this
                    curried.concat.apply( curried, arguments )
            ); 
        };
        bound.prototype = Object.create( fn.prototype );
        return bound; 
    };
}

除瞭軟綁定之外,softBind(..) 的其他原理和 ES5 內置的 bind(..) 類似。它會對指定的函數進行封裝,首先檢查調用時的 this,如果 this 綁定到全局對象或者 undefined,那就把指定的默認對象 obj 綁定到 this,否則不會修改 this。此外,這段代碼還支持可選的柯裡化(詳情請查看之前和 bind(..) 相關的介紹)。

下面我們看看 softBind 是否實現瞭軟綁定功能:

function foo() {
    console.log("name: " + this.name);
}
var obj = { name: "obj" }, 
obj2 = { name: "obj2" }, 
obj3 = { name: "obj3" };
var fooOBJ = foo.softBind( obj );
fooOBJ(); // name: obj
obj2.foo = foo.softBind(obj); 
obj2.foo(); // name: obj2 <---- 看!!!
fooOBJ.call( obj3 ); // name: obj3 <---- 看! 
setTimeout( obj2.foo, 10 );
// name: obj <---- 應用瞭軟綁定

可以看到,軟綁定版本的 foo() 可以手動將 this 綁定到 obj2 或者 obj3 上,但如果應用默認綁定,則會將 this 綁定到 obj。

this詞法

我們之前介紹的四條規則已經可以包含所有正常的函數。但是 ES6 中介紹瞭一種無法使用這些規則的特殊函數類型:箭頭函數。

箭頭函數並不是使用 function 關鍵字定義的,而是使用被稱為“胖箭頭”的操作符 => 定義的。箭頭函數不使用 this 的四種標準規則,而是根據外層(函數或者全局)作用域來決定 this。

箭頭函數的綁定無法被修改。(new 也不行!)

發佈留言