[譯] 讓我們一起解決“this”難題 — 第一部分

elang發表於2018-08-06

[譯] 讓我們一起解決“this”難題 — 第一部分

難道我們就不能徹底搞清楚“this”嗎?在某種程度上,幾乎所有的 JavaScript 開發人員都曾經思考過“this”這個事情。對我來說,每當“this”出來搗亂的時候,我就會想方設法地去解決掉它,但過後就把它忘了,我想你應該也曾遇到過類似的場景。但是今天,讓我們弄明白它,讓我們一次性地徹底解決“this”的問題,一勞永逸。

前幾天,我在圖書館遇到了一個意想不到的事情。

[譯] 讓我們一起解決“this”難題 — 第一部分

這本書的整個第二章都是關於“this”的,我很有自信地通讀了一遍,但是發現其中有些地方講到的“this”,我居然搞不懂它們是什麼,需要去猜測。真的是時候反省一下我過度自信的愚蠢行為了。我再次把這一章重讀了好幾遍,發覺這裡面的內容是每個 Javascript 開發人員都應該瞭解的。

因此,我嘗試著用一種更徹底的方式和更多的示例程式碼來展示 凱爾·辛普森 在他的這本書 你不知道的 Javascript 中描述的那些規範。

在這裡我不會通篇只講理論,我會直接以曾經困擾過我的困難問題為例開始講起,我希望它們也是你感到困難的問題。但不管這些問題是否會困撓你,我都會給出解釋說明,我會一個接一個地向你介紹所有的規則,當然還會有一些追加內容。

在開始之前,我假設你已經瞭解了一些 JavaScript 的背景知識,當我講到 global、window、this、prototype 等等的時候,你知道它們是什麼意思。這篇文章中,我會同時使用 global 和 window,在這裡它們就是一回事,是可以互換的。

在下面給出的所有程式碼示例中,你的任務就是猜一下控制檯輸出的結果是什麼。如果你猜對了,就給你自己加一分。準備好了嗎?讓我們開始吧。

Example #1

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

function bar() {  
 console.log(this);   
 baz();  
}

function baz() {  
 console.log(this);   
}

foo();
複製程式碼

你被難住了嗎?為了測試,你當然可以把這段程式碼複製下來,然後在瀏覽器或者 Node 的執行環境中去執行看看結果。再來一次,你被難住了嗎?好吧,我就不再問了。但說真的,如果你沒被難住,那就給你自己加一分。

如果你執行上面的程式碼,就會在控制檯中看到 global 物件被列印出三次。為了解釋這一點,讓我來介紹 第一個規則,預設繫結。規則規定,當一個函式執行獨立呼叫時,例如只是 funcName();,這時函式的“this”被指向 global 物件。

需要理解的是,在呼叫函式之前,“this”並沒有繫結到這個函式,因此,要找到“this”,你應該密切注意該函式是如何呼叫,而不是在哪裡呼叫。所有三個函式 foo();bar(); 和 baz();_ 都是獨立的呼叫,因此這三個函式的“this”都指向全域性物件。

Example #2

‘use strict’;
function foo() {
 console.log(this); 
 bar();
}
function bar() {
 console.log(this); 
 baz();
}
function baz() {
 console.log(this); 
}
foo();
複製程式碼

注意下最開始的“use strict”。在這種情況下,你覺得控制檯會列印什麼?當然,如果你瞭解 strict mode,你就會知道在嚴格模式下 global 物件不會被預設繫結。所以,你得到的列印是三次 undefined 的輸出,而不再是 global

回顧一下,在一個簡單呼叫函式中,比如獨立呼叫中,“this”在非嚴格模式下指向 global 物件,但在嚴格模式下不允許 global 物件預設繫結,因此這些函式中的“this”是 undefined。

為了使我們對預設繫結概念理解得更加具體,這裡有一些示例。

Example #3

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

foo();
複製程式碼

foo 先被呼叫,然後又呼叫 barbar 將“this”列印到控制檯中。這裡的技巧是看看函式是如何被呼叫的。foobar 都被單獨呼叫,因此,他們內部的“this”都是指向 global 物件。但是由於 bar 是唯一執行列印的函式,所以我們看到 global 物件在控制檯中輸出了一次。

我希望你沒有回答 foobar。有沒有?

我們已經瞭解了預設繫結。讓我們再做一個簡單的測試。在下面的示例中,控制檯輸出什麼?

Example #4

var a = 1;

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

foo();
複製程式碼

輸出結果是 undefined?是 1?還是什麼?

