或許我從一開始就沒有很懂 this
前言
雖然我寫過很多篇關於 this
的文章,但是這一次我想試著更深入探討 this
這個關鍵字,畢竟對於一個 JavaScript 工程師、前端開發者來講,this
是一個非常常見的關鍵字。
this
一開始我們先來聊聊 this
是什麼,this
在 MDN 文件上是歸類為「運算式與運算子」中的主要運算式。
這是什麼意思呢?舉凡 {}
、[]
、function
都是屬於運算式的一種。
注意,這邊所指的 function
意思是宣告函式之後不加上名稱的匿名表達函式。
比如說:
1 | function fn() {} // 具名陳述式 |
而在此所指的是這種寫法:
1 | const fn = function() {}; |
那麼什麼是運算式呢?運算式也就是我們常說的表達式與陳述式,當我們輸入一段程式碼的時候,它會回傳一個東西給你。
1 | []; // Array [] |
但是如果你嘗試直接輸入 {}
你應該會看到 undefined
,因為你單純的輸入 {}
通常會被 JavaScript 判定成一個陳述式,因此在此所指的 {}
是指 Object initializer(物件字面值)的寫法,當然直接撰寫 {}
的寫法也有一些有趣的狀況,我們來稍微聊一下。
比如說:
1 | { |
這種寫法是比較少見的,但是如果你的宣告變數的方式若不同的話,那麼所得到的結果也就會跟著不同。
比如說:
1 | { |
反之如果你使用物件字面值(物件實字)的寫法則是會出現錯誤:
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 buttons = document.querySelectorAll('.btn'); |
如果你使用過 Vue 開發過的話…
那麼你會很常看到這種寫法:
1 | const app = new Vue({ |
甚至是如果你在一個普通的函式陳述式內呼叫 this
也可以:
1 | function fn() { |
因此我們可以了解到 this
無所不在,甚至我們可能常常無意識的去使用它,但是也有可能我們太過理所當然的使用而導致沒有真的了解它,如果一直逃避了解它的話,在實際開發時往往會遇到很多很奇怪的蟲子。
比如說,我預期會希望可以取得我的名字:
1 | function fn () { |
這種時候就可以看到 this
的指向完全跑掉了。
那麼講那麼多廢話,this
是哪裡來的呢?簡單來講,JavaScript 在建立執行環境的時候就會建立這個關鍵字,因此你先知道這一點就好,剩下的我們後面繼續聊。
全域環境
基本上,如果你在全域環境下直接呼叫 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
指向跑掉,而在上面的 Vue 程式碼中有兩種解法
第一種是宣告一個變數來儲存 this
:
1 | const app = new Vue({ |
第二種方式是使用箭頭函式:
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
指向還算是滿容易的(我自己覺得),你只需要看函式是在哪一個物件下呼叫並執行就可以看出來,舉例來說 obj.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'); |
但是也有一種狀況會導致 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
。