夜行手記

技術成長 · Web 開發 · JavaScript

JavaScript 的 this 到底指向誰?重點不是寫在哪裡,而是怎麼呼叫

JavaScript 的 this 常讓人感到混亂,因為它不像一般變數一樣沿著作用域鏈尋找,而是會根據函式的呼叫方式決定。本篇將從直接呼叫、物件方法、Callback、call()、apply()、bind()、new,一路整理到 Arrow Function 的特殊行為,並搭配程式範例說明:為什麼同一個函式換一種呼叫方式後,this 就可能指向不同的對象。
12 分鐘閱讀

上一篇談到 Closure 時,我們提到:

函式會保留自己建立時的作用域關係,因此即使離開原本的位置,仍然能存取相關的外部變數。

Closure 和 Lexical Scope 主要處理的是:

函式應該沿著哪一條作用域路線尋找變數?

但是 JavaScript 裡還有另一個很容易讓人混亂的東西:

JavaScript
this

例如:

JavaScript
const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

user.showName(); // Dennis

這裡的 this 指向 user,所以可以取得:

JavaScript
user.name

但是如果把同一個函式拿出來:

JavaScript
const show = user.showName;

show();

結果可能就不再是 "Dennis"

咦?

明明是同一個函式,為什麼只是換一種呼叫方式,this 就改變了?

這就是今天要處理的問題。

---

什麼是 this

可以先用一句話理解:

this 是函式執行時,用來代表「這一次是由誰來呼叫或使用這個函式」的特殊值。

不過,這句話只是方便理解,並不是所有情況都能直接套用。

比較重要的核心原則是:

一般函式中的 this,通常不是看函式寫在哪裡,而是看它這一次怎麼被呼叫。

這和上一篇的 Lexical Scope 剛好不同。

概念主要看什麼?
Lexical Scope函式寫在哪裡
Closure函式建立時連結了哪個外部環境
一般函式的 this函式怎麼被呼叫
Arrow Function 的 this外層作用域的 this

Arrow Function 比較特別,後面會先整理它與 this 有關的基本行為;下一篇再完整比較它和一般函式在 argumentsnew、建構函式及其他語法上的差異。

---

Tips:this 不是一般變數

this 看起來很像一個普通變數,但我們不能自己宣告它:

JavaScript
const this = "Dennis";
// SyntaxError

也不能直接替它重新賦值:

JavaScript
this = "Dennis";
// SyntaxError

對一般函式來說,它是 JavaScript 在函式執行時,根據呼叫方式提供的特殊值。

所以理解一般函式的 this 時,不要像普通變數一樣沿著 Scope Chain 尋找它,而要先觀察:

這個函式是怎麼被呼叫的?

Arrow Function 比較特殊,它不會建立自己的 this,而是沿用外層環境原本的 this

---

第一種:直接呼叫一般函式

先看最單純的函式:

JavaScript
function showThis() {
    console.log(this);
}

showThis();

這種沒有透過物件,也沒有使用 call()apply()new 的呼叫方式,可以先稱為「直接呼叫」。

但是這段程式輸出什麼,和執行環境及嚴格模式有關。

非嚴格模式

在傳統瀏覽器 Script 的非嚴格模式下,直接呼叫一般函式時,this 通常會指向全域物件。

在瀏覽器中,全域物件通常是:

JavaScript
window

例如:

JavaScript
function showThis() {
    console.log(this === window);
}

showThis(); // true

但這個說法不能套用到所有 JavaScript 環境。

Node.js、ES Module 和不同執行方式,都可能有不同結果。

---

嚴格模式

加入嚴格模式後:

JavaScript
"use strict";

function showThis() {
    console.log(this);
}

showThis(); // undefined

直接呼叫一般函式時,this 會是:

JavaScript
undefined

Tips:什麼是嚴格模式?

嚴格模式可以透過下面這行開啟:

JavaScript
"use strict";

它會讓 JavaScript 使用比較嚴格的執行規則,避免某些容易造成錯誤的舊行為。

