iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 20
12
Modern Web

重新認識 JavaScript系列 第 20

重新認識 JavaScript: Day 20 What's "THIS" in JavaScript (鐵人精華版)

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


連續跟函式交手了這麼多天,今天終於要來與各位介紹到 JavaScript 最容易被誤解排行榜第一名的 this了。 (是說哪來排行榜)

在鐵人開賽之前,我曾經針對 this 寫下三篇介紹文,相信也許有些朋友也看過了:

本來想說靠這三篇可以度過愉快的三天鐵人,不過想想要是直接原封不動就貼上鐵人賽,這跟作弊沒什麼兩樣。 XD

但因為 this 的觀念在 JavaScript 實在太重要,也不可能完全不提到,所以我打算利用今天這一篇,將上面三篇文章做個濃縮精華版,再次與各位介紹 this 是什麼東西。

https://ithelp.ithome.com.tw/upload/images/20171223/20065504PZUmEqAHRz.jpg
就連美國隊長都從第一集 do this do 到第三集,誠心希望他看完這篇可以順利搞懂 this。 (超大誤)


What's this?

也許你在其他物件導向的程式語言曾經看過 this,也知道它會指向某個建構子 (constructor) 所建立的物件。 但事實上在 JavaScript 裡面, this 所代表的不僅僅是那個被建立的物件。

先來看看 ECMAScript 標準規範 對 this 的定義:

「The this keyword evaluates to the value of the ThisBinding of the current execution context.」

「this 這個關鍵字代表的值為目前執行環境的 ThisBinding。」

然後來看看 MDN 對 this 的定義:

「In most cases, the value of this is determined by how a function is called.」

「在大多數的情況下,this 會因為 function 被呼叫的方式而有所不同。」

好,如果上面兩行就看得懂的話那麼就不用再往下看了,恭喜你。

...... 我想應該不會,至少我光看這兩行還是不懂。

所以,this 到底是什麼?

  • this 是 JavaScript 的一個關鍵字。
  • this 是 function 執行時,自動生成的一個內部物件。
  • 隨著 function 執行場合的不同,this 所指向的值,也會有所不同。
  • 在大多數的情況下, this 代表的就是呼叫 function 的物件 (Owner Object of the function)。

好,先來個例子吧,從大家最熟悉的物件講起:

var getGender = function(){
  return people1.gender;
};

var people1 = {
  gender: 'female',
  getGender: getGender
};

var people2 = {
  gender: 'male',
  getGender: getGender
};

console.log( people1.getGender() );
console.log( people2.getGender() );

來,猜猜 console 後的結果是什麼?

沒錯,因為 getGender() 回傳寫死 people1.gender 的關係,結果當然是 'female'。

那麼,如果我們把 getGender 改一下:

var getGender = function(){
  return this.gender;
};

這個時候,你應該會分別得到 femalemale 兩種結果。

所以回到前面講的重點,從這個例子可以看出,即便 people1people2getGender method 參照的都是同一個 getGender function,但由於呼叫的物件不同,所以執行的結果也會不同

現在我們知道了第一個重點, this 會因執行的環境與上下文 (context) 的不同,而有不同的結果。


this 不等於 function

上面講過, this 代表的是 function 執行時所屬的物件。 而在 JavaScript 這個語言內,除了基本型別以外的一切都是「物件」。

那麼當 function 本身就是物件時,又如何呢?

var foo = function() {
  this.count++;
};

foo.count = 0;

for( var i = 0; i < 5; i++ ) {
  foo();
}

猜猜看,當這段程式碼執行後, foo.count 會是多少?

答案是 0 。 我知道你可能不能接受,來聽我解釋。

前面講過, this 代表的是 「function 執行時所屬的物件」對吧?

在上面範例中, foo 是 function,同時也是「全域變數」。 相信已經看到 DAY 20 這篇的你,一定很清楚「全域變數」的定義吧!

複習一下,「全域變數」代表的是「全域物件的屬性」。 所以說, foo 其實就是 window.foo

所以說,當 foo()for 迴圈裡面跑得很開心的時候, this.count++ 始終都是對 windoo.count 在做遞增的處理,因為這個時候的 this 實際上就是 window

windoo.count 理論上一開始會是 undefined ,在做了五次的 ++ 之後,你會得到一個 NaN 的結果,而 foo.count 依然是個 0

記住, this 代表的是 function 執行時所屬的物件,而不是 function 本身

再來一個範例:

var bar = function() {
  console.log( this.a );
};

var foo = function() {
  var a = 123;
  this.bar();
};

foo();

相信經過前一個例題後,聰明的你應該知道 foo() 的執行結果應該是 undefined 了!

