技術成長 · Web 開發 · JavaScript
JavaScript 的 this 到底指向誰?重點不是寫在哪裡,而是怎麼呼叫
上一篇談到 Closure 時,我們提到:
函式會保留自己建立時的作用域關係,因此即使離開原本的位置,仍然能存取相關的外部變數。
Closure 和 Lexical Scope 主要處理的是:
函式應該沿著哪一條作用域路線尋找變數?
但是 JavaScript 裡還有另一個很容易讓人混亂的東西:
this例如:
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
user.showName(); // Dennis這裡的 this 指向 user,所以可以取得:
user.name但是如果把同一個函式拿出來:
const show = user.showName;
show();結果可能就不再是 "Dennis"。
咦?
明明是同一個函式,為什麼只是換一種呼叫方式,this 就改變了?
這就是今天要處理的問題。
---
什麼是 this?
可以先用一句話理解:
this 是函式執行時,用來代表「這一次是由誰來呼叫或使用這個函式」的特殊值。不過,這句話只是方便理解,並不是所有情況都能直接套用。
比較重要的核心原則是:
一般函式中的 this,通常不是看函式寫在哪裡,而是看它這一次怎麼被呼叫。這和上一篇的 Lexical Scope 剛好不同。
| 概念 | 主要看什麼? |
|---|---|
| Lexical Scope | 函式寫在哪裡 |
| Closure | 函式建立時連結了哪個外部環境 |
一般函式的 this | 函式怎麼被呼叫 |
Arrow Function 的 this | 外層作用域的 this |
Arrow Function 比較特別,後面會先整理它與 this 有關的基本行為;下一篇再完整比較它和一般函式在 arguments、new、建構函式及其他語法上的差異。
---
Tips:this 不是一般變數
this 看起來很像一個普通變數,但我們不能自己宣告它:
const this = "Dennis";
// SyntaxError也不能直接替它重新賦值:
this = "Dennis";
// SyntaxError對一般函式來說,它是 JavaScript 在函式執行時,根據呼叫方式提供的特殊值。
所以理解一般函式的 this 時,不要像普通變數一樣沿著 Scope Chain 尋找它,而要先觀察:
這個函式是怎麼被呼叫的?
Arrow Function 比較特殊,它不會建立自己的 this,而是沿用外層環境原本的 this。
---
第一種:直接呼叫一般函式
先看最單純的函式:
function showThis() {
console.log(this);
}
showThis();這種沒有透過物件,也沒有使用 call()、apply() 或 new 的呼叫方式,可以先稱為「直接呼叫」。
但是這段程式輸出什麼,和執行環境及嚴格模式有關。
非嚴格模式
在傳統瀏覽器 Script 的非嚴格模式下,直接呼叫一般函式時,this 通常會指向全域物件。
在瀏覽器中,全域物件通常是:
window例如:
function showThis() {
console.log(this === window);
}
showThis(); // true但這個說法不能套用到所有 JavaScript 環境。
Node.js、ES Module 和不同執行方式,都可能有不同結果。
---
嚴格模式
加入嚴格模式後:
"use strict";
function showThis() {
console.log(this);
}
showThis(); // undefined直接呼叫一般函式時,this 會是:
undefinedTips:什麼是嚴格模式?
嚴格模式可以透過下面這行開啟:
"use strict";它會讓 JavaScript 使用比較嚴格的執行規則,避免某些容易造成錯誤的舊行為。
例如在非嚴格模式下,直接呼叫函式時,JavaScript 可能自動把 this 指向全域物件。
但在嚴格模式下,它不會偷偷替你補成全域物件,而是直接讓 this 維持 undefined。
另外,ES Module 預設就會使用嚴格模式,不需要另外寫:
"use strict";---
第二種:當成物件方法呼叫
來看最常見的物件方法:
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
user.showName(); // Dennis這次呼叫的重點是:
user.showName();呼叫函式時,點號前面是:
user因此這一次的 this 會指向 user。
可以暫時用下面的方式理解:
this.name等於:
user.name所以最後輸出:
Dennis老樣子,程式白話文翻譯
先建立一個 user 物件:
const user = {物件裡有一個名字:
name: "Dennis"以及一個可以顯示名字的方法:
showName() {
console.log(this.name);
}最後透過 user 呼叫:
user.showName();因為這一次是 user 在點號前面呼叫 showName(),所以函式裡的 this 指向 user。
簡單來說,user 物件內部有一個自己的方法 showName()。當我們在外部使用 user.showName() 呼叫這個方法時,可以理解成是由 user 呼叫自己所擁有的方法。
因為這次呼叫時,點號前面是 user,所以 showName() 裡面的 this 會指向 user。
因此,在一般函式的方法呼叫情境中,可以先記住一個判斷方式:
先看函式被呼叫時,點號前面是誰,this 通常就會指向誰。---
Tips:Method 是什麼?
Method 通常翻譯成「方法」。
白話來說:
當一個函式放在物件裡,並作為物件提供的功能使用時,我們通常會稱它為方法。
例如:
const dog = {
bark() {
console.log("汪");
}
};這裡的 bark() 就是 dog 物件的方法。
---
函式寫在物件裡,this 就永遠是那個物件嗎?
不一定。
來看下面這段程式:
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
const show = user.showName;這裡我們把:
user.showName所指向的函式取出來,再交給 show:
const show = user.showName;接著直接呼叫:
show();這一次不再是:
user.showName();而是:
show();點號前面的 user 不見了,呼叫方式已經改變。
因此 this 也不再保證指向 user。
在嚴格模式下,this 會是 undefined:
"use strict";
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
const show = user.showName;
show();
// TypeError因為程式實際上是在嘗試取得:
undefined.name因此發生 TypeError。
這說明了一件很重要的事:
this 不會因為函式曾經住在某個物件裡,就永遠記得那個物件。講到這裡是不是想到了什麼
沒錯,就是上一篇談到的:
Closure(閉包)
還記得當時提到的 Lexical Scope(詞法作用域)嗎?
函式會保留自己建立時的作用域關係,因此即使離開原本的位置,仍然可以存取相關的外部變數。
不過,Closure 和一般函式的 this,判斷方式並不相同。
可以先用下面這兩句話記住它們的差別:
Closure:看函式「在哪裡建立」。
一般函式的this:看函式「怎麼被呼叫」。
Closure 會念舊,記得函式建立時周圍有哪些外部變數;但一般函式的 this 可不會這樣。
它不會因為函式曾經住在某個物件裡,就永遠記得那個物件,而是只看這一次函式到底是怎麼被呼叫的。
---
同一個函式,可以擁有不同的 this
來看另一個例子:
function showName() {
console.log(this.name);
}
const userA = {
name: "Dennis",
showName
};
const userB = {
name: "IRyS",
showName
};
userA.showName(); // Dennis
userB.showName(); // IRySuserA.showName 和 userB.showName 使用的是同一個函式:
function showName() {
console.log(this.name);
}但是呼叫方式不同:
userA.showName();
userB.showName();第一次呼叫時,this 指向 userA。
第二次呼叫時,this 指向 userB。
所以輸出分別是:
Dennis
IRyS白話來說:
函式本人沒有換,只是這一次換成不同物件呼叫它,所以 this 也跟著換人。---
物件巢狀時,this 看哪一個?
來看巢狀物件:
const company = {
name: "Cover",
department: {
name: "Hololive",
showName() {
console.log(this.name);
}
}
};
company.department.showName(); // Hololive這次呼叫是:
company.department.showName();最靠近函式呼叫位置、點號前面的物件是:
company.department所以 this 指向 department,不是最外層的 company。
因此輸出:
Hololive可以先記成:
物件方法呼叫時,先看函式前面直接接著的那個物件。
---
Callback 為什麼容易弄丟 this?
Callback 很常讓 this 發生變化。
例如:
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
setTimeout(user.showName, 1000);我們傳給 setTimeout() 的是:
user.showName也就是函式本身。
並不是現在立刻執行:
user.showName();等到 Timer 之後呼叫這個函式時,原本的:
user.showName()呼叫形式已經不存在。
所以函式裡的 this 不會再自動指向 user。
至於最後的 this 具體是什麼,可能受到執行環境與呼叫方式影響,但可以確定的是:
它不是因為這個函式原本放在user裡,就一定繼續指向user。
---
Tips:Callback 是什麼?
Callback 通常翻譯成「回呼函式」。
白話來說:
先把一個函式交給另一段程式,請它在適當的時機幫我們呼叫。
例如:
setTimeout(function () {
console.log("時間到了");
}, 1000);這裡傳入 setTimeout() 的匿名函式,就是一個 Callback。
它不會在傳入時立刻執行,而是先交給 setTimeout(),至少等待 1000 毫秒後,才有機會被呼叫。
不過,Callback 不一定都是非同步的。像 map()、filter() 和 forEach() 接收的函式,也都是 Callback,但通常會同步執行。
可以先這樣理解:
Callback 是一種函式的使用方式;Event Loop 則負責協調非同步任務的執行時機。
更完整的 Event Loop 執行流程,之後會再另外介紹。
---
如何手動指定 this?
JavaScript 提供了三個常見方法:
call()
apply()
bind()它們都可以控制一般函式執行時的 this。
但三者的使用方式不太一樣。
---
使用 call() 指定 this
先看一個函式:
function introduce(age) {
console.log(`我是${this.name},今年${age}歲`);
}再建立一個物件:
const user = {
name: "Dennis"
};現在使用 call():
introduce.call(user, 20);輸出:
我是Dennis,今年20歲call() 的第一個參數:
user會成為這次函式執行時的 this。
後面的參數:
20則會傳給原本函式的 age。
Tips:call() 的白話翻譯
下面這行:
introduce.call(user, 20);可以白話理解成:
立刻執行introduce(),並暫時讓函式裡的this指向user,同時把20傳給age。
---
使用 apply() 指定 this
apply() 和 call() 很像,也會立刻執行函式。
差別在於參數的傳法。
先建立函式與要作為 this 的物件:
function introduce(age, city) {
console.log(`我是${this.name},今年${age}歲,住在${city}`);
}
const user = {
name: "Dennis"
};使用 call() 時,參數一個一個傳:
introduce.call(user, 20, "巴黎");使用 apply() 時,參數要放進陣列:
introduce.apply(user, [20, "巴黎"]);兩者都會輸出:
我是Dennis,今年20歲,住在巴黎Tips:call() 和 apply() 的差異
可以先這樣記:
call(this值, 參數一, 參數二);apply(this值, [參數一, 參數二]);兩者都會立刻執行函式,只是參數格式不同。
這裡的 user 會成為函式執行時的 this,而 20 和 "巴黎" 則會分別傳給 age 與 city。
---
使用 bind() 固定 this
bind() 和前面兩個最大的差別是:
bind() 不會立刻執行原本的函式。它會回傳一個新的函式。
例如:
function showName() {
console.log(this.name);
}
const user = {
name: "Dennis"
};
const boundShowName = showName.bind(user);這時候 showName() 還沒有執行。
bind() 只是建立了一個新的函式:
boundShowName這個新函式的 this 已經綁定為 user。
之後呼叫:
boundShowName(); // Dennis程式白話文翻譯
const boundShowName = showName.bind(user);可以理解成:
根據原本的showName建立一個新的綁定函式,並事先指定它之後執行時,this要使用user。這個過程不會立刻執行原本的函式。
這個特性很適合解決 Callback 遺失 this 的問題:
setTimeout(boundShowName, 1000);即使函式之後才被 Timer 呼叫,boundShowName 的 this 仍然已經被綁定到 user。
---
call()、apply()、bind() 簡單整理
| 方法 | 是否立刻執行 | 參數傳法 | 回傳內容 |
|---|---|---|---|
call() | 會 | 一個一個傳 | 原函式執行結果 |
apply() | 會 | 使用陣列傳入 | 原函式執行結果 |
bind() | 不會 | 一個一個傳 | 綁定好 this 的新函式 |
短短記法:
call():直接當場給,商品一件一件交給你。apply():直接當場給,但商品先幫你裝進同一個箱子,再整箱交給你。(參數打包成陣列)bind():先把收件人設定好,也可以先填入部分商品資料,產生一張新的訂單,之後真正呼叫時才出貨。
---
使用 new 時,this 指向誰?
this 也常出現在建構函式裡:
function User(name) {
this.name = name;
}
const dennis = new User("Dennis");
console.log(dennis.name); // Dennis這裡使用了:
new User("Dennis")當函式透過 new 呼叫時,JavaScript 會先建立一個新的物件,並讓函式裡的 this 指向這個新物件。
接著,字串 "Dennis" 會傳入 User() 的 name 參數:
function User(name) {
this.name = name;
}因此:
this.name = name;可以理解成:
把name目前保存的"Dennis",放進新物件的name屬性。
執行完成後,新物件大致會是:
{
name: "Dennis"
}最後,這個新物件才會交給 dennis:
const dennis = new User("Dennis");所以我們可以透過:
dennis.name取得剛才存進物件的 "Dennis"。
Tips:建構函式是什麼?
建構函式是預計搭配 new 使用,並用來建立物件的函式。
傳統上,建構函式的名稱會將第一個字母寫成大寫:
function User(name) {
this.name = name;
}接著搭配 new 呼叫:
const dennis = new User("Dennis");名稱大寫並不是 JavaScript 強制規定的語法,而是一種常見的命名慣例,可以提醒其他開發者:
這個函式預計要搭配 new 呼叫。---
new 大致做了哪些事情?
下面這行:
const dennis = new User("Dennis");可以先簡化理解成四個步驟:
- 建立一個新物件。
- 讓建構函式中的
this指向新物件。 - 執行建構函式內容。
- 將建立好的物件交給
dennis。
所以:
function User(name) {
this.name = name;
}會把 name 放進新物件。
最後得到:
{
name: "Dennis"
}這只是方便理解的簡化流程,實際上 new 還會處理原型鏈等內容,之後談到 Prototype 時再繼續深入。
---
Arrow Function 的 this 比較特別
前面提到的一般函式,this 通常會根據呼叫方式決定。
但是 Arrow Function 不一樣。
const showThis = () => {
console.log(this);
};Arrow Function 沒有自己的 this。
它會沿用外層環境的 this。
例如:
const user = {
name: "Dennis",
showNameLater() {
setTimeout(() => {
console.log(this.name);
}, 1000);
}
};
user.showNameLater(); // Dennis先呼叫:
user.showNameLater();所以 showNameLater() 裡的一般方法 this 指向 user。
裡面的 Arrow Function 沒有自己的 this,因此沿用 showNameLater() 的 this。
最後就可以取得:
user.name並輸出:
DennisTips:Arrow Function 是什麼?
Arrow Function 就是使用箭頭語法撰寫的函式:
const add = (a, b) => {
return a + b;
};也可以簡寫成:
const add = (a, b) => a + b;它不只是一般函式的短寫版本。
Arrow Function 在 this、arguments、new 等行為上,都和一般函式不同。
下一篇會再專門比較。
---
為什麼不建議使用 Arrow Function 當物件方法?
來看一個容易踩雷的寫法:
const user = {
name: "Dennis",
showName: () => {
console.log(this.name);
}
};
user.showName();很多人會以為:
user.showName();所以 this 應該指向 user。
但 Arrow Function 沒有自己的 this。
它不會因為被寫進 user,就自動讓 this 指向 user。
它會沿用建立位置外層的 this。
因此這裡通常無法取得預期的:
Dennis物件方法若需要使用該物件作為 this,通常應該使用一般方法:
const user = {
name: "Dennis",
showName() {
console.log(this.name);
}
};
user.showName(); // Dennis---
this 的常見判斷順序
看到函式裡的 this 時,可以按照下面的順序判斷。
第一個:是不是 Arrow Function?
Arrow Function 沒有自己的 this。
它不會使用一般函式的呼叫規則決定 this,而是直接沿用外層環境原本的 this。
如果不是 Arrow Function,再繼續判斷一般函式的呼叫方式。
---
第二個:有沒有使用 new?
new User("Dennis");有使用 new,this 會指向新建立的物件。
---
第三個:是不是由 bind() 建立的新函式?
const boundShowName = showName.bind(user);
boundShowName();bind() 會事先將新函式的 this 綁定到指定的物件。
因此之後呼叫 boundShowName() 時,this 仍然會使用 user。
---
第四個:有沒有使用 call() 或 apply()?
showName.call(user);如果有手動指定,就先看指定的物件。
---
第五個:是不是物件方法呼叫?
user.showName();如果是,通常看點號前面的物件。
---
第六個:是不是直接呼叫?
showName();如果只是直接呼叫:
- 嚴格模式下通常是
undefined - 傳統瀏覽器 Script 的非嚴格模式下,可能是全域物件
---
常見誤解
誤解一:this 永遠指向函式所在的物件
不一定。
const show = user.showName;
show();函式被取出來直接呼叫後,呼叫方式已經改變,this 也可能改變。
---
誤解二:this 和作用域鏈一樣,會往外尋找
一般函式的 this 不是普通變數,不會沿著 Scope Chain 一層一層尋找。
一般函式通常根據呼叫方式決定 this。
只有 Arrow Function 會沿用外層的 this。
---
誤解三:Arrow Function 的 this 一定指向外層物件
Arrow Function 沿用的是外層作用域中的 this,不一定是外層程式碼看起來所在的物件。
例如:
const user = {
name: "Dennis",
showName: () => {
console.log(this.name);
}
};Arrow Function 雖然寫在 user 物件中,但物件本身不會建立一個新的 this 作用域。
所以它不會自動取得 user。
---
誤解四:bind() 會直接執行函式
不會。
const newFunction = showName.bind(user);這一行只會產生一個新函式。
真正執行要再呼叫:
newFunction();---
簡單整理
一般函式中的 this,通常由函式的呼叫方式決定。
常見情況包括:
- 直接呼叫一般函式時,嚴格模式下的
this通常是undefined。 - 傳統瀏覽器 Script 的非嚴格模式下,直接呼叫時可能指向全域物件。
- 使用
user.showName()呼叫時,this通常指向user。 - 將方法取出後直接呼叫,原本的
this可能會遺失。 call()和apply()可以指定this並立刻執行函式。bind()會回傳一個綁定好this的新函式。- 使用
new呼叫時,this會指向新建立的物件。 - Arrow Function 沒有自己的
this,會沿用外層的this。
最後濃縮成一句話:
一般函式的this主要看「怎麼呼叫」;Arrow Function 則不建立自己的this,而是直接沿用外層環境原本的this。
上一篇的 Closure 處理的是:
函式要去哪裡尋找外部變數?
這一篇的 this 處理的是:
函式這一次執行時,this 代表誰?下一篇,我們就可以專門比較:
Arrow Function 和一般 function 的 this 到底差在哪裡?因為 Arrow Function 並不只是少寫一個 function 而已;它和一般函式在 this、arguments,以及能否搭配 new 作為建構函式等行為上,都有明顯差異。