例如在非嚴格模式下,直接呼叫函式時,JavaScript 可能自動把 this 指向全域物件。

但在嚴格模式下,它不會偷偷替你補成全域物件,而是直接讓 this 維持 undefined

另外,ES Module 預設就會使用嚴格模式,不需要另外寫:

JavaScript
"use strict";

---

第二種:當成物件方法呼叫

來看最常見的物件方法:

JavaScript
const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

user.showName(); // Dennis

這次呼叫的重點是:

JavaScript
user.showName();

呼叫函式時,點號前面是:

JavaScript
user

因此這一次的 this 會指向 user

可以暫時用下面的方式理解:

JavaScript
this.name

等於:

JavaScript
user.name

所以最後輸出:

text
Dennis

老樣子,程式白話文翻譯

先建立一個 user 物件:

JavaScript
const user = {

物件裡有一個名字:

JavaScript
name: "Dennis"

以及一個可以顯示名字的方法:

JavaScript
showName() {
    console.log(this.name);
}

最後透過 user 呼叫:

JavaScript
user.showName();

因為這一次是 user 在點號前面呼叫 showName(),所以函式裡的 this 指向 user

簡單來說,user 物件內部有一個自己的方法 showName()。當我們在外部使用 user.showName() 呼叫這個方法時,可以理解成是由 user 呼叫自己所擁有的方法。

因為這次呼叫時,點號前面是 user,所以 showName() 裡面的 this 會指向 user

因此,在一般函式的方法呼叫情境中,可以先記住一個判斷方式:

先看函式被呼叫時,點號前面是誰,this 通常就會指向誰。

---

Tips:Method 是什麼?

Method 通常翻譯成「方法」。

白話來說:

當一個函式放在物件裡,並作為物件提供的功能使用時,我們通常會稱它為方法。

例如:

JavaScript
const dog = {
    bark() {
        console.log("汪");
    }
};

這裡的 bark() 就是 dog 物件的方法。

---

函式寫在物件裡,this 就永遠是那個物件嗎?

不一定。

來看下面這段程式:

JavaScript
const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

const show = user.showName;

這裡我們把:

JavaScript
user.showName

所指向的函式取出來,再交給 show

JavaScript
const show = user.showName;

接著直接呼叫:

JavaScript
show();

這一次不再是:

JavaScript
user.showName();

而是:

JavaScript
show();

點號前面的 user 不見了,呼叫方式已經改變。

因此 this 也不再保證指向 user

在嚴格模式下,this 會是 undefined

JavaScript
"use strict";

const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

const show = user.showName;

show();
// TypeError

因為程式實際上是在嘗試取得:

JavaScript
undefined.name

因此發生 TypeError

這說明了一件很重要的事:

this 不會因為函式曾經住在某個物件裡,就永遠記得那個物件。

講到這裡是不是想到了什麼

沒錯,就是上一篇談到的:

Closure(閉包)

還記得當時提到的 Lexical Scope(詞法作用域)嗎?

函式會保留自己建立時的作用域關係,因此即使離開原本的位置,仍然可以存取相關的外部變數。

不過,Closure 和一般函式的 this,判斷方式並不相同。

可以先用下面這兩句話記住它們的差別:

Closure:看函式「在哪裡建立」。
一般函式的 this:看函式「怎麼被呼叫」。

Closure 會念舊,記得函式建立時周圍有哪些外部變數;但一般函式的 this 可不會這樣。

它不會因為函式曾經住在某個物件裡,就永遠記得那個物件,而是只看這一次函式到底是怎麼被呼叫的。

---

同一個函式,可以擁有不同的 this

來看另一個例子:

JavaScript
function showName() {
    console.log(this.name);
}

const userA = {
    name: "Dennis",
    showName
};

const userB = {
    name: "IRyS",
    showName
};

userA.showName(); // Dennis
userB.showName(); // IRyS

userA.showNameuserB.showName 使用的是同一個函式:

JavaScript
function showName() {
    console.log(this.name);
}

但是呼叫方式不同:

JavaScript
userA.showName();
userB.showName();

第一次呼叫時,this 指向 userA

第二次呼叫時,this 指向 userB

所以輸出分別是:

text
Dennis
IRyS

白話來說:

函式本人沒有換,只是這一次換成不同物件呼叫它,所以 this 也跟著換人。

---

物件巢狀時,this 看哪一個?

來看巢狀物件:

JavaScript
const company = {
    name: "Cover",

    department: {
        name: "Hololive",

        showName() {
            console.log(this.name);
        }
    }
};

company.department.showName(); // Hololive

這次呼叫是:

JavaScript
company.department.showName();

最靠近函式呼叫位置、點號前面的物件是:

JavaScript
company.department

所以 this 指向 department,不是最外層的 company

因此輸出:

text
Hololive

可以先記成:

物件方法呼叫時,先看函式前面直接接著的那個物件。

---

Callback 為什麼容易弄丟 this

Callback 很常讓 this 發生變化。

例如:

JavaScript
const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

setTimeout(user.showName, 1000);

我們傳給 setTimeout() 的是:

JavaScript
user.showName

也就是函式本身。

並不是現在立刻執行:

JavaScript
user.showName();

等到 Timer 之後呼叫這個函式時,原本的:

JavaScript
user.showName()

呼叫形式已經不存在。

所以函式裡的 this 不會再自動指向 user

至於最後的 this 具體是什麼,可能受到執行環境與呼叫方式影響,但可以確定的是:

它不是因為這個函式原本放在 user 裡,就一定繼續指向 user

---

Tips:Callback 是什麼?

Callback 通常翻譯成「回呼函式」。

白話來說:

先把一個函式交給另一段程式,請它在適當的時機幫我們呼叫。

例如:

JavaScript
setTimeout(function () {
    console.log("時間到了");
}, 1000);

這裡傳入 setTimeout() 的匿名函式,就是一個 Callback。

它不會在傳入時立刻執行,而是先交給 setTimeout(),至少等待 1000 毫秒後,才有機會被呼叫。

不過,Callback 不一定都是非同步的。像 map()filter()forEach() 接收的函式,也都是 Callback,但通常會同步執行。

可以先這樣理解:

Callback 是一種函式的使用方式;Event Loop 則負責協調非同步任務的執行時機。

更完整的 Event Loop 執行流程,之後會再另外介紹。

---

如何手動指定 this

JavaScript 提供了三個常見方法:

JavaScript
call()
apply()
bind()

它們都可以控制一般函式執行時的 this

但三者的使用方式不太一樣。

---

使用 call() 指定 this

先看一個函式:

JavaScript
function introduce(age) {
    console.log(`我是${this.name},今年${age}歲`);
}

再建立一個物件:

JavaScript
const user = {
    name: "Dennis"
};

現在使用 call()

JavaScript
introduce.call(user, 20);

輸出:

text
我是Dennis,今年20歲

call() 的第一個參數:

JavaScript
user

會成為這次函式執行時的 this

後面的參數:

JavaScript
20

則會傳給原本函式的 age

Tips:call() 的白話翻譯

下面這行:

JavaScript
introduce.call(user, 20);

可以白話理解成:

立刻執行 introduce(),並暫時讓函式裡的 this 指向 user,同時把 20 傳給 age

---

使用 apply() 指定 this

apply()call() 很像,也會立刻執行函式。

差別在於參數的傳法。

先建立函式與要作為 this 的物件:

JavaScript
function introduce(age, city) {
    console.log(`我是${this.name},今年${age}歲,住在${city}`);
}

const user = {
    name: "Dennis"
};

使用 call() 時,參數一個一個傳:

JavaScript
introduce.call(user, 20, "巴黎");

使用 apply() 時,參數要放進陣列:

JavaScript
introduce.apply(user, [20, "巴黎"]);

兩者都會輸出:

text
我是Dennis,今年20歲,住在巴黎

Tips:call()apply() 的差異

可以先這樣記:

JavaScript
call(this值, 參數一, 參數二);
JavaScript
apply(this值, [參數一, 參數二]);

兩者都會立刻執行函式,只是參數格式不同。

這裡的 user 會成為函式執行時的 this,而 20"巴黎" 則會分別傳給 agecity

---

使用 bind() 固定 this

bind() 和前面兩個最大的差別是:

bind() 不會立刻執行原本的函式。

它會回傳一個新的函式。

例如:

JavaScript
function showName() {
    console.log(this.name);
}

const user = {
    name: "Dennis"
};

const boundShowName = showName.bind(user);

這時候 showName() 還沒有執行。

bind() 只是建立了一個新的函式:

JavaScript
boundShowName

這個新函式的 this 已經綁定為 user

之後呼叫:

JavaScript
boundShowName(); // Dennis

程式白話文翻譯

JavaScript
const boundShowName = showName.bind(user);

可以理解成:

根據原本的 showName 建立一個新的綁定函式,並事先指定它之後執行時,this 要使用 user。這個過程不會立刻執行原本的函式。

這個特性很適合解決 Callback 遺失 this 的問題:

JavaScript
setTimeout(boundShowName, 1000);

即使函式之後才被 Timer 呼叫,boundShowNamethis 仍然已經被綁定到 user

---

call()apply()bind() 簡單整理

方法是否立刻執行參數傳法回傳內容
call()一個一個傳原函式執行結果
apply()使用陣列傳入原函式執行結果
bind()不會一個一個傳綁定好 this 的新函式

短短記法:

  • call():直接當場給,商品一件一件交給你。
  • apply():直接當場給,但商品先幫你裝進同一個箱子,再整箱交給你。(參數打包成陣列)
  • bind():先把收件人設定好,也可以先填入部分商品資料,產生一張新的訂單,之後真正呼叫時才出貨。

---

使用 new 時,this 指向誰?

this 也常出現在建構函式裡:

JavaScript
function User(name) {
    this.name = name;
}

const dennis = new User("Dennis");

console.log(dennis.name); // Dennis

這裡使用了:

JavaScript
new User("Dennis")

當函式透過 new 呼叫時,JavaScript 會先建立一個新的物件,並讓函式裡的 this 指向這個新物件。

接著,字串 "Dennis" 會傳入 User()name 參數:

JavaScript
function User(name) {
    this.name = name;
}

因此:

JavaScript
this.name = name;

可以理解成:

name 目前保存的 "Dennis",放進新物件的 name 屬性。

執行完成後,新物件大致會是:

JavaScript
{
    name: "Dennis"
}

最後,這個新物件才會交給 dennis

JavaScript
const dennis = new User("Dennis");

所以我們可以透過:

JavaScript
dennis.name

取得剛才存進物件的 "Dennis"

Tips:建構函式是什麼?

建構函式是預計搭配 new 使用,並用來建立物件的函式。

傳統上,建構函式的名稱會將第一個字母寫成大寫:

JavaScript
function User(name) {
    this.name = name;
}

接著搭配 new 呼叫:

JavaScript
const dennis = new User("Dennis");

名稱大寫並不是 JavaScript 強制規定的語法,而是一種常見的命名慣例,可以提醒其他開發者:

這個函式預計要搭配 new 呼叫。

---

new 大致做了哪些事情?

下面這行:

JavaScript
const dennis = new User("Dennis");

可以先簡化理解成四個步驟:

  1. 建立一個新物件。
  2. 讓建構函式中的 this 指向新物件。
  3. 執行建構函式內容。
  4. 將建立好的物件交給 dennis

所以:

JavaScript
function User(name) {
    this.name = name;
}

會把 name 放進新物件。

最後得到:

JavaScript
{
    name: "Dennis"
}

這只是方便理解的簡化流程,實際上 new 還會處理原型鏈等內容,之後談到 Prototype 時再繼續深入。

---

Arrow Function 的 this 比較特別

前面提到的一般函式,this 通常會根據呼叫方式決定。

但是 Arrow Function 不一樣。

JavaScript
const showThis = () => {
    console.log(this);
};

Arrow Function 沒有自己的 this

它會沿用外層環境的 this

例如:

JavaScript
const user = {
    name: "Dennis",

    showNameLater() {
        setTimeout(() => {
            console.log(this.name);
        }, 1000);
    }
};

user.showNameLater(); // Dennis

先呼叫:

JavaScript
user.showNameLater();

所以 showNameLater() 裡的一般方法 this 指向 user

裡面的 Arrow Function 沒有自己的 this,因此沿用 showNameLater()this

最後就可以取得:

JavaScript
user.name

並輸出:

text
Dennis

Tips:Arrow Function 是什麼?

Arrow Function 就是使用箭頭語法撰寫的函式:

JavaScript
const add = (a, b) => {
    return a + b;
};

也可以簡寫成:

JavaScript
const add = (a, b) => a + b;

它不只是一般函式的短寫版本。

Arrow Function 在 thisargumentsnew 等行為上,都和一般函式不同。

下一篇會再專門比較。

---

為什麼不建議使用 Arrow Function 當物件方法?

來看一個容易踩雷的寫法:

JavaScript
const user = {
    name: "Dennis",

    showName: () => {
        console.log(this.name);
    }
};

user.showName();

很多人會以為:

JavaScript
user.showName();

所以 this 應該指向 user

但 Arrow Function 沒有自己的 this

它不會因為被寫進 user,就自動讓 this 指向 user

它會沿用建立位置外層的 this

因此這裡通常無法取得預期的:

text
Dennis

物件方法若需要使用該物件作為 this,通常應該使用一般方法:

JavaScript
const user = {
    name: "Dennis",

    showName() {
        console.log(this.name);
    }
};

user.showName(); // Dennis

---

this 的常見判斷順序

看到函式裡的 this 時,可以按照下面的順序判斷。

第一個:是不是 Arrow Function?

Arrow Function 沒有自己的 this

它不會使用一般函式的呼叫規則決定 this,而是直接沿用外層環境原本的 this

如果不是 Arrow Function,再繼續判斷一般函式的呼叫方式。

---

第二個:有沒有使用 new

JavaScript
new User("Dennis");

有使用 newthis 會指向新建立的物件。

---

第三個:是不是由 bind() 建立的新函式?

JavaScript
const boundShowName = showName.bind(user);

boundShowName();

bind() 會事先將新函式的 this 綁定到指定的物件。

因此之後呼叫 boundShowName() 時,this 仍然會使用 user

---

第四個:有沒有使用 call()apply()

JavaScript
showName.call(user);

如果有手動指定,就先看指定的物件。

---

第五個:是不是物件方法呼叫?

JavaScript
user.showName();

如果是,通常看點號前面的物件。

---

第六個:是不是直接呼叫?

JavaScript
showName();

如果只是直接呼叫:

  • 嚴格模式下通常是 undefined
  • 傳統瀏覽器 Script 的非嚴格模式下,可能是全域物件

---

常見誤解

誤解一:this 永遠指向函式所在的物件

不一定。

JavaScript
const show = user.showName;

show();

函式被取出來直接呼叫後,呼叫方式已經改變,this 也可能改變。

---

誤解二:this 和作用域鏈一樣,會往外尋找

一般函式的 this 不是普通變數,不會沿著 Scope Chain 一層一層尋找。

一般函式通常根據呼叫方式決定 this

只有 Arrow Function 會沿用外層的 this

---

誤解三:Arrow Function 的 this 一定指向外層物件

Arrow Function 沿用的是外層作用域中的 this,不一定是外層程式碼看起來所在的物件。

例如:

JavaScript
const user = {
    name: "Dennis",

    showName: () => {
        console.log(this.name);
    }
};

Arrow Function 雖然寫在 user 物件中,但物件本身不會建立一個新的 this 作用域。

所以它不會自動取得 user

---

誤解四:bind() 會直接執行函式

不會。

JavaScript
const newFunction = showName.bind(user);

這一行只會產生一個新函式。

真正執行要再呼叫:

JavaScript
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 而已;它和一般函式在 thisarguments,以及能否搭配 new 作為建構函式等行為上,都有明顯差異。