在這個範例中, foo() 可以透過 this.bar 取得 bar() ,是因為 this.bar 實際上是指向 window.bar
bar()this.a 並非是 foo 中的 123,而是指向 window.a,所以會得到 undefined 的結果。


巢狀迴圈中的 this

繼續來講一下很多人容易踩中的誤區,看範例:

var obj = {

  func1: function(){
    console.log( this === obj );

    var func2 = function(){
      // 這裡的 this 跟上層不同!
      console.log( this === obj );
    };

    func2();
  }
};

obj.func1();

在這個範例當中,會有兩次的 console

obj.func1() 裡面的 console.log( this === obj ); 會印出 true,原因是因為 func1 是透過 obj 來呼叫的。

obj.func1() 裡面的 func2() 在執行時的 console.log( this === obj ); 卻會印出 false

這裡必須說明兩個重點:

  • JavaScript 中,用來切分變數的最小作用範圍 (scope),也就是我們說的有效範圍的單位,就是 function
  • 當沒有特定指明 this 的情況下,預設綁定 (Default Binding) this 為 「全域物件」,也就是 window

換言之,在 func2 裡頭的 this,若是沒有特別透過 call()apply() 或是 bind() 來指定 this 的話,那麼這裡的 this 就是 window

但要注意的是,在 ES5 的嚴格模式下,會禁止 this 自動指定為全域物件,像這樣:

var obj = {

  func1: function(){
    "use strict";
    console.log( this === obj );

    var func2 = function(){
      // 宣告成嚴格模式後,這裡的 this 會變成 undefined。
      console.log( this );
    };

    func2();
  }
};

obj.func1();

That or This ?

假設我們今天在某個元素上透過 addEventListener 註冊了 click 事件,那麼根據先前在 重新認識 JavaScript: Day 15 隱藏在 "事件" 之中的秘密 曾經介紹過的,在事件中的 this 指的是「觸發事件的元素」。

然而,要是我們在事件的 callback function 加入 ajax 的請求,那麼根據前面所說的,預設綁定 (Default Binding) 會把這個 callback function 的 this 指定給 global object,也就是 window

有個很簡單的方式可以解決這個問題,那就是透過另一個變數來對目前的 this 做參考:

el.addEventListener("click", function(event) {

  // 透過 that 參考
  var that = this;
  console.log( this.textContent );

  $ajax('[URL]', function(res) {
    // this.textContent => undefined
    console.log(that.textContent, res);
  });

}, false);

像這樣,我們將事件內的 this 先用一個叫 that 的變數儲存它的參考,那麼在 ajax 的 callback function 就可以透過 that 來存取到原本事件中的 this 了。


強制指定 this 的方式

透過另一個變數來暫存 this 的方式雖然方便,那麼有沒有其他方式可以取得原本 this 的內容呢? 在 JavaScript 有三個可以強制指定 this 的方式,分別是 call()apply() 以及 bind()

.bind()

延續上個範例,我們先看 bind()。 在前面範例中,我們用 that 這個變數來替代 this,以便取得觸發 click 事件的元素。

如果用 bind() 改寫的話:

el.addEventListener("click", function(event) {
  console.log( this.textContent );

  // 透過 .bind(this) 來強制指定該 scope 的 this
  $ajax('[URL]', function(res) {
    console.log(this.textContent, res);
  }.bind(this));

}, false);

像上面這樣,在 function 後面加上 .bind(this) 就可以強制將 ( ) 內的物件帶入至 callback function 內。 於是 callback function 裡的 this 就會強制被指定成先前在 bind( ) 裡面的內容了。

var obj = {
  x: 123
};

var func = function () {
  console.log(this.x);
};

func();            // undefined
func.bind(obj)();  // 123

加上了 bind 之後的 func.bind(obj)() 執行的結果,會替我們將 functhis 暫時指向我們所設定的 obj
於是,console.log(this.x) 的結果自然就是 obj.x 也就是 123 了。

這裡你可以想像成某個 function 在執行的時候,「暫時」把它掛在某個物件下,以便透過 this 去取得該物件的 Context。

實務上除了 ajax 的 callback function 以外,另外像是 setTimeoutsetInterval 這類的 function,也是常見需要特別處理 this 的場景。


箭頭函式與 this

值得一提的是,從 ES6 開始新增了一種叫做 「箭頭函式表示式」 (Arrow Function expression) 的函式表達式。

而箭頭函式有兩個重要的特性:

  • 更簡短的函式寫法
  • this 變數強制綁定

像這樣,我們可以直接在 ajax 的 callback function 中取用 this.textContent

