或許我從一開始就沒有很懂 this

前言

雖然我寫過很多篇關於 this 的文章,但是這一次我想試著更深入探討 this 這個關鍵字,畢竟對於一個 JavaScript 工程師、前端開發者來講,this 是一個非常常見的關鍵字。

this

一開始我們先來聊聊 this 是什麼,this 在 MDN 文件上是歸類為「運算式與運算子」中的主要運算式

這是什麼意思呢?舉凡 {}[]function 都是屬於運算式的一種。

注意,這邊所指的 function 意思是宣告函式之後不加上名稱的匿名表達函式。

比如說:

1
function fn() {} // 具名陳述式

而在此所指的是這種寫法:

1
2
3
4
5
const fn = function() {};

const obj = {
fn: function() {},
};

那麼什麼是運算式呢?運算式也就是我們常說的表達式與陳述式當我們輸入一段程式碼的時候,它會回傳一個東西給你

1
[]; // Array []

但是如果你嘗試直接輸入 {} 你應該會看到 undefined,因為你單純的輸入 {} 通常會被 JavaScript 判定成一個陳述式,因此在此所指的 {} 是指 Object initializer(物件字面值)的寫法,當然直接撰寫 {} 的寫法也有一些有趣的狀況,我們來稍微聊一下。

比如說:

1
2
3
4
5
{
var myName = 'is Ray';
} // undefined

console.log(myName); // is Ray;

這種寫法是比較少見的,但是如果你的宣告變數的方式若不同的話,那麼所得到的結果也就會跟著不同。

比如說:

1
2
3
4
5
{
const myName = 'is Ray';
} // undefined

console.log(myName); // myName is not defined;

反之如果你使用物件字面值(物件實字)的寫法則是會出現錯誤:

1
2
3
{
myName: 'is Ray',
}

當你輸入以上程式碼到瀏覽器之後,你應該會直接看到瀏覽器噴出一個錯誤給你(各家瀏覽器呈現錯誤方式可能有所不同,在此我所使用的是 FireFox) Uncaught SyntaxError: expected expression, got '}',簡單來講就是這個表達式必須被一個容器給裝著,通常這個容器會是一個變數。

當然匿名函式也是一樣的狀況:

1
function() {};

只是匿名函式則是會噴出 Uncaught SyntaxError: function statement requires a name 的字眼。

當然這不是我們這一次要深入探討的主題,這邊只是稍微聊一下而已。

我們主要的主角是 this,前面只是簡單聊一下並介紹一下什麼是表達式與陳述式。

因此 this 會被歸類為表達式的話,就代表著如果你直接輸入時就會回傳一個值給你。

比如說:

1
this; // 瀏覽器的 window 物件

因此你也可以用一個變數來儲存這個 this 回傳的值:

1
2
3
var myName = 'is Ray';
var vm = this;
console.log(vm.myName); // is Ray

那…this 是哪裡來的?在實際開發的時候,往往我們可以很常看到 this 這個關鍵字的出沒。

比如說,以下是一個取得按鈕元素的 this

1
2
3
4
5
<button type="button" class="btn">1</button>
<button type="button" class="btn">2</button>
<button type="button" class="btn">3</button>
<button type="button" class="btn">4</button>
<button type="button" class="btn">5</button>
1
2
3
4
5
6
7
const buttons = document.querySelectorAll('.btn');

buttons.forEach(function(button) {
button.addEventListener('click', function() {
console.log(this); // 你所點擊的 DOM
});
});

如果你使用過 Vue 開發過的話…

那麼你會很常看到這種寫法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
console.log(this.myName); // Ray
}
},
created() {
this.getName();
}
});

甚至是如果你在一個普通的函式陳述式內呼叫 this 也可以:

1
2
3
function fn() {
console.log(this); // 瀏覽器的 window 物件
}

因此我們可以了解到 this 無所不在,甚至我們可能常常無意識的去使用它,但是也有可能我們太過理所當然的使用而導致沒有真的了解它,如果一直逃避了解它的話,在實際開發時往往會遇到很多很奇怪的蟲子。

比如說,我預期會希望可以取得我的名字:

1
2
3
4
5
6
7
8
9
10
11
12
function fn () {
console.log(this.myName);
}
const obj = {
myName: 'Ray',
getName: fn,
};

obj.getName(); // Ray;

const getName = obj.getName;
getName();// undefined,WTF?

這種時候就可以看到 this 的指向完全跑掉了。

那麼講那麼多廢話,this 是哪裡來的呢?簡單來講,JavaScript 在建立執行環境的時候就會建立這個關鍵字,因此你先知道這一點就好,剩下的我們後面繼續聊。

全域環境

