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

elang發表於2018-08-07

嗨!歡迎來到讓我們一起解決“this”難題的第二部分,我們試圖揭開 JavaScript 中最難讓人理解的一部分內容 - “this”關鍵字的神祕面紗。如果您還沒有讀過 第一部分,你需要先把它讀一下。在第一部分中,我們通過 15 個示例介紹了預設繫結規則和隱式繫結規則。我們瞭解了函式內部的“this”如何隨著函式呼叫方式的不同而發生改變。最後,我們也介紹了箭頭函式以及它是如何進行詞法繫結。我希望你能記住這一切。

在這一部分我們將討論兩個新規則,從 new 繫結開始,我們將深入地分析這一切是如何工作的。接下來,我們將介紹顯式繫結以及如何通過 call(...),apply(...) 和 bind(...) 方法將任意物件繫結到函式內部的“this”上。

讓我們接著之前的內容繼續。你的任務還是一樣,繼續猜一下控制檯的輸出內容是什麼。還記得 WGL 嗎?

不過,在深入之前,先讓我們通過一個例子來熱熱身。

Example #16

function foo() {}

foo.a = 2;
foo.bar = {
 b: 3,
 c: function() {
  console.log(this);
 } 
}

foo.bar.c();
複製程式碼

我知道,現在你可能會想“到底發生了什麼?為什麼在這裡將屬性分配給函式?這不會導致錯誤嗎?”好吧,首先,這不會導致錯誤。JavaScript 中的每個函式也都是一個物件。就像其他普通的物件一樣,你也可以為函式指定屬性!

接下來,讓我們弄清楚控制檯會輸出什麼。如果您注意下,你會發現隱式繫結在此處起作用。c 呼叫之前的物件是 bar,對嗎?因此 c 中的“this”指向的是 bar,因此 bar 被輸出到控制檯中。

通過這個示例,你可以知道,JavaScript 中的函式也是物件,就像任何其他物件一樣,它們可以被賦予屬性。

Example #17

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

new foo();
複製程式碼

那麼,輸出什麼?還是根本沒有輸出?

正確答案是一個空物件。是的,不是 a,也不是 foo,只是一個空物件。讓我們看看它是如何工作的。

首先要注意,函式 如何 被呼叫。它不是一個獨立呼叫,它的前面也沒有物件引用。它的前面只有一個 new。在 Javascript 中可以通過 new 關鍵字來引入任意函式。當這樣做的時候,使 new 引入一個函式時,大致會發生四件事情,其中兩個是,

  1. 建立一個空物件。
  2. 新建立的物件被繫結到函式呼叫的“this”上。

第二點正是你執行上面的程式碼時控制檯輸出一個空物件的原因。你可能會問“這能有什麼用?”。我們會發現這裡有些小爭議。

Example #18

function foo(id, name) {
 this.id = id;
 this.name = name;
}

foo.prototype.print = function() {
 console.log( this.id, this.name );
};

var a = new foo(1, ‘A’);
var b = new foo(2, ‘B’);

a.print();
b.print();
複製程式碼

直觀地說,在這個例子中很容易就能猜到控制檯上輸出什麼,但是從技術角度你知道真正的原理嗎?讓我們來看看。

來回顧一下,當使用 new 關鍵字呼叫函式時,會發生四個事件。

  1. 建立一個空物件。
  2. 新建立的物件被繫結到函式呼叫的“this”上。
  3. 新建立物件的原型鏈指向函式的原型物件。
  4. 函式被正常執行,最後返回新建立的物件。

在前面的例子中我們已經驗證了前兩個事情,這就是我們會在控制檯中輸出空物件的原因。先忘掉第三點,讓我們聚焦在第四點上。沒有什麼可以阻止函式的執行,除了函式內部的“this”是新建立的空物件之外,傳參後函式的執行過程與其他正常的 Javascript 函式一樣。因此,這個例子中的 foo,在它裡面我們執行類似 this.id=id 的操作時,我們實際上是將屬性分配給了在呼叫函式時繫結到“this”上的新建立的空物件。再讀一遍這句話。一旦函式執行完成,就會返回這個剛被建立的物件。由於在上面的示例中我們為返回的物件分配了 idname 屬性,所以這個返回的物件也會擁有這些屬性。然後我們可以將返回的物件賦值給我們想要的任何變數,就像我們上面示例中的 a 和 b。