el.addEventListener("click", function(event) {
  console.log( this.textContent );

  // 箭頭函式隱含「強制指定 this」 至 callback function 中
  $ajax('[URL]', res => {
    console.log(this.textContent, res);
  });

}, false);

但要注意的是,無論是使用 'use strict' 或是再加上 .bind(xxx) 都無法改變 this 的內容,也不能作為物件建構子 (constructor)來使用。 箭頭函式方便歸方便,若是你的 function 內會有需要用到 this 的情況時,就需要特別小心你的 this 是不是在不知不覺中換了人來當。


.call().apply()

既然講到了強制指定 this 的方式,看完了 bind() 與「箭頭函式」,接下來就不能不講到 call()apply()

假設今天有個 function 長這樣:

function func( ){
  // do something
}

那麼我們可以透過 func() 來呼叫它。

當然你也可以用 .call() 或是 .apply() 來呼叫它:

func.call( );
func.apply( );

你可能會覺得奇怪,看起來沒什麼不同對吧,還要多打幾個字豈不是自找麻煩。 但如果遇上了需要帶參數的時候,就又顯得有些不同。

基本上 .call() 或是 .apply() 都是去呼叫執行這個 function ,並將這個 function 的 context 替換成第一個參數帶入的物件。 換句話說,就是強制指定某個物件作為該 function 執行時的 this

.call().apply() 的作用完全一樣,差別只在傳入參數的方式有所不同:

function func( arg1, arg2, ... ){
  // do something
}

func.call( context, arg1, arg2, ... );
func.apply( context, [ arg1, arg2, ... ]);

.call() 傳入參數的方式是由「逗點」隔開,而 .apply() 則是傳入整個陣列作為參數,除此之外沒有明顯的差別。


bind, call, apply 的差異

bind() 讓這個 function 在呼叫前先綁定某個物件,使它不管怎麼被呼叫都能有固定的 this
尤其常用在像是 callback function 這種類型的場景,可以想像成是先綁定好 this,然後讓 function 在需要時才被呼叫的類型。

.call().apply() 則是使用在 context 較常變動的場景,依照呼叫時的需要帶入不同的物件作為該 function 的 this。 在呼叫的當下就立即執行。


this 與前後文本 (context) 綁定的基本原則

this 綁定的基本原則大致上可以分成下列四種:

  • 預設綁定 (Default Binding)
  • 隱含式綁定 (Implicit Binding)
  • 顯式綁定 (Explicit Binding)
  • 「new」關鍵字綁定

第一種 預設綁定 (Default Binding) 我們前面已經介紹過了:

宣告在全域範疇 (global scope) 的變數,與同名的全域物件 (window 或 global) 的屬性是一樣的意思。
因為預設綁定的關係,當 function 是在普通、未經修飾的情況下被呼叫,也就是當 function 被呼叫的當下如果沒有值或是在 func.call(null)func.call(undefined) 此類的情況下,此時裡面的 this自動指定至全域物件

但若是加上 "use strict" 宣告成嚴格模式後,原本預設將 this 綁定至全域物件的行爲,會轉變成 undefined


隱含式綁定 (Implicit Binding) 指的是,即使 function 被宣告的地方是在 global scope 中,只要它成為某個物件的參考屬性 (reference property),在那個 function 被呼叫的當下,該 function 即被那個物件所包含。

function func() {
  console.log( this.a );
}

var obj = {
  a: 2,
  foo: func
};

func();       // undefined
obj.foo();    // 2

在上面的範例中可以看到,根據 「預設綁定」的原則,直接呼叫 func() 的情況下,此時的 this.a 實際上會指向 window.a,所以結果是 undefined

而當我們在 obj 物件中,將 foo 這個屬性指到 func() 的時候,再透過 obj 來呼叫 obj.foo() 的時候,雖然實際上仍是 func() 被呼叫, 但此時的 this 就會指向至 obj 這個 owner 的物件上,於是此時的 this.a 就會是 obj.a 也就是 2

理解了隱含式綁定的原則後,繼續來看看這個變化過的版本:

function func() {
  console.log( this.a );
}

var obj = {
  a: 2,
  foo: func
};

obj.foo();  // 2

var func2 = obj.foo;
func2();    // ??

在這個版本中,我們宣告另一個變數 func2 指向 obj.foo,那麼聰明的你是否可以猜到呼叫 func2() 的結果為何呢?

答案是 undefined

雖然 func2 看起來是對 obj.foo 的參考,但實際上 func2 參考的對象是 window.func

