談談JavaScript中的this機制

翊溪發表於2018-07-11

thisJavaScript中比較複雜的機制之一,本篇文章希望可以帶大家瞭解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
複製程式碼

這段程式碼可以在不同的上下文物件(meyou)複用函式,並且程式碼中使用了this,如果不使用this程式碼會是這個樣子


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這種隱式的傳遞一個物件的引用,更加方便

⬇️this的誤區

關於this,由於它的語義性的問題,會帶來很多的誤解:

誤區一:指向自身

function foo(num) {
    console.log( "foo: " + num );
    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
複製程式碼

執行後我們發現foo.count仍然是0,說明this並沒有指向foo自身。

誤區二:指向它的作用域

在某種情況下這個說法是正確的,而在某些情況下這個說法又是錯誤的,但是要注意!!this 在任何情況下都不指向函式的詞法作用域!! 為什麼這麼說呢?

function bar() { 
    console.log(1);
}
this.bar(); // 1
複製程式碼

在上例中,this指向了全域性作用域,但是隻是特殊情況,因此會有這個說法是正確的,而在某些情況下這個說法又是錯誤的結論

function foo() { 
    var a = 2;
    this.bar(); 
}
function bar() { 
    console.log( this.a );
}
foo();
複製程式碼

上文this.a檢視引用foo詞法作用域定義的變數a,這是永遠也不可能實現的

❤️this到底是什麼

說了它的使用方式以及誤區,那麼this到底是什麼呢?首先明確一點:this的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。

呼叫位置

this是在呼叫時被繫結的,完全取決於函式的呼叫位置,因此要搞清楚函式的呼叫位置,但是某些程式設計模式會隱藏函式的呼叫位置,最重要的分析它的呼叫棧(就是為了達到當前執行位置的所有呼叫函式)

function baz() {
    // 當前呼叫棧是: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繫結的4種規則,下次看到this出現時,便可以使用這些規則

預設繫結

這是比較常見的函式呼叫型別:獨立函式呼叫

function foo() {
    console.log(this.a);
}

var a = 2;
foo(); // 2
複製程式碼

如何判斷應用了預設繫結呢?foo是直接使用不帶任何修飾符的函式進行引用呼叫的

注意:如果使用了嚴格模式,this會繫結到undefined

function foo() {
    console.log(this.a);
}

var a = 2;
foo(); // TypeError: this is undefined
複製程式碼

隱式繫結

當函式引用有上下文物件時(嚴格來說函式被物件“擁有”或者“包含”),隱式繫結規則會把函式呼叫中的this繫結到這個上下文物件

function foo() { 
    console.log( this.a );
}
var obj = { 
    a: 2,
    foo: foo 
};
obj.foo(); // 2
複製程式碼

嚴格來說,foo不屬於obj物件,但是落腳點卻指向obj物件,因此你可以說函式被呼叫時 obj 物件“擁 有”或者“包含”它。

隱式丟失

一個最常見的問題就是:隱式繫結會丟失繫結物件,從而執行預設繫結

function foo() { 
    console.log( this.a );
}
var obj = { 
    a: 2,
    foo: foo 
};
var bar = obj.foo; // 函式別名!
var a = "oops, global"; // a 是全域性物件的屬性 
bar(); // "oops, global"
複製程式碼

雖然barobj.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"
複製程式碼

綜上所述:有兩種情況會導致隱式繫結的繫結丟失。

  • 進行引用賦值var bar = obj.foo;
  • 進行傳遞引數doFoo( obj.foo );

顯式繫結

function foo() { 
    console.log( this.a );
}
var obj = { 
    a:2
};
foo.call( obj ); // 2
複製程式碼

通過foo.call(..)可以在呼叫時強制把this繫結到obj上,但是這樣的方式也無法解決掉丟失繫結問題

var a = 0;
function foo() {
    console.log(this.a);
}
var obj1 = {
    a:1
};
var obj2 = {
    a:2
};
foo.call(obj1);// 1
foo.call(obj2);// 2
複製程式碼

我們發現this隨著呼叫一直在改變,即this丟失。

我們可以通過以下方式解決:

硬繫結

建立一個包裹函式,傳入所有的引數並返回接收到的所有值

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
複製程式碼

API呼叫的上下文

許多內建函式都提供了一個可選引數,通常被稱為上下文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
複製程式碼

new繫結

JavaScript中的new機制與物件導向的語言完全不同,實際上,在JavaScript中並不存在所謂的"建構函式",只有對與函式的"構造呼叫"

使用new來呼叫函式,或者說發生建構函式呼叫時的流程:

  • 建立(構造一個全新的物件)
  • 這個新物件會被執行[[原型]]連線
  • 這個新物件會被繫結到函式呼叫的this
  • 如果函式沒有返回其他物件,那麼new表示式中的函式呼叫會自動返回這個新物件
function foo(a) { 
    this.a = a;
}
var bar = new foo(2); 
console.log( bar.a ); // 2
複製程式碼

❤️優先順序

上面介紹了this的4種繫結規則,那麼它們的優先順序誰高誰低呢,首先,確認一點的是預設繫結的優先順序最低

比較隱式繫結和顯示繫結

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

// new 和 隱式繫結同時存在,obj1的a是2,而this指向了bar
var bar = new obj1.foo( 4 ); 
console.log( obj1.a ); // 2 
console.log( bar.a ); // 4
複製程式碼

可以看出來new繫結高於隱式繫結

比較new繫結和顯示繫結

由於newcall/apply無法一起使用,我們可以使用硬繫結測試優先順序

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的優先順序大於硬繫結。

但是使用剛開始的裸bind

function foo(something) { 
    this.a = something;
}

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

var bar = bind( obj1, foo ); 
bar( 2 );

console.log( obj1.a ); // 2

var baz = new bar(3); 
console.log( obj1.a ); // 3 
console.log( baz.a ); // undefined
複製程式碼

會驚奇地發現,new bar(3)obj1.a修改為 3 因此內建bind的實現是非常複雜的,不在此進行研究,既然這麼複雜,為什麼還要使用呢?

這種做法稱為“部 分應用”,是“柯里化”的一種,它的主要目的是預設函式的一些引數,這樣在使用new進行初始化時就可以只傳入其餘的引數。

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繫結)?
  • 函式是否通過call、apply(顯式繫結)或者硬繫結呼叫
  • 函式是否在某個上下文物件中呼叫(隱式繫結)
  • 如果都不是的話,使用預設繫結

☕️繫結例外

規則總有例外,當你認為應用了其他規則時,有可能只應用了預設規則

被忽略的this

如果我們把null或者undefined作為this的繫結物件傳遞入callapply、或者bind,會使用預設繫結規則

function foo() { 
    console.log( this.a );
}
var a = 2;
foo.call( null ); // 2
複製程式碼

那麼什麼情況下會使用這種方式呢?利用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
複製程式碼

es6中可以使用...來代替``apply(...)```,但是ES6中沒有柯里化的相關方法

忽略this會存在一個問題,比如第三方庫的函式真的使用了this,我們這種方式把this繫結到了全域性作用域,會存在問題,需要使用更安全的this,建立空的非委託的物件Object.create( null )

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}
// 我們的 DMZ 空物件
var ø = Object.create( null ); 
// 把陣列展開成引數
foo.apply( ø, [2, 3] ); // a:2, b:3
// 使用 bind(..) 進行柯里化 
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3
複製程式碼

間接引用

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 的能力

這種叫做軟繫結。

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

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 <---- 應用了軟繫結
複製程式碼

☕️this詞法

最後介紹es6中的箭頭函式,箭頭函式不使用this的四種規則,而是根據外層(函式或者全域性)作用域來決定this

function foo() {
    // 返回一個箭頭函式 
    return (a) => {
    //this 繼承自 foo()
        console.log( this.a ); 
    };
}

var obj1 = { 
    a:2
};

var obj2 = { 
    a:3
};

var bar = foo.call( obj1 );
bar.call( obj2 ); // 2, 不是 3 !
複製程式碼

foothis繫結到了obj1bar引用箭頭函式的this也會繫結到obj1,箭頭函式的繫結無法被修改

相關文章