每個使用 new 關鍵字的函式呼叫都會建立一個全新的空物件,在函式內部配置物件的引數屬性 _(this.propName = …) 在函式執行完畢後返回這個物件。

var a = {
 id: 1,
 name: ‘A’
};

var b = {
 id: 2,
 name: ‘B’
};
複製程式碼

太棒了!我們剛剛學會了建立物件的新方法。但是 a 和 b 有一些共同點,它們都是 原型鏈指向 foo 的原型物件(事件 4),因此可以訪問它們的屬性(變數,函式等等)。正因為如此,我們可以呼叫 a.print()b.print(),因為 print 是我們在 foo 原型鏈上建立的函式。快速的問一個問題,當我呼叫 a.print() 時會發生什麼繫結?如果你說發生了隱性繫結,那你就答對了。因此,在呼叫 a.print() 時,print 裡面的“this”指向的就是 a,並且控制檯上首先輸出的是 1,A,同樣當我們呼叫 b.print() 時,會輸出 2,B

Example #19

function foo(id, name) {
 this.id = id;
 this.name = name;

 return {
  message: ‘Got you!’
 };
}

foo.prototype.print = function() {
 console.log( this.id, this.name );
};

var a = new foo(1, ‘A’);
var b = new foo(2, ‘B’);

console.log( a );
console.log( b );
複製程式碼

幾乎與上一個示例中的程式碼完全相同,除了請注意,foo 函式現在返回的是一個物件。好吧,讓我們返回上一個例子,重讀一下第四點,怎麼樣?注意加粗的內容了嗎?當使用 new 關鍵字呼叫函式時,在執行結束時將返回新建立的物件,除非你返回自定義物件,就像我們在這個示例中所做的這樣。

所以?輸出的什麼?很明顯,它返回自定義物件,具有 message 屬性的這個物件會在控制檯中輸出,輸出兩次。如此容易就打破了整個結構,是不是?只返回了一個沒有意義的物件,一切就完全改變了。此外,你現在無法呼叫 a.print()b.print(),因為 ab 被分配了返回的物件,但返回的物件沒有連結到 foo 的原型鏈。

但等一下,如果不返回一個物件,我們返回比如 'abc'、數字、布林值、函式、nullundefined 或是陣列,結果會怎樣?事實證明,構造物件是否會改變取決於你返回的內容。看看下面的模式?

return {}; // 改變
return function() {}; // 改變
return new Number(3); // 改變
return [1, 2, 3]; // 改變
return null; // 不改變
return undefined; // 不改變
return ‘Hello’; // 不改變
return 3; // 不改變
...
複製程式碼

為什麼會這樣呢,這就是另外一篇文章的主題了。我的意思是我們已經離題有點遠了,這個例子與“this”繫結沒太大關係,對嗎?

在 Javascript 中,從很久之前就開始通過使用 new 關鍵字繫結來建立完整的物件(也許是一種誤用),以此來偽造傳統的類。實際上,在 JavaScript 中沒有類的概念,ES2015 中新的 class 語法只是一個語法。在它的後面還是使用 new 繫結,沒有任何變化。我一點都不關心你是否使用 new 繫結偽造類,只要你的程式工作正常,程式碼是可擴充套件,可讀和可維護的,就沒有問題。但是,由於 new 繫結帶來的不穩定性,你如何能夠確保所有程式碼包都擁有可擴充套件,可讀和可維護的程式碼呢?

可能這裡還涉及很多內容。如果你還有點迷茫,你應該再重新閱讀一下。重要的是如果你瞭解了 new 繫結的工作原理,可能永遠都不會再使用它 :)。

不開玩笑,讓我們繼續。

思考以下的程式碼。不用猜測這個例子會輸出什麼,我們將從下個例子開始繼續“猜謎遊戲” :)。

var expenses = {
 data: [1, 2, 3, 4, 5],
 total: function(earnings) {
  return this.data.reduce( (prev, cur) => prev + cur ) - (earnings || 0);
 }
};

var rents = {
 data: [1, 2, 3, 4]
};
複製程式碼

expenses 物件具有 datatotal 兩個屬性。data 包含一些數字,而 total 是一個函式,它將 earnings 作為輸入引數並返回 data 中所有數字的總和減去 earnings。非常直觀。

