深入理解 js this 繫結 ( 無需死記硬背,尾部有總結和麵試題解析 )

海洋餅乾發表於2019-02-16

js 的 this 繫結問題,讓多數新手懵逼,部分老手覺得噁心,這是因為this的繫結 ‘難以捉摸’,出錯的時候還往往不知道為什麼,相當反邏輯。
讓我們考慮下面程式碼:

var people = {
    name : "海洋餅乾",
    getName : function(){
        console.log(this.name);
    }
};
window.onload = function(){
    xxx.onclick =  people.getName;
};

在平時搬磚時比較常見的this繫結問題,大家可能也寫給或者遇到過,當xxx.onclick觸發時,輸出什麼呢 ?

為了方便測試,我將程式碼簡化:

var people = {
    Name: "海洋餅乾",
    getName : function(){
        console.log(this.Name);
    }
};
var bar = people.getName;

bar();    // undefined

通過這個小例子帶大家感受一下this噁心的地方,我最開始遇到這個問題的時候也是一臉懵逼,因為程式碼裡的this在建立時指向非常明顯啊,指向自己 people 物件,但是實際上指向 window 物件,這就是我馬上要和大家說的 this 繫結規則

1 . this

什麼是this ?在討論this繫結前,我們得先搞清楚this代表什麼。

  1. this是JavaScript的關鍵字之一。它是 物件 自動生成的一個內部物件,只能在 物件 內部使用。隨著函式使用場合的不同,this的值會發生變化。
  2. this指向什麼,完全取決於 什麼地方以什麼方式呼叫,而不是 建立時。(比較多人誤解的地方)(它非常語義化,this在英文中的含義就是 這,這個 ,但這其實起到了一定的誤導作用,因為this並不是一成不變的,並不一定一直指向當前 這個

2 . this 繫結規則

掌握了下面介紹的4種繫結的規則,那麼你只要看到函式呼叫就可以判斷 this 的指向了

2 .1 預設繫結

考慮下面程式碼:

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

這種就是典型的預設繫結,我們看看foo呼叫的位置,”光桿司令“,像 這種直接使用而不帶任何修飾的函式呼叫 ,就 預設且只能 應用 預設繫結。

那預設繫結到哪呢,一般是window上,嚴格模式下 是undefined

2 .2 隱性繫結

程式碼說話:

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

obj.foo();            // ?

答案 : undefined 10

foo()的這個寫法熟悉嗎,就是我們剛剛寫的預設繫結,等價於列印window.a,故輸出undefined ,
下面obj.foo()這種大家應該經常寫,這其實就是我們馬上要討論的 隱性繫結

函式foo執行的時候有了上下文物件,即 obj。這種情況下,函式裡的this預設繫結為上下文物件,等價於列印obj.a,故輸出10

如果是鏈性的關係,比如 xx.yy.obj.foo();, 上下文取函式的直接上級,即緊挨著的那個,或者說物件鏈的最後一個。

2 .3 顯性繫結

2 .3 .1 隱性繫結的限制

在我們剛剛的 隱性繫結中有一個致命的限制,就是上下文必須包含我們的函式 ,例:var obj = { foo : foo },如果上下文不包含我們的函式用隱性繫結明顯是要出錯的,不可能每個物件都要加這個函式 ,那樣的話擴充套件,維護性太差了,我們接下來聊的就是直接 給函式強制性繫結this

2 .3 .2 call apply bind

這裡我們就要用到 js 給我們提供的函式 call 和 apply,它們的作用都是改變函式的this指向第一個引數都是 設定this物件

兩個函式的區別:

  1. call從第二個引數開始所有的引數都是 原函式的引數。
  2. apply只接受兩個引數,且第二個引數必須是陣列,這個陣列代表原函式的引數列表。

例如:

function foo(a,b){
    console.log(a+b);
}
foo.call(null,`海洋`,`餅乾`);        // 海洋餅乾  這裡this指向不重要就寫null了
foo.apply(null, [`海洋`,`餅乾`] );     // 海洋餅乾

除了 call,apply函式以外,還有一個改變this的函式 bind ,它和call,apply都不同。

bind只有一個函式,且不會立刻執行,只是將一個值繫結到函式的this上,並將繫結好的函式返回。例:

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

foo = foo.bind(obj);
foo();                    // 10

(bind函式非常特別,下次和大家一起討論它的原始碼)

2 .3 .2 顯性繫結

開始正題,上程式碼,就用上面隱性繫結的例子 :

function foo(){
    console.log(this.a);
}
var obj = {
    a : 10            //去掉裡面的foo
}
foo.call(obj);        // 10

我們將隱性繫結例子中的 上下文物件 裡的函式去掉了,顯然現在不能用 上下文.函式 這種形式來呼叫函式,大家看程式碼裡的顯性繫結程式碼foo.call(obj),看起來很怪,和我們之前所瞭解的函式呼叫不一樣。

其實call 是 foo 上的一個函式,在改變this指向的同時執行這個函式。

(想要深入理解 [call apply bind this硬繫結,軟繫結,箭頭函式繫結 ] 等更多黑科技 的小夥伴歡迎關注我或本文的評論,最近我會單獨做一期放到一起寫一篇文章)(不想看的小夥伴不用擔心,不影響對本文的理解

2 .4 new 繫結

2 .4 .1 什麼是 new

學過物件導向的小夥伴對new肯定不陌生,js的new和傳統的面嚮物件語言的new的作用都是建立一個新的物件,但是他們的機制完全不同。

建立一個新物件少不了一個概念,那就是建構函式,傳統的物件導向 建構函式 是類裡的一種特殊函式,要建立物件時使用new 類名()的形式去呼叫類中的建構函式,而js中就不一樣了。

js中的只要用new修飾的 函式就是`建構函式`,準確來說是 函式的構造呼叫,因為在js中並不存在所謂的`建構函式`。

那麼用new 做到函式的構造呼叫後,js幫我們做了什麼工作呢:

  1. 建立一個新物件。
  2. 把這個新物件的__proto__屬性指向 原函式的prototype屬性。(即繼承原函式的原型)
  3. 將這個新物件繫結到 此函式的this上
  4. 返回新物件,如果這個函式沒有返回其他物件

第三條就是我們下面要聊的new繫結

2 .4 .2 new 繫結

不嗶嗶,看程式碼:

function foo(){
    this.a = 10;
    console.log(this);
}
foo();                    // window物件
console.log(window.a);    // 10   預設繫結

var obj = new foo();      // foo{ a : 10 }  建立的新物件的預設名為函式名
                          // 然後等價於 foo { a : 10 };  var obj = foo;
console.log(obj.a);       // 10    new繫結

使用new呼叫函式後,函式會 以自己的名字 命名 和 建立 一個新的物件,並返回。

特別注意 : 如果原函式返回一個物件型別,那麼將無法返回新物件,你將丟失繫結this的新物件,例:

function foo(){
    this.a = 10;
    return new String("搗蛋鬼");
}
var obj = new foo();
console.log(obj.a);       // undefined
console.log(obj);         // "搗蛋鬼"

2 .5 this繫結優先順序

過程是些無聊的程式碼測試,我直接寫出優先順序了

new 繫結 > 顯示繫結 > 隱式繫結 > 預設繫結

3 . 總結

  1. 如果函式被new 修飾

       this繫結的是新建立的物件,例:var bar = new foo();  函式 foo 中的 this 就是一個叫foo的新建立的物件 , 然後將這個物件賦給bar , 這樣的繫結方式叫 new繫結 .
  2. 如果函式是使用call,apply,bind來呼叫的

       this繫結的是 call,apply,bind 的第一個引數.例: foo.call(obj); , foo 中的 this 就是 obj , 這樣的繫結方式叫 顯性繫結 .
  3. 如果函式是在某個 上下文物件 下被呼叫

       this繫結的是那個上下文物件,例 : var obj = { foo : foo };    obj.foo();  foo 中的 this 就是 obj . 這樣的繫結方式叫 隱性繫結 .
  4. 如果都不是,即使用預設繫結

       例:function foo(){...} foo() ,foo 中的 this 就是 window.(嚴格模式下預設繫結到undefined).
       這樣的繫結方式叫 預設繫結 .
    

4 . 面試題解析

1.

var x = 10;
var obj = {
    x: 20,
    f: function(){
        console.log(this.x);        // ?
        var foo = function(){ 
            console.log(this.x);    
            }
        foo();                      // ?
    }
};
obj.f();

———————–答案———————
答案 : 20 10
解析 :考點 1. this預設繫結 2. this隱性繫結

var x = 10;
var obj = {
    x: 20,
    f: function(){
        console.log(this.x);    // 20
                                // 典型的隱性繫結,這裡 f 的this指向上下文 obj ,即輸出 20
        function foo(){ 
            console.log(this.x); 
            }
        foo();       // 10
                     //有些人在這個地方就想當然的覺得 foo 在函式 f 裡,也在 f 裡執行,
                     //那 this 肯定是指向obj 啊 , 仔細看看我們說的this繫結規則 , 對應一下很容易
                     //發現這種`光桿司令`,是我們一開始就示範的預設繫結,這裡this繫結的是window
    }
};
obj.f();             

2.

function foo(arg){
    this.a = arg;
    return this
};

var a = foo(1);
var b = foo(10);

console.log(a.a);    // ?
console.log(b.a);    // ?

———————–答案———————

答案 : undefined 10
解析 :考點 1. 全域性汙染 2. this預設繫結

這道題很有意思,問題基本上都集中在第一undefined上,這其實是題目的小陷阱,但是追棧的過程絕對精彩
讓我們一步步分析這裡發生了什麼:

  1. foo(1)執行,應該不難看出是預設繫結吧 , this指向了window,函式裡等價於 window.a = 1,return window;
  2. var a = foo(1) 等價於 window.a = window , 很多人都忽略了var a 就是window.a ,將剛剛賦值的 1 替換掉了。
  3. 所以這裡的 a 的值是 window , a.a 也是window , 即window.a = window ; window.a.a = window;
  4. foo(10) 和第一次一樣,都是預設繫結,這個時候,將window.a 賦值成 10 ,注意這裡是關鍵,原來window.a = window ,現在被賦值成了10,變成了值型別,所以現在 a.a = undefined。(驗證這一點只需要將var b = foo(10);刪掉,這裡的 a.a 還是window)
  5. var b = foo(10); 等價於 window.b = window;

本題中所有變數的值,a = window.a = 10 , a.a = undefined , b = window , b.a = window.a = 10;

3.

var x = 10;
var obj = {
    x: 20,
    f: function(){ console.log(this.x); }
};
var bar = obj.f;
var obj2 = {
    x: 30,
    f: obj.f
}
obj.f();
bar();
obj2.f();

———————–答案———————
答案:20 10 30
解析:傳說中的送分題,考點,辨別this繫結

var x = 10;
var obj = {
    x: 20,
    f: function(){ console.log(this.x); }
};
var bar = obj.f;
var obj2 = {
    x: 30,
    f: obj.f
}
obj.f();    // 20
            //有上下文,this為obj,隱性繫結
bar();      // 10
            //`光桿司令` 預設繫結  ( obj.f 只是普通的賦值操作 )
obj2.f();   //30
            //不管 f 函式怎麼折騰,this只和 執行位置和方式有關,即我們所說的繫結規則
            

4. 壓軸題了

function foo() {
    getName = function () { console.log (1); };
    return this;
}
foo.getName = function () { console.log(2);};
foo.prototype.getName = function () { console.log(3);};
var getName = function () { console.log(4);};
function getName () { console.log(5);}
 
foo.getName ();                // ?
getName ();                    // ?
foo().getName ();              // ?
getName ();                    // ?
new foo.getName ();            // ?
new foo().getName ();          // ?
new new foo().getName ();      // ?

———————–答案———————
答案:2 4 1 1 2 3 3
解析:考點 1. new繫結 2.隱性繫結 3. 預設繫結 4.變數汙染

function foo() {
    getName = function () { console.log (1); }; 
            //這裡的getName 將建立到全域性window上
    return this;
}
foo.getName = function () { console.log(2);};   
        //這個getName和上面的不同,是直接新增到foo上的
foo.prototype.getName = function () { console.log(3);}; 
        // 這個getName直接新增到foo的原型上,在用new建立新物件時將直接新增到新物件上 
var getName = function () { console.log(4);}; 
        // 和foo函式裡的getName一樣, 將建立到全域性window上
function getName () { console.log(5);}    
        // 同上,但是這個函式不會被使用,因為函式宣告的提升優先順序最高,所以上面的函式表示式將永遠替換
        // 這個同名函式,除非在函式表示式賦值前去呼叫getName(),但是在本題中,函式呼叫都在函式表示式
        // 之後,所以這個函式可以忽略了
        
        // 通過上面對 getName的分析基本上答案已經出來了

foo.getName ();                // 2
                               // 下面為了方便,我就使用輸出值來簡稱每個getName函式
                               // 這裡有小夥伴疑惑是在 2 和 3 之間,覺得應該是3 , 但其實直接設定
                               // foo.prototype上的屬性,對當前這個物件的屬性是沒有影響的,如果要使
                               // 用的話,可以foo.prototype.getName() 這樣呼叫 ,這裡需要知道的是
                               // 3 並不會覆蓋 2,兩者不衝突 ( 當你使用new 建立物件時,這裡的
                               // Prototype 將自動繫結到新物件上,即用new 構造呼叫的第二個作用)
                               
getName ();                    // 4 
                               // 這裡涉及到函式提升的問題,不知道的小夥伴只需要知道 5 會被 4 覆蓋,
                               // 雖然 5 在 4 的下面,其實 js 並不是完全的自上而下,想要深入瞭解的
                               // 小夥伴可以看文章最後的連結
                               
foo().getName ();              // 1 
                               // 這裡的foo函式執行完成了兩件事, 1. 將window.getName設定為1,
                               // 2. 返回window , 故等價於 window.getName(); 輸出 1
getName ();                    // 1
                               // 剛剛上面的函式剛把window.getName設定為1,故同上 輸出 1
                               
new foo.getName ();            // 2
                               // new 對一個函式進行構造呼叫 , 即 foo.getName ,構造呼叫也是呼叫啊
                               // 該執行還是執行,然後返回一個新物件,輸出 2 (雖然這裡沒有接收新
                               // 建立的物件但是我們可以猜到,是一個函式名為 foo.getName 的物件
                               // 且__proto__屬性裡有一個getName函式,是上面設定的 3 函式)
                               
new foo().getName ();          // 3
                               // 這裡特別的地方就來了,new 是對一個函式進行構造呼叫,它直接找到了離它
                               // 最近的函式,foo(),並返回了應該新物件,等價於 var obj = new foo();
                               // obj.getName(); 這樣就很清晰了,輸出的是之前繫結到prototype上的
                               // 那個getName  3 ,因為使用new後會將函式的prototype繼承給 新物件
                               
new new foo().getName ();      // 3
                               // 哈哈,這個看上去很嚇人,讓我們來分解一下:
                               // var obj = new foo();
                               // var obj1 = new obj.getName();
                               // 好了,仔細看看, 這不就是上兩題的合體嗎,obj 有getName 3, 即輸出3
                               // obj 是一個函式名為 foo的物件,obj1是一個函式名為obj.getName的物件

5 . 箭頭函式的this繫結 (2017.9.18更新)

箭頭函式,一種特殊的函式,不使用function關鍵字,而是使用=>,學名 胖箭頭(2333),它和普通函式的區別:

  1. 箭頭函式不使用我們上面介紹的四種繫結,而是完全根據外部作用域來決定this。(它的父級是使用我們的規則的哦)
  2. 箭頭函式的this繫結無法被修改 (這個特性非常爽(滑稽))

先看個程式碼鞏固一下:

function foo(){
    return ()=>{
        console.log(this.a);
    }
}
foo.a = 10;

// 1. 箭頭函式關聯父級作用域this

var bar = foo();            // foo預設繫結
bar();                      // undefined 哈哈,是不是有小夥伴想當然了

var baz = foo.call(foo);    // foo 顯性繫結
baz();                      // 10 

// 2. 箭頭函式this不可修改
//這裡我們使用上面的已經繫結了foo 的 baz
var obj = {
    a : 999
}
baz.call(obj);              // 10

來來來,實戰一下,還記得我們之前第一個例子嗎,將它改成箭頭函式的形式(可以徹底解決噁心的this繫結問題):

var people = {
    Name: "海洋餅乾",
    getName : function(){
        console.log(this.Name);
    }
};
var bar = people.getName;

bar();    // undefined

====================修改後====================

var people = {
    Name: "海洋餅乾",
    getName : function(){
        return ()=>{
            console.log(this.Name);
        }
    }
};
var bar = people.getName(); //獲得一個永遠指向people的函式,不用想this了,豈不是美滋滋?

bar();    // 海洋餅乾 

可能會有人不解為什麼在箭頭函式外面再套一層,直接寫不就行了嗎,搞這麼麻煩幹嘛,其實這也是箭頭函式很多人用不好的地方

var obj= {
    that : this,
    bar : function(){
        return ()=>{
            console.log(this);
        }
    },
    baz : ()=>{
        console.log(this);
    }
}
console.log(obj.that);  // window
obj.bar()();            // obj
obj.baz();              // window
  1. 我們先要搞清楚一點,obj的當前作用域是window,如 obj.that === window。
  2. 如果不用function(function有自己的函式作用域)將其包裹起來,那麼預設繫結的父級作用域就是window。
  3. 用function包裹的目的就是將箭頭函式繫結到當前的物件上。函式的作用域是當前這個物件,然後箭頭函式會自動繫結函式所在作用域的this,即obj。

美滋滋,溜了溜了




參考書籍:你不知道的JavaScript<上卷> KYLE SIMPSON 著 (推薦)

相關文章