- 原文地址:Let’s settle ‘this’ — Part One
- 原文作者:Nash Vail
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:geniusq1981
- 校對者:Moonliujk、lance10030
難道我們就不能徹底搞清楚“this”嗎?在某種程度上,幾乎所有的 JavaScript 開發人員都曾經思考過“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 先被呼叫,然後又呼叫 bar,bar 將“this”列印到控制檯中。這裡的技巧是看看函式是如何被呼叫的。foo 和 bar 都被單獨呼叫,因此,他們內部的“this”都是指向 global 物件。但是由於 bar 是唯一執行列印的函式,所以我們看到 global 物件在控制檯中輸出了一次。
我希望你沒有回答 foo 或 bar。有沒有?
我們已經瞭解了預設繫結。讓我們再做一個簡單的測試。在下面的示例中,控制檯輸出什麼?
Example #4
var a = 1;
function foo() {
console.log(this.a);
}
foo();
複製程式碼
輸出結果是 undefined?是 1?還是什麼?
如果你已經很好地理解了之前講解的內容,那麼你應該知道控制檯輸出的是“1”。為什麼?首先,預設繫結作用於函式 foo。因此 foo 中的“this”指向 global 物件,並且 a 被宣告為 global 變數,這就意味著 a 是 global 物件的屬性(也稱之為全域性物件汙染),因此 this.a 和 var 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.expenses 是 yearlyExpense 物件中的 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 前面有一個物件引用,所以首先輸出的是物件 obj。bar 當然是被獨立呼叫的,因此下一個輸出是全域性變數。提醒你一下,記住在嚴格模式下,全域性物件是不會預設繫結的,因此如果你在開啟了嚴格模式,那麼控制檯輸出的就是 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 這個名字聽起來很有趣)。同樣, fn 和 foo 是對同一個函式的引用。現在我要重複同樣的分析過程, 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
複製程式碼
你堅持到最後了!做得好。第二部分,我們再見。
如果你發現這篇文章很有用,你可以推薦並分享給其他開發者。我經常發表文章,在 Twitter 和 Medium 上關注我,以便在這種情況發生時得到通知。
謝謝你的閱讀,祝你愉快!
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。