基本上,如果你在全域環境下直接呼叫 this 我們會得到一個 window 的物件,而且是完全的相同。

比如說:

1
this === window; // true

甚至是 document 都是相同的:

1
this.document === document; // true

當然你也可以透過 this 直接在 Window 下新增一個屬性(在此並不是建立變數而是新增屬性,詳情可見此文)也是可以的。

比如說:

1
2
3
4
5
6
this.myName = 'Ray';
console.log(myName); // Ray
console.log(window.myName); // Ray

this.myName === window.myName; // true
this.myName === myName; // true

就算你使用了 'use strict' (嚴謹模式)模式, this 依然會指向 window

1
2
3
4
5
6
7
'use strict'
this.myName = 'Ray';
console.log(myName); // Ray
console.log(window.myName); // Ray

this.myName === window.myName; // true
this.myName === myName; // true

函式環境

在函式下所使用的 this 基本上取決於你如何呼叫這個函式來決定它要參考誰,但是如果你「直接呼叫」這個函式,this 是會直接指向 window,而這個行為又稱之為 **簡易呼叫(Sample Call)**。

比如說:

1
2
3
4
5
function fn() {
return this;
}

fn() === window; // true

通常來講我們在實務開發上都會盡可能的避免簡易呼叫,這邊我直接舉例一段 Vue 中的一段程式碼

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
console.log(this.myName); // Ray
}
},
created() {
this.getName();
}
});

現在我將程式碼給改變一下,這時候又會變成如何呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
const array = [1, 2, 3];
array.forEach(function() {
console.log(this.myName); // undefined * 3 ,WTF?
});
}
},
created() {
this.getName();
}
});

這下可神奇了,為什麼只是多增加一段 forEach 就會變成 undefined?這邊我們試著想像一下 forEach 的實作。

比如說:

1
2
3
4
5
Array.prototype.forEach = function (callback){
for (let index = 0; index < this.length; index += 1) {
callback(this[index], index, this);
}
}

我們可以看到傳入到 forEach 中的函式是直接被呼叫,因此就很容易導致 this 形成簡易呼叫導致 this 指向跑掉,而在上面的 Vue 程式碼中有兩種解法

第一種是宣告一個變數來儲存 this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
const vm = this;
const array = [1, 2, 3];
array.forEach(function() {
console.log(vm.myName); // Ray * 3,is Good!
});
}
},
created() {
this.getName();
}
});

第二種方式是使用箭頭函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
const array = [1, 2, 3];
array.forEach(() => {
console.log(this.myName); // Ray * 3,is Good!
});
}
},
created() {
this.getName();
}
});

等等,為什麼箭頭函式就正常了?這完全超乎我們的想像與預期,this 感覺上比我想像中的還難以掌握,但我們後面再來談為什麼。

還有什麼狀況下會形成簡易呼叫?通常來講只要你直接呼叫一個函式或是傳入一個匿名函式,通常都很容易發生簡易呼叫,就算使用 IIFE 也會發生這個問題。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
(function() {
console.log(this.myName); // undefined,WTF...
})();
}
},
created() {
this.getName();
}
});

因此我們注意到形成簡易呼叫的關鍵不外乎有幾個辨別方式

  1. 通常是直接呼叫函式導致。
  2. 如果是傳入一個匿名函式,通常就會成簡易呼叫。

但是如果使用箭頭函式結果可能不同?這一點我們後面再繼續談。

物件函式

另一個 this 很常見的狀況在於物件的函式內,一個不小心也是非常容易導致 this 指向到處跑得狀況

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
var myName = 'oh No!';

var obj = {
myName: 'Ray',
fn: function() {
console.log(this.myName);
}
}

obj.fn(); // Ray

var fn = obj.fn;
fn() // oh No!,WTF?

這邊還有一個非常有趣的現象,如果你將 var 改成 ES6 的 letconst 反而是會變成另一種結果

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
const myName = 'oh No!';

const obj = {
myName: 'Ray',
fn: function() {
console.log(this.myName);
}
}

obj.fn(); // Ray

var fn = obj.fn;
fn() // undefined,WTF?

想找出物件調用下的 this 指向還算是滿容易的(我自己覺得),你只需要看函式是在哪一個物件下呼叫並執行就可以看出來,舉例來說 obj.fn(); 是在 obj 底下被呼叫執行,因此 this 就會被指向到 obj,那麼 fn() 則會形成簡易呼叫的原因在於,在此我們是將變數 fn 參考到 obj.fn 的路徑,並沒有呼叫,而是在下一行直接呼叫,而這行為就形成了簡易呼叫,因此 this 就會直接指向到 window 底下。

new

