JavaScript 的 this

JavaScript 中的 this 關鍵字是最令人困惑的機制之一,真正掌握 this 的用法才是算是真正跨過 JS 的門檻。

同步發表於HackMD:JavaScript 的 this

this 的誤解

this 有兩個因為過度解讀字面本身的意義,而造成的誤解。

以下兩者皆為錯誤解讀

  1. 自身(Itself)
  2. 其作用域(It’s Scope)

1. 自身

第一個常見的誤解是 this 參考到函式本身(the fuction itself)。

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

foo();  // ?

這個 this 的值會是什麼?

答案會是全域物件(瀏覽器下是 Window 物件、node.js 底下是 Global 物件)。

這證明了 this 並不會指向函式本身。

2. 其作用域

另一個常見的誤解是 this 以某種方式參考了函式自身作用域。

考慮一段錯誤示範的程式碼:

這段程式碼嘗試跨作用域,並使用 this 隱含地參考一個函式的語彙作用域。想利用 thisfoo()bar() 的語彙作用建立一個通道,讓 bar() 能夠取用在 foo() 內層作用域的變數 a

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

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

foo();   // ?

這個 this 的值會是什麼?

結果當然不可能會成功,會回傳 undefined

this 的指向

說了那麼多,那麼 this 到底是什麼?

this 與函式是在何處被宣告無關,而完全取決於該函式的被呼叫的方式。

1. 一般的函式呼叫

直接呼叫函式的情況下,this 會指向全域物件。

一個簡單的範例:

var name = 'GlobalName';

function foo() {
  let name = 'Chupai';
  console.log(this.name); 
}

foo();  // "GlobalName"

放到立即函式 IIFE ,直接在函式內直接在呼叫另一個函式:

var name = 'GlobalName';
(function() {
  function foo() {
    let name = 'Chupai';
    console.log(this.name);
  }
  foo(); // "GlobalName"
})();

結果是一樣的。

無論我們把函式宣告放在立即函式內與外,結果還是一樣。

var name = 'GlobalName';
function foo() {
  let name = 'Chupai';
  console.log(this.name);
}

(function() {
  foo(); // "GlobalName"
})();

閉包 Closure:

var name = 'GlobalName';
function foo() {
  let name = 'Chupai';
  return function() {
    console.log(this.name);
  };
}

let myFoo = foo();

myFoo(); // "GlobalName"

這樣結果依然相同,並不會因為獨立的作用域改變造成 this 的不同。

回呼函式 Callback function:

var name = 'GlobalName';

function foo() {
  let name = 'Chupai';
  
  function boo() {
    console.log(this.name);
  }
  
  boo();
}

foo();  // "GlobalName"

無論在哪一層, 一般的函式呼叫 this 都會指向全域物件。

為什麼 this 會指向全物域件,關於這部分會在下節的《嚴格模式》說明。

2. 物件的方法呼叫

this 會指向最後呼叫它的物件。

一個簡單的範例:

var name = 'GlobalName';
let obj = {
  name: 'Chupai',
  foo: function() {
    console.log(this.name); // "Chupai"
  },
};

obj.foo();

foo()obj 的方法,因此 foo() 內的 this 會指向 obj 物件。

稍微改變一下程式碼:

var name = 'GlobalName';

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

let obj = {
  name: 'Chupai',
  foo: foo
};

foo();  // "GlobalName"
obj.foo();  // "Chupai"

函式宣告的位置不重要,重要的是呼叫的方法,foo() 內的 this 還是會指向 obj 物件。

繼續看下個範例:

var name = 'GlobalName';

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

let obj = {
  name: 'Chupai',
  boo: {
    name: 'Wang',
    foo: foo
  }
};

obj.boo.foo(); // "Wang"

this 會指向最後呼叫它的那個物件,最後呼叫的物件為 obj 物件內的 boo 物件,所以會指向它。

3. 間接參考

接下來看一個容易搞錯的範例,如果將物件內的函式,賦予在一個變數上,並呼叫它:

var name = 'GlobalName';

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

let obj = {
  name: 'Chupai',
  foo: foo,
};

var callThisName = obj.foo;

callThisName(); // "GlobalName"

obj.foo 賦值給變數 callThisName 時,foo 並沒有被呼叫,也就是函式只是另一個函式的參考。

因此 callThisName() 就只是一般的函式呼叫。