現在看一下 rents,就像 expenses 一樣,它也有 data 屬性。這樣說,出於某種原因,這只是個假設,你想基於 rentdata 陣列執行 total 函式,因為我們是優秀的程式設計師,我們不喜歡重複工作。我們絕對無法呼叫 rents.total(),也無法把 rents 的“this”隱式繫結為 total,因為 rents.total() 是一個無效的呼叫,因為 rents 沒有名為 total 的屬性。現在有沒有一種方法可以將 rents 的“this”繫結為 total 函式。好吧,猜猜是什麼?是有的,請允許我介紹 call()apply()

你可以看到 callapply 做了同樣的事情,它們允許你將你想要的物件繫結到你想要的功能上。這意味著我可以做到這一點……

console.log( expenses.total.call(rents) ); // 10
複製程式碼

還有這個。

console.log( expenses.total.apply(rents) ); // 10
複製程式碼

這很棒!上面的兩行程式碼都會導致 total 函式被呼叫,而內部的“this”被繫結為 rents 物件。callapply 兩個方法就“this”繫結而言,只有傳遞引數的方式不同。

注意,total 函式有一個引數 earnings,讓我們傳一下引數試試。

console.log( expenses.total.call(rents, 10) ); // 0 正常!
console.log( expenses.total.apply(rents, 10) ); // 報錯
複製程式碼

使用 call 給目標函式(在我們的例子中是 total )傳遞引數很簡單,像給其他普通函式傳遞引數一樣,你只需傳入一個由逗號隔開的引數列表 .call(customThis, arg1, arg2, arg3…)。在上面的程式碼我們傳入了 10 作為 earnings 引數,一切正常。

apply 要求你將引數傳遞給目標函式(在我們的例子中是 total)時,將引數包裝在一個陣列裡 .apply(customThis,[arg1,arg2,arg3 ...]) 你應該注意到了,上面的程式碼中我們沒有這樣傳入引數,所以會發生錯誤。把引數封裝成一個陣列,然後再傳入,就不會報錯了。就像下面這樣。

console.log( expenses.total.apply(rents, [10]) ); // 0 正常!
複製程式碼

我過去曾經總結了一個助記符就是通過上面說的這點差別來記住 callapply 之間的區別的。A 代表 apply ,A 也代表 array !所以通過 apply 把引數傳給目標函式時,需要把引數封裝成 array 。這只是一個簡單的小助記符,但它確實很有用。

現在如果我們傳入一個數字,或一個字串,或一個布林值,或 null/undefined,而不是傳入一個物件來呼叫 callapplybind (接下來討論)。那樣會發生什麼?沒有什麼特別,比如你給“this”傳入數字 2, 它在物件內被封裝成物件形式 new Number(2) ,同樣如果你傳入一個字串,它會變成 new String(...) ,布林值會變成 new Boolean(...) 等等,這個新物件,不管是字元,還是數字或是布林值都被繫結到被呼叫函式的“this”。傳入 nullundefined 的結果會有點不同。如果呼叫函式時為“this”傳入 nullundefined ,那它就好像進行了預設繫結一樣,那意味著全域性物件被繫結在被呼叫函式的“this”上。

還有另一種方法將'this'繫結到一個函式,這次通過一個方法名叫,等等,bind

讓我們看看你是否可以解決這個問題。下面的示例會輸出什麼?

Example #2

var expenses = {
 data: [1, 2, 3, 4, 5],
 total: function(earnings) {
  return this.data.reduce( (prev, cur) => prev + cur ) - (earnings   || 0);
 }
};

var rents = {
 data: [1, 2, 3, 4]
};

var rentsTotal = expenses.total.bind(rents);

console.log(rentsTotal());
console.log(rentsTotal(10));
複製程式碼

這個例子的答案是 10 後跟著輸出 0。注意 rents 物件宣告下面發生了什麼。我們從函式 expenses.total 建立一個新函式 rentsTotal 。這裡 bind 建立一個新函式,當這個函式被呼叫時,它的“this”關鍵字設定為提供的值(在我們的例子中是 rents )。因此,當我們呼叫 rentsTotal() 時,雖然它是一個獨立的呼叫,但它的“this”已指向了 rents ,而預設繫結無法覆蓋它。這次呼叫會在控制檯輸入 10。

在下一行中,使用引數(10)呼叫 rentsTotal 與使用相同的引數(10)呼叫 expenses.total 完全相同,它只是“this”中的值不同。這次呼叫的結果為 0。

另外,你也可以使用 bind 繫結引數給目標函式(在我們的例子中是 expenses.total)。思考下這個。

var rentsTotal = expenses.total.bind(rents, 10);
console.log(rentsTotal());
複製程式碼

