JavaScript 的 this
JavaScript 中的 this 關鍵字是最令人困惑的機制之一,真正掌握 this 的用法才是算是真正跨過 JS 的門檻。
同步發表於HackMD:JavaScript 的 this
this
的誤解
this
有兩個因為過度解讀字面本身的意義,而造成的誤解。
以下兩者皆為錯誤解讀:
- 自身(Itself)
- 其作用域(It’s Scope)
1. 自身
第一個常見的誤解是 this
參考到函式本身(the fuction itself)。
function foo() {
console.log(this);
}
foo(); // ?
這個 this
的值會是什麼?
答案會是全域物件(瀏覽器下是 Window
物件、node.js 底下是 Global
物件)。
這證明了 this
並不會指向函式本身。
2. 其作用域
另一個常見的誤解是 this
以某種方式參考了函式自身作用域。
考慮一段錯誤示範的程式碼:
這段程式碼嘗試跨作用域,並使用 this
隱含地參考一個函式的語彙作用域。想利用 this
在 foo()
與 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
的指向的情況:
- 使用
apply
、call
、bind
方法 new
建構一個物件實體- 使用 ES6 的箭頭函式
1.apply
、call
、bind
方法
1.1 apply
、call
方法
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
的值(常見命名 _this
、that
、vm
、self
)。
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
是被綁定的,所以套用 apply
、call
、bind
的方法時是無法修改 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
值為 undefined
或 null
時,會將 this
值強制轉換為一個物件。
在嚴格模式下,刻意將 undefined
或 null
設為 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
值為 undefined
或 null
會被強制轉成全域物件,而嚴格模式下,將不會強制轉值。