如果你已經很好地理解了之前講解的內容,那麼你應該知道控制檯輸出的是“1”。為什麼?首先,預設繫結作用於函式 foo。因此 foo 中的“this”指向 global 物件,並且 a 被宣告為 global 變數,這就意味著 a 是 global 物件的屬性(也稱之為全域性物件汙染),因此 this.avar a 就是同一個東西。

隨著本文的深入,我們將會繼續研究預設繫結,但是現在是時候向你介紹下一個規則了。

Example #5

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

obj.foo();
複製程式碼

這裡應該沒有什麼疑問,物件“obj”會被輸出在控制檯中。你在這裡看到的是 隱式繫結。規則規定,當一個函式被作為一個物件方法被呼叫時,那麼它內部的“this”應該指向這個物件。如果函式呼叫前面有多個物件( obj1.obj2.func() ),那麼函式之前的最後一個物件(obj3)會被繫結。

需要注意的一點是函式呼叫必須有效,那也就是說當你呼叫 obj.func() 時,必須確保 func 是物件 obj 的屬性。

因此,在上面的例子中呼叫 obj.foo() 時,“this”就指向 obj,因此 obj 被列印輸出在控制檯中。

Example #6

function logThis() {  
 console.log(this);  
}

var myObject = {  
 a: 1,   
 logThis: logThis  
};

logThis();  
myObject.logThis();
複製程式碼

你被難住了?我希望沒有。

跟在 myObject 後面的這個全域性呼叫 logThis() 通過 console.log(this) 列印的是 global 物件;而 myObject.logThis() 列印的是 myObject 物件。

這裡需要注意一件有趣的事情:

console.log(logThis === myObject.logThis); // true
複製程式碼

為什麼不呢?它們當然是相同的函式,但是你可以看到 如何呼叫_logThis_ 會讓其中的“this”發生改變。當 logThis 被單獨呼叫時,使用預設繫結規則,但是當 logThis 作為前面的物件屬性被呼叫時,使用隱式繫結規則。

不管採用哪條規則,讓我們看看是怎麼處理的(雙關語)。

Example #8

function foo() {  
 var a = 2;  
 this.bar();  
}

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

foo();
複製程式碼

控制檯輸出什麼?首先,你可能會問我們可以呼叫“_this.bar()”嗎?當然可以,它不會導致錯誤。

就像示例 #4 中的 var a 一樣,bar 也是全域性物件的屬性。因為 foo 被單獨呼叫了,它內部的“this”就是全域性物件(預設繫結規則)。因此 foo 內部的 this.bar 就是 bar。但實際的問題是,控制檯中輸出什麼?

如果你猜的沒錯,“undefined”會被列印出來。

注意 bar 是如何被呼叫的?看起來,隱式繫結在這裡發揮作用。隱式繫結意味著 bar 中的“this”是其前面的物件引用。bar 前面的物件引用是全域性物件,在 foo 裡面是全域性物件,對不對?因此在 bar 中嘗試訪問 this.a 等同於訪問 [global object].a。沒有什麼意外,因此控制檯會輸出 undefined。

太棒了!繼續向下講解。

Example #7

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

obj.foo(function() {  
 console.log(this);  
});
複製程式碼

請不要讓我失望。

函式 foo 接受一個回撥函式作為引數。我們所做的就是在呼叫 foo 的時候在引數裡面放了一個函式。

obj.foo( function() { console.log(this); } );
複製程式碼

但是請注意 foo如何 被呼叫的。它是一個單獨呼叫嗎?當然不是,因此第一個輸出到控制檯的是物件 obj 。我們傳入的回撥函式是什麼?在 foo 內部,回撥函式變為 fn ,注意 fn如何 被呼叫的。對,因此 fn 中的“this”是全域性物件,因此第二個被輸出到控制檯的是全域性物件。

希望你不會覺得無聊。順便問一下,你的分數怎麼樣?還可以嗎?好吧,這次我準備難倒你了。

Example #8

var arr = [1, 2, 3, 4];

Array.prototype.myCustomFunc = function() {
 console.log(this);
};

arr.myCustomFunc();
複製程式碼

如果你還不知道 Javascript 裡面的 .prototype 是什麼,那你就權且把它和其他物件等同看待,但如果你是 JavaScript 開發者,你應該知道。你知道嗎?努努力,再去多讀一些關於原型鏈相關的書籍吧。我在這裡等著你。

那麼列印輸出的是什麼?是 Array.prototype 物件?錯了!

這是和之前相同的技巧,請檢查 custommyfunc如何 被呼叫的。沒錯,隱式繫結把 arr 繫結到 myCustomFunc,因此輸出到控制檯的是 arr[1,2,3,4]

我說的,你理解了嗎?

Example #9

var arr = [1, 2, 3, 4];

