關於 JavaScript 的 this 各種用法與探討
前言
雖然我寫過很多篇關於 this
的文章,但是這一次我想試著更深入探討 this
這個關鍵字,畢竟對於一個 JavaScript 工程師、前端開發者來講,this
是一個非常常見的關鍵字。
this
一開始我們先來聊聊 this
是什麼,this
在 MDN 文件上是歸類為「運算式與運算子」中的主要運算式。
這是什麼意思呢?舉凡 {}
、[]
、function
都是屬於運算式 (Expressions) 的一種,注意到英文了嗎?其實就是表達式的意思,所以接下來不論你看到的是講運算式還是表達式,通通指的都是 Expressions。
注意,這邊所指的 function
意思是宣告函式之後不加上名稱的匿名函式表達式,如果你無法區分的話也不用太擔心,這邊我會盡可能去舉例出來。
首先哪一種是具名函式陳述式呢?也就是我們很常見的函式宣告方式:
1 | function fn() {} // 具名陳述式 |
而所謂的匿名函式表達式就是像是下方這幾種宣告方式:
1 | const fn = function() {}; |
(這邊先不提 IIFE (立即執行函式))
那麼什麼是運算式呢?運算式也就是我們常說的表達式與陳述式,當我們輸入一段程式碼的時候,它會回傳一個東西給你。
1 | []; // Array [] |
但是如果你嘗試直接輸入 {}
你應該會看到 undefined
,因為你單純的輸入 {}
通常會被 JavaScript 判定成一個陳述式,因此在此所指的 {}
是指 Object initializer(物件字面值) 的寫法,當然直接撰寫 {}
的寫法也有一些有趣的狀況,下面就讓我們來稍微簡單聊一下。
首先先讓我們看一個基本的範例程式碼:
1 | { |
實務開發上來講,這種寫法是比較少見的,但是如果你宣告變數的方式也會影響 console.log(myName)
輸出的結果,在上面可以看到我是使用一個 var
來宣告變數,但如果調整成 ES6 的語法 (let
、const
) 宣告呢?
1 | { |
至於原因是什麼會建議你參考我先前寫的筆記 JavaScript 核心觀念(59) - ES6 章節:Let 及 Const - Let, Const 基本概念,這邊我就不探討這個問題了。
反之如果你使用物件字面值(物件實字)的寫法則是會出現錯誤:
1 | { |
當你輸入以上程式碼到瀏覽器之後,你應該會直接看到瀏覽器噴出一個錯誤給你(各家瀏覽器呈現錯誤方式可能有所不同,在此我所使用的是 FireFox) Uncaught SyntaxError: expected expression, got '}'
,簡單來講就是這個表達式必須被一個容器給裝著,通常這個容器會是一個變數。
當然匿名函式也是一樣的狀況:
1 | function() {}; |
只是匿名函式則是會噴出 Uncaught SyntaxError: function statement requires a name
的字眼。
當然這不是我們這一次要深入探討的主題,這邊也只是稍微聊一下而已。
這一篇主要的主角是 this
,前面只是簡單聊一下並介紹一下什麼是表達式與陳述式,否則後面我怕你會感到困惑。
因此 this
會被歸類為表達式的話,就代表著當你直接在瀏覽器的控制台直接輸入它會回傳一個值給你:
1 | this; // 瀏覽器的 window 物件 |
當然你也可以用一個變數來儲存這個 this
回傳的值:
1 | var myName = 'is Ray'; |
那…this
是哪裡來的?實際開發的時候,往往我們可以很常看到 this
這個關鍵字的出沒。
比如說,以下是一個取得按鈕元素的 this
:
1 | <button type="button" class="btn">1</button> |
1 | const btns = document.querySelectorAll('.btn'); |
如果你使用過 Vue 開發過的話,那麼你會很常看到這種寫法:
(以下是 Vue2 經典寫法, Vue3 之後稱之為 Option API)
1 | const app = new Vue({ |
甚至是你在一個普通的函式陳述式內呼叫 this
也可以:
1 | function fn() { |
透過上面各種範例程式碼我們可以了解到 this
是無所不在,甚至你已經無意識的去使用它,但是也有可能我們太過理所當然的使用而導致沒有真的了解它,如果一直逃避了解它的話,在實際開發時往往會遇到很多很奇怪的蟲子(Bug)。
比如說,我預期會取得我的名字:
1 | function fn () { |
看起來非常的正常,但是如果你不小心寫成以下這種的話,你可能會晴天霹靂:
1 | function fn () { |
好吧,應該是滿滿的 WTF…,我們可以看到 this
完全跑掉了。
前面列出那麼多範例程式碼與講了那麼多廢話,其實就是想要讓你知道 this
這個關鍵字的雷,如果你不好好認識並了解它的話勢必在開發上一定會踩到雷(Bug)。
那…this
是哪裡來的呢?簡單來講 JavaScript 在建立每一個執行環境的時候就會同時建立這個關鍵字,舉凡函式建立也是一樣,因此你只要先知道這一點就好,後面就讓我們了解 this
各種操作下的狀況與指向吧。
全域環境
基本上,如果你在全域環境下直接呼叫 this
是會得到一個 window
的物件,而且是完全的相同。
比如說:
1 | this === window; // true |
甚至是 document
都是相同的:
1 | this.document === document; // true |
當然你也可以透過 this
直接在 Window 下新增一個屬性(在此並不是建立變數而是新增屬性,詳情可見此文)也是可以的。
比如說:
1 | this.myName = 'Ray'; |
就算你使用了 'use strict'
(嚴謹模式)模式, this
依然會指向 window
1 |
|
函式環境
在函式下所使用的 this
它的指向取決於你如何呼叫這個函式來決定它要參考誰,但是如果你「直接呼叫」這個函式,this
是會直接指向 window
,而這個行為又稱之為 **簡易呼叫(Sample Call)**。
比如說:
1 | function fn() { |
通常來講我們在實務開發上都會盡可能的避免簡易呼叫,這邊我直接舉例一段 Vue 中的一段程式碼:
1 | const app = new Vue({ |
看起來很正常對吧?現在我將程式碼稍微改變一下,這時候結果又會變成如何呢?
1 | const app = new Vue({ |
這下可神奇了,為什麼只是多增加一段 forEach 就會變成 undefined
?這邊我們試著想像一下 forEach
的實作(以下只是舉例):
1 | Array.prototype.forEach = function (callback){ |
我們可以看到傳入到 forEach
中的函式是直接被呼叫,因此就很容易導致 this
形成簡易呼叫,而這也是為什麼 this
指向會跑掉的原因,那麼該如何解決呢?其實有兩種解法,讓我們來認識一下解決的方式。
第一種是宣告一個變數來儲存 this
:
1 | const app = new Vue({ |
(ps. vm
的意思是 ViewModel。)
第二種方式是使用箭頭函式:
1 | const app = new Vue({ |
等等,為什麼使用箭頭函式就正常了?這完全超乎我們的想像與預期。
this
感覺上比我想像中的還難以掌握,但不用擔心,在後面我們都會一一細談為什麼。
那麼還有什麼狀況會形成簡易呼叫呢?通常來講只要你直接呼叫一個函式或是傳入一個匿名函式,都很容易發生簡易呼叫,就算使用 IIFE 也會發生這個問題。
比如說:
1 | const app = new Vue({ |
因此可以注意到形成簡易呼叫的關鍵不外乎以下特徵:
- 通常是直接呼叫函式導致。
- 如果是傳入一個匿名函式,也會成簡易呼叫。
但是使用箭頭函式的話結果可能不同,至於原因是為什麼我們後面再繼續談。
物件函式
另一個 this
很常見的狀況在於物件的函式內,一個不小心也是非常容易導致 this
指向到處跑的狀況:
1 | var myName = 'oh No!'; |
這邊還有另一個非常有趣的現象,如果你將 var
改成 ES6 的 let
、const
反而是會變成另一種結果
比如說:
1 | const myName = 'oh No!'; |
想找出物件調用下的 this
指向還算是滿容易的(我自己覺得),你只需要看函式是在哪一個物件下呼叫並執行就可以看出來,舉例來說 fn();
是在 obj
底下被呼叫執行,因此 this
就會被指向到 obj
。
那麼單純的呼叫 fn()
則會形成簡易呼叫的原因在於,在此我們是將變數 fn
參考到 obj.fn
的路徑並沒有呼叫,而是在下一行直接呼叫,請注意是直接呼叫,因此這行為就形成了簡易呼叫,這也導致了 this
直接指向到 window
底下。
new 建構子
那麼 new
建構子也會有一個很奇妙且好玩的狀況,當若我們在要呼叫的函式前面補上 new
建構子時,此時裡面的 this
就會作為物件的屬性使用:
1 | function fn(myName) { |
這與使用物件字面值建立的方式有異曲同工之處
1 | const obj = { |
當然還有一種狀況會導致 new
的回傳結果改變:
1 | function fn(myName) { |
使用 return
回傳另一個物件的行為確實是會導致 new
的物件被消滅,當然實際開發上是幾乎不會有這種寫法,如果有的話,我想他應該是很想被請出去喝咖啡吧?
DOM
this
在 DOM 的表現上又是更不一樣,當你搭配上了 addEventListener
不管怎麼樣 this
都會指向到該 DOM 元素,在前面的範例其實有舉例到。
比如說:
1 | <button type="button" class="btn">1</button> |
1 | const buttons = document.querySelectorAll('.btn'); |
當你宣告了 addEventListener
監聽事件之後,基本上是會將後面所傳入的函式卡在某個地方等待被你呼叫,而這個函式在預設狀況下會指向你所監聽的 DOM 上,所以你可以把它想像成像這樣:
1 | const click = { |
但是也有一種狀況會導致 this
指向參考跑掉,也就是箭頭函式:
1 | const buttons = document.querySelectorAll('.btn'); |
是不是感覺每次只要扯到箭頭函式就感覺特別噁心討厭呢?別擔心,接下來讓我們了解一下 ES6 箭頭函式到底在搞什麼鬼。
arrow function expression (箭頭函式表達式)
在說明箭頭函式之前我們要先了解到傳統函式與箭頭函式的差別,在 MDN 中有說明到箭頭函式沒有自己的 this
、arguments
、super
、new.target
,後三者並不是重點,而主要重點是「箭頭函式沒有自己的 this
」,這代表什麼呢?代表當使用箭頭函式,因為沒有自己的 this
那麼這時候它的 this
該從哪裡來?這時候它會參考外層,也就是父層(上一層)。
因此剛才有許多的範例都有這種狀況,明明 this
一開始是跑掉的,但是改成箭頭函式之後反而卻正常。
比如說:
1 | const app = new Vue({ |
在上面範例中,我們原本若是寫 array.forEach(function () { ... });
是會導致 this
指向到 window
or undefined
,在前面有講到箭頭函式沒有自己的 this
,因此它會參照父層的 this
(概念類似原型鏈),因此傳統函式當有自己的 this
時,就會形成前面所講的簡易呼叫,這也就是為什麼適當的使用箭頭函式可以幫助你更簡化程式碼,甚至是更好使用 this
。
但是在物件調用下就必須多加小心:
1 | var myName = 'oh No!'; |
在上面程式碼中,this
並沒有在其他函式下,因此就會直接參考最外層。
強制綁定 this
JavaScript 的 Function.prototype
有提供三種方法可以幫助我們強制綁定 this
的指向,分別是:
call()
apply()
bind()
比如說:
1 | var myName = 'oh No!'; |
這邊要注意一件事情 call()
傳入欲給定的 this
參數之後,就會立刻被執行。
call()
除了傳入給定 this 的參數之外還可以傳入其他參數。
比如說:
1 | var myName = 'oh No!'; |
那麼 apply()
與 call()
是非常相同的存在,只是第二個參數接受型別不同,如果 call()
是接受一大推參數的話,那麼 apply()
則是接受陣列。
比如說:
1 | var myName = 'oh No!'; |
最後一個是 bind()
為什麼會將 bind()
放在最後一個呢?其實是有原因的,bind()
與 call()
非常雷同,只是 bind()
比較特別的地方是它並不會立刻馬上執行函式,而是先回傳已經綁定好的 this
函式。
比如說:
1 | var myName = 'oh No!'; |
這邊要注意一個小細節是如果你傳入的第一個參數是 null
、undefined
,那麼必定會重新指向到 window
底下,不論是 call()
、apply()
或者是 bind()
都會有這種狀況。
比如說:
1 | var myName = 'oh No!'; |
另一種綁定 this 方式
除了前面介紹的 call()
、apply()
以及 bind()
的綁定方式之外,其實還有別種綁定方式 this
的方式。
在前面我們有簡單了解到箭頭函式的方便性,我們可以將原本的一段寫法更簡化成一條,整體看起來就是潮。
比如說:
1 | const array = [1, 2, 3]; |
基本上只要知道使用箭頭函式時,裡面的 this
絕大部分時候都會參考父層的 this
這一關鍵點,大多都可以抓到 this
的指向,但是當若採用的是傳統函式寫法,那麼結果就會完全不同,而這種時候就會形成簡易呼叫。
比如說:
1 | var myName = 'oh No!'; |
而此時可能你不想使用 call()
、apply()
以及 bind()
來強制綁定 this
也不願意改成箭頭函式時,那你可以考慮針對這些迴圈傳入第二個參數。
沒有錯,你真的沒有看錯!
其實絕大部分的迴圈大多都可以傳入第二個參數,而第二個參數也是指定 this
的指向。
比如說:
1 | var myName = 'oh No!'; |
以目前 MDN 所提供的文件中,舉凡以下這幾個都具備第二個參數來指定 this
指向功能
forEach
filter
map
some
every
find
除了 reduce
不具備 this
指向之外,絕大部分都是具備第二參數來指定 this
。