而當作參數傳遞中的回呼函式,也屬於間接參考:

var name = 'GlobalName';

let obj = {
  name: 2,
  foo: foo,
};

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

function boo(fn) {
  fn();
}

boo(obj.foo); // "GlobalName"

參數傳遞只是一種隱含的指定,boo( obj.foo ) 的參數 fn 依舊是 obj.foo 的參考,結果如同上一段程式碼。

如果回呼函式給它的那個函式不是本身,而是內建的,結果一樣。

var name = 'GlobalName';

let obj = {
  name: 2,
  foo: foo,
};

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

setTimeout(obj.foo, 100); // "GlobalName"

4. DOM 物件呼叫

DOM 搭配事件監聽 addEventListener 時,此 this 所指向的則是該 DOM 物件。

let e = document.querySelector('body');

function changeDOM() {
  console.log(this);  // <body>...</body>
}

e.addEventListener('click', changeDOM);

改變 this 的指向

會改變 this 的指向的情況:

  • 使用 applycallbind 方法
  • new 建構一個物件實體
  • 使用 ES6 的箭頭函式

1.applycallbind 方法

1.1 applycall 方法

call()apply() 是能呼叫函式的方法,並且能指定 this 值:

let obj = {};

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

foo(); // "Window{}"
foo.call(obj); // Object{}
foo.apply(obj); // Object{}

兩者第一個參數都是 this 值,也就是要綁定的物件。

而兩者差異只在於後面的參數:

let obj = {};

function foo(a, b) {
  console.log(this, a, b);
}

foo.call(obj, 1, 2); // Object{} 1 2
foo.apply(obj, [1, 2]); // Object{} 1 2
  • call() 跟平常呼叫函式一樣
  • apply() 需要使用陣列將引數包起來

應用 call()apply() 解決前面所提到的函式間接參考問題:

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

let obj = {
  name: 2,
};

let boo = function() {
  foo.call(obj);
};

boo(); // 2
setTimeout(boo, 100); // 2
boo.call(window); // 2

建立 boo 函式 在內部手動呼叫 foo.call( obj ),藉此強制以 obj 作為 this 的綁定來呼叫 foo。不管如何呼叫 boo 函式都會以 obj 手動調用 foo

此模式被稱為硬綁定(hard binding) 指的是綁定既明確又不會意外變回預設的綁定。

1.2 bind()

因為硬綁定模式如此常用,ES5 新增了一個方法,將此模式包裝了起來,bind() 會回傳一個新的函式,當被呼叫時,將提供的值設為 this 值。

這範例與上段程式碼一模一樣:

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

let obj = {
  name: 2,
};

let boo = foo.bind(obj);

boo(); // 2
setTimeout(boo, 100); // 2
boo.call(window); // 2

後面的參數平常呼叫函式一樣:

let obj = {};

function foo(a, b) {
  console.log(this, a, b);
}

const myFoo = foo.bind(obj, 1, 2);

myFoo(); // Object{} 1 2

如果使用 name 屬性查看 bind() 所創建的函式,將會在函式的名稱前加上 "bound "

let obj = {};

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

const myFoo = foo.bind(obj);

console.log(myFoo.name);  // "bound foo"

2. new 建構一個物件實體

這裡必須注意,JavaScript 的 new 運算子看似與其他類別導向語言相同,但實際上跟類別導向的功能性並沒有關聯。對 JavaScript 的 new 來說,它並不會連接到類別上,也不會實體化一個類別。

當一個函式前面帶有 new 被呼叫時,會發生以下事情:

  • 會有一個無中生有的全新物件被建構出來
  • 新建構的物件會帶有 [[ prototype ]] 連結
  • 新建構的物件會被設為那個函式的呼叫的 this 繫結
  • 除非該函式回傳自己提供的替代物件,否則以這個 new 調用的函式呼叫會自動回傳這個新建構物件。
function Foo(a) {
  this.a = a;
}

let bar = new Foo(2);
console.log(bar.a); // 2

呼叫 foo() 時,前面加了 new,所以建構出一個新物件,foo() 沒有回傳值,所以建立,使新的物件 bar 被設為 foo() 呼叫的 this

此部分只要了解建構式的 this 是指向物件本身即可。

3. 箭頭函式

ES6 新增的箭頭函式,它本身並沒有 this,它會在定義時記住 this 值,也就在宣告它的地方的 this 是什麼,它的 this 就是什麼。