arr.forEach(function() {  
 console.log(this);  
});
複製程式碼

執行上述程式碼的結果是,在控制檯中輸出了 4 次全域性物件。如果你錯了,也沒關係。請再看示例#7。還沒理解?下一個示例會有所幫助。

Example #10

var arr = [1, 2, 3, 4];

Array.prototype.myCustomFunc = function(fn) {  
 console.log(this);  
 fn();  
};

arr.myCustomFunc(function() {  
 console.log(this);   
});
複製程式碼

就像示例 #7 一樣,我們將回撥函式 fn 作為引數傳遞給函式 myCustomFunc。結果是傳入的函式會被獨立呼叫。這就是為什麼在前面的示例(#9)中輸出全域性物件,因為在 forEach 中傳入的回撥函式被獨立呼叫。

類似地,在本例中,首先輸出到控制檯的是 arr,然後是輸出的是全域性物件。我知道這看上去有點複雜,但我相信如果你能再多用點心,你會弄明白的。

讓我們繼續使用這個陣列的示例來介紹更多的概念。我想我會在這裡使用一個簡稱,WGL 怎麼樣?作為 WHAT.GETS.LOGGED 的簡稱?好吧,在我開始老生常談之前,下面是另外一個例子。

Example #11

var arr = [1, 2, 3, 4];

Array.prototype.myCustomFunc = function() {  
 console.log(this);

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

};

arr.myCustomFunc();
複製程式碼

那麼,輸出是?

答案和示例 #10 完全一樣。輪到你了,說一說為什麼首先輸出的是 arr?你看到第一個 console.log(this) 的下面有一段複雜的程式碼,它被稱為 IIFE(立即呼叫的函式表示式)。這個名字不用再過多解釋了,對吧?被 (…)(); 這樣形式封裝的函式會立即被呼叫,也就是說等同於被獨立呼叫,因此它內部的“this”是全域性變數,所以輸出的是全域性變數。

要來新概念了!讓我們看看你對 ES2015 的熟悉程度。

Example #12

var arr = [1, 2, 3, 4];

Array.prototype.myCustomFunc = function() {  
 console.log(this);

 (function() {  
  console.log(‘Normal this : ‘, this);  
 })();

 (() =\> {  
  console.log(‘Arrow function this : ‘, this);  
 })();

};

arr.myCustomFunc();
複製程式碼

除了 IIFE 後面的增加了 3 行程式碼之外,其他程式碼與示例 #11 完全相同。它實際上也是一種 IIFE,只是語法稍有不同。嗨,這是箭頭函式。

箭頭函式的意思是,這些函式中的“this”是一個詞法變數。也就是說,當將“this”與這種箭頭函式繫結時,函式會從包裹它的函式或作用域中獲取“this”的值。包裹我們這個箭頭函式的函式裡面的“this”是 arr。因此?

// This is WGL
arr [1, 2, 3, 4]
Normal this : global
Arrow function this : arr [1, 2, 3, 4]
複製程式碼

如果我用箭頭函式重寫示例 #9 會怎麼樣?控制檯輸出什麼呢?

var arr = [1, 2, 3, 4];

arr.forEach(() => {
 console.log(this);
});
複製程式碼

上面的這個例子是額外追加的,所以即使你猜對了也不用增加分數。你還在算分嗎?書呆子。

現在請仔細關注以下示例。我會不惜一切代價讓你弄懂他們 :-)。

Example #13

var yearlyExpense = {

 year: 2016,

 expenses: [
   {‘month’: ‘January’, amount: 1000}, 
   {‘month’: ‘February’, amount: 2000}, 
   {‘month’: ‘March’, amount: 3000}
  ],

 printExpenses: function() {
  this.expenses.forEach(function(expense) {
   console.log(expense.amount + ‘ spent in ‘ + expense.month + ‘, ‘ +    this.year);
   });
  }

};

yearlyExpense.printExpenses();
複製程式碼

那麼,輸出是?多點時間想一想。

這是答案,但我希望你在閱讀解釋之前先自己想想。

1000 spent in January, undefined  
2000 spent in February, undefined  
3000 spent in March, undefined
複製程式碼

這都是關於 printExpenses 函式的。首先注意下它是如何被呼叫的。隱式繫結?是的。所以 printExpenses 中的“this”指向的是物件 yearlycost。這意味著 this.expensesyearlyExpense 物件中的 expenses 陣列,所以這裡沒有問題。現在,當它在傳遞給 forEach 的回撥函式中出現“this”時,它當然是全域性物件,請參考例 #9。

注意,下面的“修正”版本是如何使用箭頭函式進行改進的。