跟我們上回介紹的「範圍鏈」(Scope Chain) 不同的是,決定 this 的關鍵不在於它屬於哪個物件,而是在於 function「呼叫的時機點」,當你透過物件呼叫某個方法 (method) 的時候,此時 this 就是那個物件 (owner object)。


然後是顯式綁定 (Explicit Binding)。 相較於前兩種,顯式綁定就單純許多,簡單來說就是透過 .bind() / .call() / .apply() 這類直接指定 this 的 function 都可被歸類至顯式綁定的類型。

前面已介紹過不少範例,這裡就不再贅述。


最後一個是**「new」關鍵字**綁定。

在傳統類別導向 (class-oriented) 的程式語言中,建構子 (constructors) 是被附接到類別上的特殊方法,在透過 newclass 實體化的時候,這個建構子方法就會被呼叫。 而 JavaScript 雖然也有 new 這個關鍵字,運作時也與類別導向的語言行為類似,但由於 JavaScript 並不是一個類別導向的程式語言 (而是基於原型的物件導向) ,所以它的 new 運作原理並不相同。

當一個 function 前面帶有 new 被呼叫時,會發生:

  • 會產生一個新的物件 (物件被建構出來)
  • 這個新建構的物件會被設為那個 function 的 this 綁定目標,也就是 this 會指向新建構的物件。
  • 除非這個 function 指定回傳 (return) 了他自己的替代物件,否則這個透過 new 產生的物件會被自動回傳。
function foo(a) {
  this.a = a;
}

var obj = new foo( 123 );
console.log( obj.a );      // 123

在上面的範例中,因為呼叫 foo 時,加了一個 new ,所以建構了一個新物件,並回傳到 obj
透過傳入的參數 123,在建立物件的時候,會作為新物件的屬性 a 的值,這種用 new 建立 this 綁定的方式,就是 new 關鍵字綁定的方式。


結論 What's "this" in JavaScript?

綜合上述範例介紹,我們可以簡單總結出一個結論:

  • 這個 function 的呼叫,是透過 new 進行的嗎? 如果是,那 this 就是被建構出來的物件。
  • 這個 function 是以 .call().apply() 的方式呼叫的嗎? 或是 function 透過 .bind() 指定? 如果是,那 this 就是被指定的物件。
  • 這個 function 被呼叫時,是否存在於某個物件? 如果是,那 this 就是那個物件。
  • 如果沒有滿足以上條件,則此 function 裡的 this 就一定是全域物件,在嚴格模式下則是 undefined

那麼以上就是關於 this 的介紹。 相信在看完這篇之後,各位對於 this 在 JavaScript 這個程式語言所扮演的角色,應該會有更清楚、更深入的理解。


上一篇
重新認識 JavaScript: Day 19 閉包 Closure
下一篇
重新認識 JavaScript: Day 21 函式的 Combo 技: Cascade
系列文
重新認識 JavaScript37
0
a2741890
iT邦新手 5 級 ‧ 2019-01-08 16:55:34

大大你好,關於一開始的

var bar = function(){
console.log(this.a);
};

var foo = function(){
var a = 123;
this.bar();
};
foo();

這段程式碼我在VSC上無法執行1, system表示bar並不是一個function,但如果我不透過this呼叫bar而是:
bar();
就可以正常執行,而且結果和您一樣是undefined.
我在想是我編譯器的定義問題嗎?
或者是已經在foo這個function裡面,所以當開始執行的時候,this.bar() 的this就會變成foo的物件而不是window了呢?

還請大大解惑謝謝!

Kuro Hsu iT邦新手 4 級 ‧ 2019-01-08 17:28:00 檢舉

啊,抱歉,應該按回應,結果按到另一個留言了 orz
請看下面留言

1
Kuro Hsu
iT邦新手 4 級 ‧ 2019-01-08 17:26:19

我猜想你應該是透過 VSC 的 Code Runner 來執行程式吧?

如果是的話,那麼在 foo(){ ... } 裡頭的 this 實際上會指到 node 的環境物件「global」,這個環境物件基本上等同於瀏覽器的 「window」。

那為什麼 this.bar() 會出錯,而 bar() 卻可以正確顯示呢?

回答這個問題前,你可以試著打開瀏覽器的 devtool 的 console 介面,並且執行上面程式碼。 我想應該會出現 undefined 的結果。

https://ithelp.ithome.com.tw/upload/images/20190108/20065504v7c7sk65tm.png

為什麼同一段程式,在瀏覽器執行與在 node 執行的結果會不同呢?
這是因為 JavaScript 在不同環境下,執行宿主的環境物件不同的原因所致。

上面說過,在瀏覽器下的全域物件 (或稱環境物件) 叫 window
而這個 window 有個很神奇的特性,就是它會將所有的「全域變數」都變成這個物件的「屬性」。