那麼 new 建構子也會有一個很奇妙且好玩的狀況,當若我們呼叫一個函式的前面補上 new 建構子時,此時裡面的 this 就會作為物件的屬性使用。

比如說:

1
2
3
4
5
6
7
function fn(myName) {
this.myName = myName;
}

const newFn = new fn('Ray');

console.log(newFn.myName); // Ray

這與使用物件字面值建立的方式有異曲同工之處

1
2
3
4
5
const obj = {
myName: 'Ray',
}

console.log(obj.myName); // Ray

當然還有一種狀況會導致 new 的回傳結果改變。

比如說:

1
2
3
4
5
6
7
8
9
10
function fn(myName) {
this.myName = myName;
return {
myName: 'oh No!',
}
}

const newFn = new fn('Ray');

console.log(newFn.myName); // oh No!

使用 return 回傳另一個物件的行為確實是會導致 new 的物件被消滅,當然實際開發上是幾乎不會有這種寫法。

如果有的話,我想他應該是很想被請出去喝咖啡吧。

DOM

this 在 DOM 的表現上又是更不一樣,當你搭配上了 addEventListener 不管怎麼樣 this 都會指向到該 DOM 元素,在前面的範例其實有舉例到。

比如說:

1
2
3
4
5
<button type="button" class="btn">1</button>
<button type="button" class="btn">2</button>
<button type="button" class="btn">3</button>
<button type="button" class="btn">4</button>
<button type="button" class="btn">5</button>
1
2
3
4
5
6
7
const buttons = document.querySelectorAll('.btn');

buttons.forEach(function(button) {
button.addEventListener('click', function() {
console.log(this); // 你所點擊的 DOM
});
});

當你宣告了 addEventListener 監聽事件之後,基本上是會將後面所傳入的函式卡在某的地方,而這個函式在預設狀況下會指向你所監聽的 DOM 上,你是可以把它想像成像這樣:

1
2
3
4
5
6
7
8
const click = {
button: 'DOM',
addEventListener() {
console.log(this);
}
}

click.addEventListener(); // 點擊才會觸發這個呼叫

但是也有一種狀況會導致 this 指向參考跑掉,也就是箭頭函式。

比如說:

1
2
3
4
5
6
7
const buttons = document.querySelectorAll('.btn');

buttons.forEach(function(button) {
button.addEventListener('click', () => {
console.log(this); // window,WTF?
});
});

是不是感覺每次遇到箭頭函式就感覺特別討厭?別擔心,接下來讓我們了解一下 ES6 箭頭函式到底在搞什麼鬼。

arrow function expression (箭頭函式表達式)

在說明箭頭函式之前我們要先了解到傳統函式與箭頭函式的差別,在 MDN 中有說明到箭頭函式沒有自己的 thisargumentssupernew.target,後三者並不是重點,而主要重點是「箭頭函式沒有自己的 this」,這代表什麼呢?代表當使用箭頭函式,因為沒有自己的 this 那麼這時候它的 this 該從哪裡來?這時候它會參考外層,也就是父層(上一層)。

因此剛才有許多的範例都有這種狀況,明明 this 一開始是跑掉的,但是改成箭頭函式之後反而卻正常。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const app = new Vue({
el: '#app',
data: {
myName: 'Ray',
},
methods: {
getName() {
const array = [1, 2, 3];
array.forEach(() => {
console.log(this.myName); // Ray * 3,is Good!
});
}
},
created() {
this.getName();
}
});

在上面範例中,我們原本若是寫 array.forEach(function () { ... }); 是會導致 this 指向到 window or undefined,在前面有講到箭頭函式沒有自己的 this,因此它會參照父層的 this(概念類似原型鏈),因此傳統函式當有自己的 this 時,就會形成前面所講的簡易呼叫,這也就是為什麼適當的使用箭頭函式可以幫助你更簡化程式碼,甚至是更好使用 this

但是在物件調用下就必須多加小心:

1
2
3
4
5
6
7
8
9
10
var myName = 'oh No!';

var obj = {
myName: 'Ray',
fn: () => {
console.log(this.myName);
}
}

obj.fn(); // oh No!

在上面程式碼中,this 並沒有在其他函式下,因此就會直接參考最外層。

強制綁定 this

JavaScript 的 Function.prototype 有提供三種方法可以幫助我們強制綁定 this 的指向,分別是:

  1. call()
  2. apply()
  3. bind()

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var myName = 'oh No!';

var obj1 = {
myName: 'Ray 2',
}

var obj2 = {
myName: 'Ray',
fn: function () {
console.log(this.myName);
}
}

obj2.fn.call(obj1); // Ray 2

這邊要注意一件事情 call() 傳入欲給定的 this 參數之後,就會立刻被執行。

