0%

[week 16] JavaScript 進階 - What is this?

本篇為 [JS201] 進階 JavaScript:那些你一直搞不懂的地方 這門課程的學習筆記。如有錯誤歡迎指正!

學習目標:

 P1 你知道大部分情況下 this 的值是什麼
 P2 你知道物件導向的基本概念(類別、實體、繼承、封裝)

從物件導向到 this

在上一篇JavaScript 進階 - 物件導向 & Prototype 筆記中,其實我們就有提到 this 相關概念。this 的主要用途就在於物件導向,用來指哪個 instance 在呼叫這個 function,透過 this 我們能夠進一步操作。

那如果是在和物件導向無關的情況下,呼叫 this 會代表什麼?

Global context 下的 this

如果在全域環境下呼叫 this,根據不同執行環境(瀏覽器或 node.js),預設值可能會是 global 或 window:

在 node.js 運行

function test() {
  console.log(this);            // Object [global] { ...}
  console.log(this === global)  // true
}
test();

在瀏覽器運行

function test() {
  console.log(this);            // Window { ...}
  console.log(this === window)  // true
}
test();

use strict 嚴格模式

在不需要的地方呼叫 this,卻還是會有預設值 global 或 window。這時候,只要設定 'use strict'(嚴格模式)就能避免,this 的預設值會是 undefined:

'use strict';
function test() {
  console.log(this);
}
test();  // undefined

DOM 元素中的 this

此外,對 DOM 元素進行事件監聽時,this 就代表當下操作的元素,其實非常直覺。例如監聽按鈕的點擊事件,那 this 就會是那個按鈕:

document.querySelector('.btn').addEventListener('click', function() {
  console.log(this)   // this: 觸發這個 funciont 的物件
})

因此我們可以說,除了在物件導向跟 DOM 之外,this 是沒有意義的。

Function context 下的 this

在 function 裡面時,this 的值是什麼取決於該 function 是怎麼被呼叫的。

也就是說,function 沒有被呼叫的時候,預設值就會是 window, global 或 undefined。一旦某個 function 被呼叫時,裡面的 this 就可以理解成,是指向誰在 call 這個 function:

var obj = {
  foo: function(){
    console.log(this)
  }
}
var bar = obj.foo

obj.foo() // foo(): obj 在 call,obj 就是 this 
bar()   // bar(): 沒人 call,window 就是 this

其中,bar() 這種呼叫函式的方法,其實是 .call() 的語法糖,要更詳細了解呼叫 function 背後在做的事,就要深入理解 .call().apply()bind() 的行為。

另外兩種呼叫 function 的方法:call 與 apply

.call()

第一個值傳入什麼 this 就是什麼。

'use strict';
function test() {
  console.log(this);
}
test.call(123);     // 123
test.call({});      // {}
test.call([1]);     // [ 1 ]
test.call('abc');   // 'abc'

.apply()

.call() 一樣,第一個值傳入什麼 this 就是什麼,差異在於第二個參數會接一個 array。

可由下方範例來比較三種方式呼叫 function 的差異:

'use strict';  // 嚴格模式
function test(a, b, c) {
  console.log(this);
  console.log(a, b, c);
}

test(1, 2, 3)
// undefined
// 1 2 3
test.call(123, 1, 2, 3);
// 123
// 1 2 3
test.apply(123, [1, 2, 3]);
// 123
// 1 2 3

透過 .call.apply 的第一個參數,我們可以設定 function 中 this 的值。兩者差別在於,一個可以傳入無限的參數,另外一個只能傳入兩個參數;一個要用 array 帶入參數,另一個則否。

此外,我們也可以把一般呼叫 function 的方式都轉成 call 的形式:

const obj = {
  value: 1,
  hello: function() {
    console.log(this.value)
  }
}

obj.hello()  // 1
obj.hello.call(obj)  // 1

再舉一個例子,透過 call 來呼叫 function,我們就能夠指定 this 的值:

function hello() {
  console.log(this)
}

var a = {
  value: 1, hello
}

var b = {
  value: 2, hello
}

hello()  // global
a.hello()  // a { value: 1, hello}
b.hello.call(a)   // a { value: 1, hello}

如何判斷 this 的值

因為 this 是針對物件導向設計的,從以下範例,可知道 this 就是 obj 物件本身:

'use strict';
var obj = {
  a: 10,
  test: function(a, b, c) {
    console.log(this);  // obj
  }
}
obj.test();
// { a: 10, test: [Function: test] } === obj

重點就是,this 的值和程式碼在哪無關,而是和怎麼呼叫有關係:

'use strict';
var obj = {
  a: 10,
  test: function (a, b, c) {
    console.log(this);    // obj
  }
}
obj.test();  // { a: 10, test: [Function: test] }
var func = obj.test;  // 使 func 等同於 obj.test()
func()       // undefined

結果卻會得到不同結果,這是因為 obj 呼叫 function,所以 this 會指向 obj;另一方面,this 所在的 instance 中,沒有被 func 呼叫,所以會得到 undefined。

我們可以透過 .call() 來看看 this 指的是什麼:

'use strict';
var obj = {
  a: 10,
  inner: {
    test: function(a, b, c) {
      console.log(this);
    }
  }
}
obj.inner.test();
// { test: [Function: test] }