假設我們在瀏覽器下宣告一個全域變數: var a = 123;
這個時候你可以試著執行: console.log(window.a); , 應該會得到 「123」 的結果。

但是在 node 環境下,你試著印出 global.a,應該只會看到 undefined

回到問題,

var foo = function(){
  var a = 123;
  this.bar();
};

foo();

當我們在瀏覽器執行 foo() 時,實際上是執行 window.foo()
這樣想是不是就可以理解裡面的 thiswindow 還是 foo 了呢?

a2741890 iT邦新手 5 級 ‧ 2019-01-09 10:35:04 檢舉

謝謝大大這下子觀念更清楚了!對的一開始都是用code runner來跑程式,後來有試了瀏覽器發現是行得通的,非常感謝你清楚又詳細的回覆
XD 我會繼續把30篇都看完
btw, 你的文章真的寫得很有趣,邏輯也很清楚,超讚
/images/emoticon/emoticon07.gif

1
mingyangshih
iT邦新手 5 級 ‧ 2019-06-25 14:56:58

Kuro大大您好:

    var name = '全域阿婆'
    var auntie = {
        name: '漂亮阿姨',
        callName: () => {
          console.log('1', this.name); // 1 全域阿婆
          setTimeout(() => {
            console.log('2', this.name); // 2 全域阿婆
          }, 10);
        },
     }
     auntie.callName();

想請教在callName屬性內使用arrow function並呼叫this.name為何取用到的this.name都是window object內的name而不是auntie object的name?原本以為會透過你文中所提arrow function會強制指定this至callbackfunction中,所以會跑出兩個漂亮阿姨?
麻煩了,感謝

mingyang

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 4 級 ‧ 2019-06-25 15:48:17 檢舉

你好,文章中我曾提及:決定 this 的關鍵不在於它屬於哪個物件,而是在於 function「呼叫的時機點」

以你提供的範例來說,

var name = '全域阿婆'

var auntie = {
    name: '漂亮阿姨',
    callName: () => {
      console.log('1', this.name); // 1 全域阿婆
      setTimeout(() => {
        console.log('2', this.name); // 2 全域阿婆
      }, 10);
    },
  }

auntie.callName();

這個 this.name 在哪裡被定義的並不重要,重要的是看呼叫的當下這個 this 是誰。

所以問題來了,當執行 auntie.callName(); 的時候,你覺得當下的 this 會是誰?

您好:
我自己認為第一個應該是漂亮阿姨(依照您所提的this所指向的不是function本身,而是function執行時所屬的物件),而在setTimeout中沒有特別指定的this也會指定給全域物件所以答案會是全域阿婆。
因此感到困惑,麻煩了

Kuro Hsu iT邦新手 4 級 ‧ 2019-06-25 16:23:24 檢舉

沒錯,你提到一個重點,「this所指向的不是function本身,而是function執行時所屬的物件」,這是在一般 function 的情況。
所以你可以試著改成這樣:

var name = '全域阿婆'

var auntie = {
    name: '漂亮阿姨',
    callName: function() {
      console.log('1', this.name); // ??
    },
  }

auntie.callName();

這個時候 this.name 會是誰?


而改用 arrow function 之後的

var auntie = {
    name: '漂亮阿姨',
    callName: () => {
      console.log('1', this.name); // ??
    },
  }

auntie.callName();

這個時候 this.name 又會是誰?

第一個的結果會是漂亮阿姨,因為呼叫物件方法時他所屬的物件是auntie,但第二個結果竟然是全域阿婆...,您有提到「this所指向的不是function本身,而是function執行時所屬的物件」是在一般的function情況下,所以arrow function的this都會指向全域嗎?

Kuro Hsu iT邦新手 4 級 ‧ 2019-06-25 16:39:15 檢舉

arrow function 的 this 要看在呼叫的當下那個「環境」的 this 是誰。

換個方式說,如果在 global 的環境下,你覺得這個 this 會是誰?

var auntie = {
    name: '漂亮阿姨',
    callName: () => {
      console.log('1', this.name); // ??
    },
  }

// 這個 this 是誰?
console.log(this);

// 透過 arrow function 取得的 this 就是誰
auntie.callName();

在Global環境下的話this就是指向window了,真的很感謝您的回答,不知道arrow function是否有如您文中所提到的.bind(),相關的方法,可以指定物件,感謝了。

Kuro Hsu iT邦新手 4 級 ‧ 2019-06-26 19:44:11 檢舉

arrow function 是無法使用 .bind() 來指定 this 的喔。

我要留言

立即登入留言