var expense = {

 year: 2016,

 expenses: [
   {‘month’: ‘January’, amount: 1000}, 
   {‘month’: ‘February’, amount: 2000}, 
   {‘month’: ‘March’, amount: 3000}
  ],

 printExpenses: function() {
   this.expenses.forEach((expense) => {
    console.log(expense.amount + ‘ spent in ‘ + expense.month + ‘, ‘ +  this.year);
   });
  }

};

expense.printExpenses();
複製程式碼

這樣我們就得到了想要的輸出結果:

1000 spent in January, 2016  
2000 spent in February, 2016  
3000 spent in March, 2016
複製程式碼

到目前為止,我們已經熟悉了隱式繫結和預設繫結。我們現在知道函式被呼叫的方式決定了它裡面的“this”。我們還簡要地講了箭頭函式以及它們內部的“this”是怎樣定義的。

在我們討論其他規則之前,你應該知道,有些情況下,我們的“this”可能會丟失隱式繫結。讓我們快速地看一下這些例子。

Example #14

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

obj.foo();

var bar = obj.foo;  
bar();
複製程式碼

不要被這裡面的花哨程式碼所分心,只需注意函式是如何被呼叫的,就可以弄明白“this”的含義。你現在一定已經掌握這個技巧了吧。首先 obj.foo() 被呼叫,因為 foo 前面有一個物件引用,所以首先輸出的是物件 objbar 當然是被獨立呼叫的,因此下一個輸出是全域性變數。提醒你一下,記住在嚴格模式下,全域性物件是不會預設繫結的,因此如果你在開啟了嚴格模式,那麼控制檯輸出的就是 undefined,而不再是全域性變數。

bar 和 foo 是對同一個函式的引用,唯一區別是它們被呼叫的方式不同。

Example #15

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

function doFoo(fn) {  
 fn();  
}

doFoo(obj.foo);
複製程式碼

這裡也沒什麼特別的。我們是通過把 obj.foo 作為 doFoo 函式的引數(doFoo 這個名字聽起來很有趣)。同樣, fnfoo 是對同一個函式的引用。現在我要重複同樣的分析過程, fn 被獨立呼叫,因此 fn 中的“this”是全域性物件。而全域性物件沒有屬性 a,因此我們在控制檯中得到了 undifined 的輸出結果。

到這裡,我們這部分就講完了。在這一部分中,我們討論了將“this”繫結到函式的兩個規則。預設繫結和隱式繫結。我們研究瞭如何使用“use strict”來影響全域性物件的繫結,以及如何會讓隱式繫結的“this”失效。我希望在接下來的第二部分中,你會發現本文對你有所幫助,在那裡我們將介紹一些新規則,包括 new 和顯式繫結。那裡再見吧!


在我們結束之前,我想用一個“簡單”的例子來作為這一部分的收尾,當我開始使用 Javascript 時,這個例子曾經讓我感到非常震驚。Javascript 裡面也並不是所有的東西都是美的,也有看起來很糟糕的東西。讓我們看看其中的一個。

var obj = {  
 a: 2,  
 b: this.a * 2  
};

console.log( obj.b ); // NaN
複製程式碼

它讀起來感覺很好,在 obj 裡面,“this”應該是 obj,因此是 this.a 應該是 2。嗯,錯了。因為在這個物件裡面的“this”是全域性物件,所以如果你像這麼寫…

var myObj = {  
 a: 2,  
 b: this  
};

console.log(myObj.b); // global
複製程式碼

控制檯輸出的就是全域性物件。你可能會說“但是,myObj 是全域性物件的屬性(示例 #4 和示例 #8),不對嗎?”是的,絕對正確。

console.log( this === myObj.b ); // true   
console.log( this.hasOwnProperty(‘myObj’) ); //true
複製程式碼

“也就是說,如果我像這樣寫的話,它就可以!”

var myObj = {  
 a: 2,  
 b: this.myObj.a * 2  
};
複製程式碼

遺憾的是,不是這樣的,這會導致邏輯錯誤。上面的程式碼是不正確的,編譯器會抱怨它找不到未定義的屬性 a為什麼會這樣?我也不太清楚。

幸運的是,getters(隱式繫結)可以給我們提供幫助。

var myObj = {  
 a: 2,  
 get b() {  
  return this.a * 2  
 }  
};

console.log( myObj.b ); // 4
複製程式碼

你堅持到最後了!做得好。第二部分,我們再見。

如果你發現這篇文章很有用,你可以推薦並分享給其他開發者。我經常發表文章,在 TwitterMedium 上關注我,以便在這種情況發生時得到通知。

謝謝你的閱讀,祝你愉快!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章