關於 JavaScript 的 this 各種用法與探討

前言

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

this

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

這是什麼意思呢?舉凡 {}[]function 都是屬於運算式 (Expressions) 的一種,注意到英文了嗎?其實就是表達式的意思,所以接下來不論你看到的是講運算式還是表達式,通通指的都是 Expressions。

注意,這邊所指的 function 意思是宣告函式之後不加上名稱的匿名函式表達式,如果你無法區分的話也不用太擔心,這邊我會盡可能去舉例出來。

首先哪一種是具名函式陳述式呢?也就是我們很常見的函式宣告方式:

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

而所謂的匿名函式表達式就是像是下方這幾種宣告方式:

1
2
3
4
5
6
7
8
9
const fn = function() {};

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

const obj2 = {
fn() {}, // 請注意,這還是匿名表達式的一種,只是變成縮寫形式而已
};

(這邊先不提 IIFE (立即執行函式))

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

1
2
[]; // Array []
myName = 'Ray'; // "Ray"

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

首先先讓我們看一個基本的範例程式碼:

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

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

實務開發上來講,這種寫法是比較少見的,但是如果你宣告變數的方式也會影響 console.log(myName) 輸出的結果,在上面可以看到我是使用一個 var 來宣告變數,但如果調整成 ES6 的語法 (letconst) 宣告呢?

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

console.log(myName); // Uncaught ReferenceError: myName is not defined

至於原因是什麼會建議你參考我先前寫的筆記 JavaScript 核心觀念(59) - ES6 章節:Let 及 Const - Let, Const 基本概念,這邊我就不探討這個問題了。

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

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 btns = document.querySelectorAll('.btn');

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

如果你使用過 Vue 開發過的話,那麼你會很常看到這種寫法:

(以下是 Vue2 經典寫法, Vue3 之後稱之為 Option API)

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 是無所不在,甚至你已經無意識的去使用它,但是也有可能我們太過理所當然的使用而導致沒有真的了解它,如果一直逃避了解它的話,在實際開發時往往會遇到很多很奇怪的蟲子(Bug)。

比如說,我預期會取得我的名字:

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

obj.getName(); // Ray;

看起來非常的正常,但是如果你不小心寫成以下這種的話,你可能會晴天霹靂:

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

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

好吧,應該是滿滿的 WTF…,我們可以看到 this 完全跑掉了。

前面列出那麼多範例程式碼與講了那麼多廢話,其實就是想要讓你知道 this 這個關鍵字的雷,如果你不好好認識並了解它的話勢必在開發上一定會踩到雷(Bug)。

那…this 是哪裡來的呢?簡單來講 JavaScript 在建立每一個執行環境的時候就會同時建立這個關鍵字,舉凡函式建立也是一樣,因此你只要先知道這一點就好,後面就讓我們了解 this 各種操作下的狀況與指向吧。

全域環境

基本上,如果你在全域環境下直接呼叫 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 指向會跑掉的原因,那麼該如何解決呢?其實有兩種解法,讓我們來認識一下解決的方式。

第一種是宣告一個變數來儲存 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();
}
});

(ps. vm 的意思是 ViewModel。)

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

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 指向還算是滿容易的(我自己覺得),你只需要看函式是在哪一個物件下呼叫並執行就可以看出來,舉例來說 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
11
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: '<button type="button" class="btn">1</button>',
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

參考文獻