你認為控制檯輸出什麼?當然是 0,因為 10 已通過 bind 繫結到目標函式(expenses.total)作為 earnings 引數。

讓我們看一個例子,它可以說明 bind 生命週期。

Example #21

// HTML

<button id=”button”>Hello</button>

// JavaScript

var myButton = {
 elem: document.getElementById(‘button’),
 buttonName: ‘My Precious Button’,
 init: function() {
  this.elem.addEventListener(‘click’, this.onClick);
 },
 onClick: function() {
  console.log(this.buttonName);
 }
};

myButton.init();
複製程式碼

我們已經在 HTML 中建立了一個按鈕,然後我們在 Javascript 程式碼中,將這個按鈕定義為 myButton 。注意,在 init 中,我們還為按鈕上新增了一個滑鼠點選的事件監聽。你現在的問題是當點選按鈕的時候,控制檯會輸出什麼?

如果您猜對了,被列印出來的就是 undefined 。這種“奇怪的結果”的原因是作為事件監聽的回撥(在我們的例子中是 this.onClick),它會把目標元素繫結在“this”上。這意味著,當 onClick 被呼叫時,它內部的“this”是按鈕的 DOM 物件(elem),而不是我們的 myButton 物件,因為按鈕的 DOM 物件沒有 buttonName 的屬性,所以控制檯輸出 undefined

但是有辦法解決這個問題(雙關語)。我們需要做的就是新增一行程式碼,僅需一行程式碼。

方案 #1

var myButton = {
 elem: document.getElementById(‘button’),
 buttonName: ‘My Precious Button’,
 init: function() {
  this.onClick = this.onClick.bind(this);
  this.elem.addEventListener(‘click’, this.onClick);
 },
 onClick: function() {
  console.log(this.buttonName);
 }
};
複製程式碼

注意上面的程式碼片段(#21)中呼叫函式 init 的方式。確切地說,隱式繫結將 myButton 繫結到 init 函式的“this”上。現在注意,我們新加的程式碼行是如何把 myButton 繫結到 onClick 函式。這樣做會建立一個新的函式,除了它內部的“this”指向了 myButton,其他就和 onClick 完全一樣。然後新建立的函式被重新分配給 myButton.onClick。這就是全部操作,當你點選按鈕時,你將看到控制檯上輸出“My Precious Button”。

你也可以通過箭頭函式來修復程式碼。就是這樣。我將把這個問題留給你,讓你思考一下這為什麼可以。

方案 #2

var myButton = {
 elem: document.getElementById(‘button’),
 buttonName: ‘My Precious Button’,
 init: function() {
  this.elem.addEventListener(‘click’, () => {
   this.onClick.call(this);
  });
 },
 onClick: function() {
 console.log(this.buttonName);
 }
};
複製程式碼

方案 #3

var myButton = {
 elem: document.getElementById(‘button’),
 buttonName: ‘My Precious Button’,
 init: function() {
  this.elem.addEventListener(‘click’, () => {
   console.log(this.buttonName);
  });
 }
};
複製程式碼

好了。我們差不多就要結束了。還有一些問題,比如繫結是否有優先順序?如果兩個規則都試圖將“this”繫結到同一個函式,這樣的衝突該怎麼辦?這是另一篇文章的主題了。第3部分?可能吧,但是老實說,你很少會遇到這樣的衝突。所以現在我們已經全部講完了,讓我們總結一下我們在這兩部分學到的東西。

總結

在第一部分中,我們看到函式的“this”是如何變化的,並且如何根據函式的呼叫方式而改變。我們討論了預設繫結規則,它適用於函式的獨立呼叫,而隱式繫結規則適用於呼叫函式時,前面有一個物件引用和箭頭函式,以及它們如何使用詞法繫結。在第一部分的結尾處,我們還快速的介紹了在 JavaScript 物件中進行自呼叫。

在第二部分,我們從 new 繫結開始,並討論它是如何工作以及如何能夠輕鬆地破壞整個結構。這一部分的後半部分致力於使用 callapplybind 顯式地將'this'繫結到函式。我還略顯尷尬地與你分享了關於如何記住 callapply 之間差異的助記符。希望你能記住它。

這篇文章很長。非常感謝你能一直讀完。我希望這篇文章能讓你學到些東西。如果覺得還不錯,也請把這篇文章推薦給其他人吧。祝你一天都有好心情!

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


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

相關文章