JS中this的深入理解

隨風君發表於2019-04-20

前言

以下的觀點來自我對《你不知道的JavaScrpt 》 上冊中第二章關於this的理解。

裡面的程式碼和文字大多來自這本書上。我只是總結以下。

下面的所有程式碼,都是在window瀏覽器執行環境下的結果。使用Node.js執行有些程式碼會有不一樣的結果。這個問題,我也不懂。請見諒。

首先來說明兩個關於對this認識的誤解。

誤解

誤解一:this指向自身

很多新手就像我一樣,在沒有看這本書之前,會有一種感覺,覺得this這東西還不好理解,this指向的不就是函式自身,多大點事, so easy。

現在我只想拍拍以前的我說一句:年輕人。

不相信,那就看看下面的一段程式碼。

function foo(num) {
 console.log( "foo: " + num );
 // 記錄 foo 被呼叫的次數
 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 -- WTF?
複製程式碼

如果是像以前一樣不瞭解this的我一樣,看到這樣的輸出結果,一定感覺很疑問?

為什麼輸出的結果會是0,而不是4,this.count++不應該會改變foo.count的值嗎?

但是事實上呢,foo函式裡面的this繫結的是全域性物件window。這段程式碼在 無意中建立了一個全域性變數 count,它的值為 NaN。this.count就是它,值為NaN。

其中this使用的是預設繫結這條規則。你可能會有疑問,不要急,看下面。

誤解二:它的作用域

第二種常見的誤解是,this 指向函式的作用域。這個問題有點複雜,因為在某種情況下它是正確的,但是在其他情況下它卻是錯誤的。

看看這段程式碼:

function foo() {
  var a = 2;
  this.bar();
 }
 function bar() {
  console.log( this.a );
 }
 foo(); // ReferenceError: a is not defined
複製程式碼

這段程式碼中的錯誤不止一個。如果是在瀏覽器中執行,結果就是undefine。但是使用node執行這段程式碼,程式直接報錯:TypeError: this.bar is not a function。

其實在瀏覽器執行環境下我倒是能理解。this繫結的就是window全域性物件。在全域性物件上沒有a這個變數,所以它的值為undefine.

《你不知道的JavaScrpt》中是這樣解釋上面的程式碼

​ 這段程式碼中的錯誤不止一個。雖然這段程式碼看起來好像是我們故意寫出來的例子,但是實際上它出自一個公共社群中互助論壇的精華程式碼。這段程式碼非常完美(同時也令人傷感)地展示了 this 多麼容易誤導人。

​ 首先,這段程式碼試圖通過 this.bar() 來引用 bar() 函式。這是絕對不可能成功的,我們之後會解釋原因。呼叫 bar() 最自然的方法是省略前面的 this,直接使用詞法引用識別符號。

​ 此外,編寫這段程式碼的開發者還試圖使用 this 聯通 foo() 和 bar() 的詞法作用域,從而讓 bar() 可以訪問 foo() 作用域裡的變數 a。這是不可能實現的,你不能使用 this 來引用一 個詞法作用域內部的東西。

每當你想要把 this 和詞法作用域的查詢混合使用時,一定要提醒自己,這是無法實現的。

上面的程式碼我現在都還有點懵,所以我就不多說了,想要了解,就去看書吧。

this到底是什麼

​ this 是在執行時進行繫結的,並不是在編寫時繫結,它的上下文取決於函式調 用時的各種條件。this 的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。 ​ 當一個函式被呼叫時,會建立一個活動記錄(有時候也稱為執行上下文)。這個記錄會包含函式在哪裡被呼叫(呼叫棧)、函式的呼叫方法、傳入的引數等資訊。this 就是記錄的其中一個屬性,會在函式執行的過程中用到。

this 實際上是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫。

在《你不知道的JavaScrpt》書中有關於this呼叫位置的一點解釋。但我理解的也不是很深,估計是錯的我也就不多說了。下面來說說這篇文章的重點,this繫結的四條繫結規則。只有理解了這四條規則,你才能看到this的真面目。劃重點,劃重點,劃重點。


繫結規則

第一條:預設繫結

預設繫結就是,函式呼叫型別:獨立函式呼叫時使用的。可以把這條規則看作是無法應用其他規則時的預設規則。

思考以下下面的程式碼

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

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

看上面的程式碼,你要注意最下面的兩句。在全域性物件上建立了一個變數 a =2 ,呼叫函式的方式 也是 直接使用函式名呼叫

我就來說說這兩句為什麼很重要。

  1. 在函式中的this 使用的是預設繫結這條規則,它會繫結到全域性物件window物件上,this.a 也就是全域性變數 a
  2. 那預設繫結到底是怎麼使用的呢?就是執行 foo(); 這句時繫結的。
  3. 在程式碼中,foo() 是直接使用不帶任何修飾的函式引用進行呼叫的,因此只能使用 預設繫結,無法應用其他規則。

如果使用嚴格模式(strict mode) ,那麼情況又會有不一樣。具體請自己去看書理解,因為現在我也不懂什麼是嚴格模式,所以解釋不清楚。

function foo() {
  console.log(this.a);     //2
  console.log(a);          //undefine
  console.log(this);       //window
  var a = 3;
  console.log(a);          //3
  function bar() {
    console.log(this.a);   //2
    console.log(a);        //3
    console.log(this);     //window
    var a = 4;
    console.log(a);
    function bat() {
      console.log(this.a); //2
      console.log(a);      //4
      console.log(this);   //window
      var a = 5;
      console.log(a);      //5
    }
    bat();
  }
  bar();
}
console.log(a);            //undefine
var a = 2;
console.log(a);            //2
foo();                     // 2
複製程式碼

上面的程式碼寫的很不好,大佬理解以下,我只是一個小白,有很多不懂的東西,只能用console.log()輸出來,慢慢理解。

我就來說說我對上面程式碼的理解。

  1. 上面程式碼中的三個函式呼叫,都是直接使用自身的函式名呼叫,呼叫位置沒有任何的上下文物件。使用的都是預設繫結這條規則,所以函式內部的this 所繫結的都是全域性window物件。this.a 也就是最外層的變數a,所以值就是2。
  2. 在每個函式內部輸出自身的a變數,第一條console.log(a);和第二條console.log(a); 輸出的結果不一樣。這就涉及了詞發作用域的問題還有關於引擎執行的問題

這個問題就更加的麻煩,我也就簡單的發表以下我的理解。理解的不是很深,大佬請理解以下,有錯也歡迎指正。

  1. 首先呢,不知道是編譯器,引擎,作用域,這三者的其中一個會在程式執行之前,把所有的宣告都在開始時編譯一下,有點像是把var a;寫在最上面。所以第一個console.log(a),可以找到a這個變數,但a又沒有值,所以輸出的結果會是undefine.
  2. 但是第二個a,因為已經有了 a = 2;所以第二個console.log(a)輸出的值就是2.

講的不是很明白,因為我理解的也不是很透徹,所以請大佬見諒。

第二條:隱式繫結

隱式繫結在我看來是這四條規則中最複雜的。因為這條規則需要考慮呼叫位置是否有上下文物件,或者說是否被某個物件擁有或包含。這樣說有點繞,反正先思考下面的程式碼:

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

​ 首先要注意的還是foo()的宣告方式,及其之後是如何被當作引用屬性新增到 obj 中的。但是無論是直接在 obj 中定義還是先定義再新增為引用屬性,這個函式嚴格來說都不屬於obj 物件。這是書上的原話。總結以下就是,物件obj中的foo屬性,只是最外層函式foo的一個引用

​ 然而,呼叫的時候,使用了obj 上下文來引用函式,因此可以說,函式被呼叫時obj物件“擁有” 或者 “包含”它。函式中的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
複製程式碼

上面的程式碼最後輸出的結果是 42,我的理解是foo函式的this被繫結的是obj2這個物件,符合最後一層影響呼叫位置,因為最頂層影響呼叫位置我還沒有看到這種情況,所以我也不理解就不多說了。

使用隱式呼叫,有些情況會產生this繫結被丟失的問題,然後this就會使用預設繫結這條規則。

隱式丟失

第一種情況:使用引用賦值時會產生this繫結丟失。

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()輸出的結果不應該是2嗎? 但是事實上卻不是這樣的。我就說說我的理解。

​ bar 是 obj.foo 的一個引用,但實際上,它是foo()函式的一個 引用。所以bar()其實就和foo()函式一樣。使用的是預設繫結規則,this繫結的就是window全域性物件,所以輸出的結果this.a 值為oops, global。

第二種情況更加的隱蔽:發生在傳入引數時

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

是不是又有點懵。其實這裡問題有點深,我說不清楚。只能發表一下自己的看法。

​ doFoo( obj.foo ) 這一句,其實是和 fn = obj.foo是一個道理,如果你能這樣理解的話,我想疑問就沒有了。很明顯嗎,fn是foo函式的一個引用,呼叫時又是fn(),所以和使用foo()沒有什麼差別,this繫結的還是window全域性物件。所以doFoo( obj.foo ) 的值為"oops, global“。

第三條:顯示繫結

這條我覺得是最好理解的。就是使用call()或apply()或bind(),強制繫結到某個物件上。

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

foo.call( obj )就是強制把this繫結到obj物件上。

但是顯示繫結也不能解決繫結丟失的問題。

在《你不知道的JavaScrpt 》書寫了兩種方法解決這個問題。我也不多說,需要的話就自己看書吧。

第四條:new繫結

實際上並不存在所謂的“建構函式”,只有對於函式的“構造呼叫”。

使用 new 來呼叫函式,或者說發生建構函式呼叫時,會自動執行下面的操作。

  1. 建立(或者說構造)一個全新的物件。
  2. 這個新物件會被執行 [[ 原型 ]] 連線。
  3. 這個新物件會繫結到函式呼叫的 this。
  4. 如果函式沒有返回其他物件,那麼 new 表示式中的函式呼叫會自動返回這個新物件。

上面是《你不知道的JavaScrpt 》中對 使用new來呼叫函式的解釋。


下面是《JavaScript高階程式設計》中對 使用new來呼叫函式的解釋。

使用 new 操作符。以這種方式呼叫建構函式實際上會經歷以下 4 個步驟:

  1. 建立一個新物件;
  2. 將建構函式的作用域賦給新物件(因此 this 就指向了這個新物件);
  3. 執行建構函式中的程式碼(為這個新物件新增屬性);
  4. 返回新物件。

寫了這麼多,先看程式碼吧

function foo(a) {
 this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2
複製程式碼
  1. 使用new時,會先建立一個物件,就假如是obj.然後this就會被繫結到這個物件上.就像obj.foo().
  2. 如果函式沒有返回其他物件,那麼 new 表示式中的函式呼叫會自動返回obj物件。
  3. obj.foo()就是預設的情況。this也就預設繫結在obj這個物件上。

所以上面的程式碼,this代表的就是bar物件。

優先順序

​ 因為有四條規則,在寫程式碼時,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()

好了,這篇文章就寫到這裡了。其實在《你不知道的JavaScrpt 》書上還有關於this的一些特殊情況的解釋。我理解的不是很透徹,可能說出來是錯的。我也就不寫了,想要了解的話就自己去書上看吧。

這篇文章寫的也不是很好,這只是我自己的總結,如果有錯,請大佬見諒。理解理解我只是一個小白,還有很多要學的東西。謝謝!

相關文章