傳統函式的 this 依呼叫的方法而定,因此使用回呼函式時會遇到一個問題:

function foo() {
  setTimeout(function() {
    console.log(this.a);
  }, 100);
}

var obj = {
  a: 2,
};

foo.call(obj);  // undefined

在函式內的函式會指向全域物件。

在 ES6 前,解決辦法是利用一個變數儲存 this 的值(常見命名 _thisthatvmself)。

function foo() {
  var _this = this;
  setTimeout(function() {
    console.log(_this.a);
  }, 100);
}

var obj = {
  a: 2,
};

foo.call(obj); // 2

接下來看 ES6 新增的箭頭函式,它的 this 始終指向函式定義時的 this,而非執行時。

function foo() {
  setTimeout(() => {
    console.log(this.a);
  }, 100);
}

let obj = {
  a: 2,
};

foo.call(obj); // 2

3.1 不可使用的情況

箭頭函式中 this 是被綁定的,所以套用 applycallbind 的方法時是無法修改 this

var name = 'GlobalName';

let obj = {
  name: 'Chupai',
};

const foo = () => {
  console.log(this.name);
};

foo.call(obj); // "GlobalName"
foo.apply(obj); // "GlobalName"

let myFoo = foo.bind(obj);
myFoo(); // "GlobalName"

箭頭函式也不能用在建構式,會拋出錯誤。

const Foo = (a, b) => {
  this.a = a;
  this.b = b;
};

const myFoo = new Foo(1, 2); // Uncaught TypeError: Foo is not a constructor

用在監聽 DOM 上一樣會指向全域物件,因為 this 是指向所建立的物件上。

let e = document.querySelector('body');

let changeDOM = () => {
  console.log(this);  // Window{}
};

e.addEventListener('click', changeDOM);

嚴格模式

ES5 之後,新增了嚴格模式,在嚴格模式下,一般函式呼叫的 this 值都是 undefined

'use strict';
function foo() {
  console.log(this);
}

foo(); // undefined

undefined 與全域物件有什麼關係?

先來一段程式碼:

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

foo.call(undefined); // Window{}
foo.call(null); // Window{}

我們使用 call()this 值設為 undefined,結果卻回傳全域物件。

這是因為 JavaScript 的機制,當 this 值為 undefinednull 時,會將 this 值強制轉換為一個物件。

在嚴格模式下,刻意將 undefinednull 設為 this 值,會回傳正確的 this 值。

'use strict';
function foo() {
  console.log(this);
}

foo.call(undefined); // undefined
foo.call(null); // null

這就是為什麼一般函式呼叫會回傳全域物件的原因。

有的書會用「this 永遠指向最後呼叫它的那個物件」來解釋下面這段程式碼:

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

foo(); // Window{}
window.foo(); // Window{}

因為 foo() 等同 window.foo(),最後呼叫它的物件是全域物件,所以 this 指向全域物件。但這其實是不太正確的說法,this 值主要還是以函式的呼叫方式為主,foo()this 值會是 undefined,會得到全域物件是因為被強制給值了。

讓我們加上嚴格模式:

'use strict';
function foo() {
  console.log(this);
}

foo(); // undefined
window.foo(); // Window{}

window.foo() 的值還是指向全域物件,因為它是方法呼叫。

總結

1. 函式的四種呼叫方式

  • 作為函式 func(),函式的一般呼叫形式
  • 作為方法 obj.func(),將函式綁定到物件上,以提供物件導向的程式設計方式
  • 作為建構式 new Func(),用來創建新物件
  • 經由函式的 apply()call() 方法呼叫

2. 呼叫函式的方式會影響 this 的值

  • 直接作為函式來呼叫,通常 this 的值為全域物件,嚴格模式下為 undefined
  • 函式作為方法來呼叫,this 的值為被呼叫函式的所屬物件
  • 函式作為建構式來呼叫,this 的值為新建立的物件
  • 藉由 apply()call() 呼叫,this 的值由第一個參數決定

3. 箭頭函式

箭頭函式沒有自己的 this 的值,由建立時取得它。

4. bind()

所有函式都具備 bind() 來建立一個新函式,此函式會綁定傳入的引數,除此之外,綁定的函式運作如原始的函式。

5. 嚴格模式

this 值為 undefinednull 會被強制轉成全域物件,而嚴格模式下,將不會強制轉值。

TOP