call() 除了傳入給定 this 的參數之外還可以傳入其他參數。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var myName = 'oh No!';

var obj1 = {
myName: 'Ray 2',
}

var obj2 = {
myName: 'Ray',
fn: function (message) {
console.log(this.myName + ' ' + message);
}
}

obj2.fn.call(obj1, 'Hello'); // Ray 2 Hello

那麼 apply()call() 是非常相同的存在,只是第二個參數接受型別不同,如果 call() 是接受一大推參數的話,那麼 apply() 則是接受陣列。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var myName = 'oh No!';

var obj1 = {
myName: 'Ray 2',
}

var obj2 = {
myName: 'Ray',
fn: function (message) {
console.log(this.myName + ' ' + message);
}
}

obj2.fn.apply(obj1, ['Hello']); // Ray 2 Hello

最後一個是 bind() 為什麼會將 bind() 放在最後一個呢?其實是有原因的,bind()call() 非常雷同,只是 bind() 比較特別的地方是它並不會立刻馬上執行函式,而是先回傳已經綁定好的 this 函式。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var myName = 'oh No!';

var obj1 = {
myName: 'Ray 2',
}

var obj2 = {
myName: 'Ray',
fn: function (message) {
console.log(this.myName + ' ' + message);
}
}

var newFn = obj2.fn.bind(obj1, 'Hello');
newFn(); // Ray 2 Hello

這邊要注意一個小細節是如果你傳入的第一個參數是 nullundefined,那麼必定會重新指向到 window 底下,不論是 call()apply() 或者是 bind() 都會有這種狀況。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var myName = 'oh No!';

var obj1 = {
myName: 'Ray 2',
}

var obj2 = {
myName: 'Ray',
fn: function (message) {
console.log(this.myName + ' ' + message);
}
}

var newFn = obj2.fn.bind(obj1, 'Hello');
newFn(); // Ray 2 Hello

var newFn2 = obj2.fn.bind(null, 'this is Goods!');
newFn2(); // oh No! this is Goods!

另一種綁定 this 方式

除了前面介紹的 call()apply() 以及 bind() 的綁定方式之外,其實還有別種綁定方式 this 的方式。

在前面我們有簡單了解到箭頭函式的方便性,我們可以將原本的一段寫法更簡化成一條,整體看起來就是潮。

比如說:

1
2
3
4
5
6
7
8
9
const array = [1, 2, 3];

// 傳統寫法
array.forEach(function(item) {
console.log(item);
});

// 箭頭函式
array.forEach((item) => console.log(item))

基本上只要知道使用箭頭函式時,裡面的 this 絕大部分時候都會參考父層的 this 這一關鍵點,大多都可以抓到 this 的指向,但是當若採用的是傳統函式寫法,那麼結果就會完全不同,而這種時候就會形成簡易呼叫。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
var myName = 'oh No!';

var obj = {
myName: 'Ray',
fn: function () {
const array = [1, 2, 3];
array.forEach(function() {
console.log(this.myName);
});
}
}

obj.fn(); // oh No! * 3

而此時可能你不想使用 call()apply() 以及 bind() 來強制綁定 this 也不願意改成箭頭函式時,那你可以考慮針對這些迴圈傳入第二個參數

沒有錯,你真的沒有看錯!

其實絕大部分的迴圈大多都可以傳入第二個參數,而第二個參數也是指定 this 的指向。

比如說:

1
2
3
4
5
6
7
8
9
10
11
12
13
var myName = 'oh No!';

var obj = {
myName: 'Ray',
fn: function () {
const array = [1, 2, 3];
array.forEach(function() {
console.log(this.myName);
}, this);
}
}

obj.fn(); // Ray * 3

以目前 MDN 所提供的文件中,舉凡以下這幾個都具備第二個參數來指定 this 指向功能

  1. forEach
  2. filter
  3. map
  4. some
  5. every
  6. find

除了 reduce 不具備 this 指向之外,絕大部分都是具備第二參數來指定 this

參考文獻

Liker 讚賞 (拍手)

如果這一篇筆記文章對你有幫助,希望可以求點支持或 牡蠣 鼓勵 (ノД`)・゜・。

Liker 是一個按讚(拍手)的讚賞機制,每一篇文章最多可以按五下(拍手),按讚過程你是完全不用付費的(除非你想要每個月贊助我 :D),你只需要登入帳號就可以開始按讚。
而 Liker 會依據按讚數量分配獎金給創作者,所以如果你願意按個讚我會非常感謝你唷。

Google AD

撰寫一篇文章其實真的很花時間,如果你願意「關閉 Adblock (廣告阻擋器)」來支持我的話,我會非常感謝你 ヽ(・∀・)ノ