// 改用 .call() 呼叫
obj.inner.test.call(obj);
// { a: 10, inner: { test: [Function: test] } }
obj.inner.test.call(obj.inner);
// { test: [Function: test] }

.bind():強制綁定 this

除了 .call().apply(),我們還可以利用 .bind() 來強制綁定 this 的值,但需注意的是,一旦綁定之後就不能再用別的方法進行更改。

將前面提到的例子修改成 .bind(),這裡需注意 .bind() 會是直接回傳一個 function:

'use strict';
var obj = {
  a: 10,
  test: function () {
    console.log(this);
  }
}

// 第一種寫法:直接呼叫
obj.test.bind(obj)();
// { a: 10, test: [Function: test] }

// 第二種寫法:先宣告,再呼叫
const bindTest = obj.test.bind(obj);
bindTest();  
// { a: 10, test: [Function: test] }
const bindTestA = obj.test.bind(obj.test);
bindTestA();
// [Function: test]
const bindTestB = obj.test.bind("test B");
bindTestB();
// test B
const bindTestC = obj.test.bind("test C");
bindTestC.call('CCC');
// test C

利用 .bind() 就可以綁定 this 的值,也就是鎖定想要呼叫的區域,即便後面再 .call() 指定 this 的值也無法改變。

牛刀小試

以下範例預設在嚴格模式進行:

'use strict';

function log() {
  console.log(this);
}
var a = { a: 1, log: log };
var b = { a: 2, log: log };

log();     
a.log();    
b.log.apply(a);

思考步驟:

1. log();
// undefined
// 在嚴格模式下,如果在非物件導向的情況呼叫 this,其值會是 undeined 

2. a.log();  
// { a: 1, log: [Function: log] }
// 等於 a.log.call(a),所以 this 就是 a 本身

3. b.log.apply(a);
// { a: 1, log: [Function: log] }
// .apply() 會把 this 的值蓋過,所以 this 也會是 a 本身

arrow function 的 this

arrow function 本身其實沒有 this,在 arrow function 裡面的 this,其實就和 arrow function 外部的 this 是同一個東西。

先以一般宣告 function 為例:

class Test {
  run() {
    console.log('run this', this);
    setTimeout(function() {
      console.log(this);
    }, 100) 
  }
}
const t = new Test();
t.run();

在瀏覽器上的運行結果,會發現第二個 this 等於全域的 Window:

也就是說,在 100 毫秒後才呼叫,其實作用就等同於在全域呼叫這個 function:

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

但如果改成箭頭函式,結果就會不同了:

class Test {
  run() {
    console.log('run this', this);
    setTimeout( () => {
      console.log(this);
    }, 100) 
  }
}
const t= new Test();
t.run();

在瀏覽器上的運行結果,此時兩個 this 會變相同:

這就是箭頭函式的特性,和箭頭函式中的 this 怎麼呼叫沒關係。而是類似 Scope(作用域)的機制,看程式碼定義在哪裡,就會利用定義的 block 來呈現這個 this 的值。


結語

瞭解到物件導向的相關概念後,接著要理解 this 是什麼就沒那麼困難了。或許是因為在實際學 JacaScript 以前,就預設 this 是很難是高手在用的東西,透過慢慢理解物件導向與 this 的關聯,以及如何判斷 this 的值,似乎也感覺到自己的進化,對於未知的恐懼總是需要克服的。

關於 this 的重點,就是記得 this 的值和程式碼在哪無關,而是和怎麼呼叫有關係。

總結前面提到的觀念,其實 this 大致可分成四種綁定方式:

  • 默認綁定

在和物件導向無關的情況下,this 會被指定為全域物件。又依照執行環境不同,其值會是 global 或 window,而在嚴格模式下會是 undefined:

function test() {
  console.log(this);     // Window 
}
test();
  • 隱式綁定

若在 function 中, this 有被某物件指定為屬性並呼叫,this 就是呼叫 function 的物件。以下方範例來說 this 就是 obj:

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

var obj = {
  a: 4,
  test: func
};

obj.test();  // 4
  • 顯示綁定

若是透過 .call().apply().bind() 方式指定 this,this 就會是傳入的參數:

var obj = {
  a: 10,
  test: function () {
    console.log(this);
  }
}

obj.test.call(obj)
obj.test.apply(obj)

// 第一種寫法:直接呼叫 function
obj.test.bind(obj)();
// 第二種寫法:先宣告,再呼叫
const bindTest = obj.test.bind(obj);
bindTest();  

// 均印出: { a: 10, test: [Function: test] }
  • new 綁定

透過建構函式 new 出一個 instance,this 就會是 instance 物件本身:

class Dog {
  constructor(name) {
    this.name = name;
    console.log(this);       // Dog {name: "dog A"}
    console.log(this.name);  // dog A
  }
}

var a = new Dog('dog A');
  • 例外:箭頭函式中的 this 是看程式碼定義在哪,和怎麼呼叫沒關係。

參考資料:

  • 淺談 JavaScript 頭號難題 this:絕對不完整,但保證好懂
  • 前端中階:JS令人搞不懂的地方-this
  • JavaScript: Object-oriented JavaScript, Prototype Chain & This
  • 關於this綁定的四種方式