新手在入門 JavaScript 的過程中,一定會踩很多關于 this 的坑,出現問題的本質就是 this 指針的指向和自己想的不一樣。筆者在入門學習的過程中,也踩了很多坑,于是便寫下本篇文章記錄自己“踩坑”歷程。
一. this 在哪里?
在上篇《從 JavaScript 作用域說開去》分析中,我們知道,在 Execution Context 中有一個屬性是 this,這里的 this 就是我們所說的 this 。this 與上下文中可執行代碼的類型有直接關系,** this 的值在進入執行上下文時確定,并且在執行上下文運行期間永久不變。**
this 到底取何值?this 的取值是動態的,是在函數真正被調用執行的時候確定的,函數定義的時候確定不了。因為 this 的取值是執行上下文環境的一部分,每次調用函數,都會產生一個新的執行上下文環境。
所以 this 的作用就是用來指明執行上下文是在哪個上下文中被觸發的對象。令人迷惑的地方就在這里,同一個函數,當在不同的上下文進行調用的時候,this 的值就可能會不同。也就是說,this 的值就是函數調用表達式(也就是函數被調用的方式)的 caller。
二. this & that 具體值得是誰?
目前接觸的有以下14種情況,筆者打算一一列舉出來,以后如果遇到了更多的情況,還會繼續增加。
既然 this 是執行上下文確定的,那么從執行上下文的種類進行分類,可以分為3種:
那么接下來我們就從 Global Execution Context 全局執行上下文,Function Execution Context 函數執行上下文,Eval Execution Context Eval執行上下文 這三類,具體談談 this 究竟指的是誰。
(一). 全局執行上下文
1. 非嚴格模式下的函數調用
這是函數的最通常用法,屬于全局性調用,因此 this 就代表全局對象 Global。
var name = 'halfrost';
function test() {
console.log(this); // window
console.log(this.name); // halfrost
}
test();
在全局上下文(Global Context)中,this 總是 global object,在瀏覽器中就是 window 對象。
2. 嚴格模式下的函數調用
嚴格模式由 ECMAScript 5.1 引進,用來限制 JavaScript 的一些異常處理,提供更好的安全性和更強壯的錯誤檢查機制。使用嚴格模式,只需要將 'use strict' 置于函數體的頂部。這樣就可以將上下文環境中的 this 轉為 undefined。這樣執行上下文環境不再是全局對象,與非嚴格模式剛好相反。
在嚴格模式下,情況并不是僅僅是 undefined 這么簡單,有可能嚴格模式夾雜著非嚴格模式。
先看嚴格模式的情況:
'use strict';
function test() {
console.log(this); //undefined
};
test();
上面的這個情況比較好理解,還有一種情況也是嚴格模式下的:
function execute() {
'use strict'; // 開啟嚴格模式
function test() {
// 內部函數也是嚴格模式
console.log(this); // undefined
}
// 在嚴格模式下調用 test()
// this 在 test() 下是 undefined
test(); // undefined
}
execute();
如果嚴格模式在外層,那么在執行作用域內部聲明的函數,它會繼承嚴格模式。
接下來就看看嚴格模式和非嚴格模式混合的情況。
function nonStrict() {
// 非嚴格模式
console.log(this); // window
}
function strict() {
'use strict';
// 嚴格模式
console.log(this); // undefined
}
這種情況就比較簡單了,各個模式下分別判斷就可以了。
(二).函數執行上下文
3. 函數調用
當通過正常的方式調用一個函數的時候,this 的值就會被設置為 global object(瀏覽器中的 window 對象)。
嚴格模式和非嚴格模式的情況和上述全局執行上下文的情況一致,嚴格模式對應的 undefined ,非嚴格模式對應的 window 這里就不再贅述了。
4. 方法作為對象的屬性被調用
var person = {
name: "halfrost",
func: function () {
console.log(this + ":" + this.name);
}
};
person.func(); // halfrost
在這個例子里面的 this 調用的是函數的調用者 person,所以會輸出 person.name 。
當然如果函數的調用者是一個全局對象的話,那么這里的 this 指向又會發生變化。
var name = "YDZ";
var person = {
name: "halfrost",
func: function () {
console.log(this + ":" + this.name);
}
};
temp = person.func;
temp(); // YDZ
在上面這個例子里面,由于函數被賦值到了另一個變量中,并沒有作為 person 的一個屬性被調用,那么 this 的值就是 window。
上述現象其實可以描述為,“從一個類中提取方式時丟失了 this 對象”。針對這個現象可以再舉一個例子:
var counter = {
count: 0,
inc: function() {
this.count ++;
}
}
var func = counter.inc;
func();
counter.count; // 輸出0,會發現func函數根本不起作用
這里我們雖然把 counter.inc 函數提取出來了,但是函數里面的 this 變成了全局對象了,所以 func() 函數執行的結果是 window.count++。然而 window.count 根本不存在,且值是 undefined,對 undefined 操作,得到的結果只能是 NaN。
驗證一下,我們打印全局的 count:
count // 輸出是 NaN
那么這種情況我們應該如何解決呢?如果就是想提取出一個有用的方法給其他類使用呢?這個時候的正確做法是使用 bind 函數。
var func2 = counter.inc.bind(counter);
func2();
counter.count; // 輸出是1,函數生效了!
5. 構造函數的調用
所謂構造函數就是用來 new 對象的函數。嚴格的來說,所有的函數都可以 new 一個對象,但是有些函數的定義是為了 new 一個對象,而有些函數則不是。另外注意,構造函數的函數名第一個字母大寫(規則約定)。例如:Object、Array、Function等。
function person() {
this.name = "halfrost";
this.age = 18;
console.log(this);
}
var ydz = new person(); // person {name: "halfrost", age: 18}
console.log(ydz.name, ydz.age); // halfrost 18
如果是構造函數被調用的話,this 其實指向的是 new 出來的那個對象。
如果不是被當做構造函數調用的話,情況有所區別:
function person() {
this.name = "halfrost";
this.age = 18;
console.log(this);
}
person(); // Window {stop: function, open: function, alert: function, confirm: function, prompt: function…}
如果不是被當做構造函數調用的話,那就變成了普通函數調用的情況,那么這里的 this 就是 window。
構造函數里面如果還定義了 prototype,this 會指向什么呢?
function person() {
this.name = "halfrost";
this.age = 18;
console.log(this);
}
person.prototype.getName = function() {
console.log(this.name); // person {name: "halfrost", age: 18} "halfrost"
}
var ydz = new person(); // person {name: "halfrost", age: 18}
ydz.getName();
在 person.prototype.getName 函數中,this 指向的是 ydz 對象。因此可以通過 this.name 獲取 ydz.name 的值。
其實,不僅僅是構造函數的 prototype,即便是在整個原型鏈中,this 代表的也都是當前對象的值。
6. 內部函數 / 匿名函數 的調用
如果在一個對象的屬性是一個方法,這個方法里面又定義了內部函數和匿名函數,那么它們的 this 又是怎么樣的呢?
var context = "global";
var test = {
context: "inside",
method: function () {
console.log(this + ":" +this.context);
function f() {
var context = "function";
console.log(this + ":" +this.context);
};
f();
(function(){
var context = "function";
console.log(this + ":" +this.context);
})();
}
};
test.method();
// [object Object]:object
// [object Window]:global
// [object Window]:global
從輸出可以看出,內部函數和匿名函數里面的 this 都是指向外面的 window。
7. call() / apply() / bind() 的方式調用
this 本身是不可變的,但是 JavaScript 中提供了 call() / apply() / bind() 三個函數來在函數調用時設置 this 的值。
這三個函數的原型如下:
// Sets obj1 as the value of this inside fun() and calls fun() passing elements of argsArray as its arguments.
fun.apply(obj1 [, argsArray])
// Sets obj1 as the value of this inside fun() and calls fun() passing arg1, arg2, arg3, ... as its arguments.
fun.call(obj1 [, arg1 [, arg2 [,arg3 [, ...]]]])
// Returns the reference to the function fun with this inside fun() bound to obj1 and parameters of fun bound to the parameters specified arg1, arg2, arg3, ....
fun.bind(obj1 [, arg1 [, arg2 [,arg3 [, ...]]]])
在這3個函數里面,this 都是對應的第一個參數。
var rabbit = { name: 'White Rabbit' };
function concatName(string) {
console.log(this === rabbit); // => true
return string + this.name;
}
// 間接調用
concatName.call(rabbit, 'Hello '); // => 'Hello White Rabbit'
concatName.apply(rabbit, ['Bye ']); // => 'Bye White Rabbit'
apply() 和 call() 能夠強制改變函數執行時的當前對象,讓 this 指向其他對象。apply() 和 call() 的區別在于,apply() 的入參是一個數組,call() 的入參是一個參數列表。
apply() 和 call(),它倆都立即執行了函數,而 bind() 函數返回了一個新的函數,它允許創建預先設置好 this 的函數 ,并可以延后調用。
function multiply(number) {
'use strict';
return this * number;
}
// 創建綁定函數,綁定上下文2
var double = multiply.bind(2);
// 調用間接調用
double(3); // => 6
double(10); // => 20
bind() 函數實質其實是實現了,原始綁定函數共享相同的代碼和作用域,但是在執行時擁有不同的上下文環境。
bind() 函數創建了一個永恒的上下文鏈并不可修改。一個綁定函數即使使用 call() 或者 apply()傳入其他不同的上下文環境,也不會更改它之前連接的上下文環境,重新綁定也不會起任何作用。
只有在構造器調用時,綁定函數可以改變上下文,然而這并不是特別推薦的做法。
function getThis() {
'use strict';
return this;
}
var one = getThis.bind(1);
// 綁定函數調用
one(); // => 1
// 使用 .apply() 和 .call() 綁定函數
one.call(2); // => 1
one.apply(2); // => 1
// 重新綁定
one.bind(2)(); // => 1
// 利用構造器方式調用綁定函數
new one(); // => Object
只有 new one() 時可以改變綁定函數的上下文環境,其他類型的調用結果是 this 永遠指向 1。
8. setTimeout、setInterval 中的 this
《 javascript 高級程序設計》中寫到:“超時調用的代碼需要調用 window 對象的 setTimeout 方法”。setTimeout/setInterval 執行的時候,this 默認指向 window 對象,除非手動改變 this 的指向。
var name = 'halfrost';
function Person(){
this.name = 'YDZ';
this.sayName=function(){
console.log(this); // window
console.log(this.name); // halfrost
};
setTimeout(this.sayName, 10);
}
var person=new Person();
上面這個例子如果想改變 this 的指向,可是使用 apply/call 等,也可以使用 that 保存 this。
值得注意的是:setTimeout 中的回調函數在嚴格模式下也指向 window 而不是 undefined !
'use strict';
function test() {
console.log(this); //window
}
setTimeout(test, 0);
因為 setTimeout 的回調函數如果沒有指定的 this ,會做一個隱式的操作,將全局上下文注入進去,不管是在嚴格還是非嚴格模式下。
9. DOM event
當一個函數被當作event handler的時候,this會被設置為觸發事件的頁面元素(element)。
var body = document.getElementsByTagName("body")[0];
body.addEventListener("click", function(){
console.log(this);
});
// <body>…</body>
10. in-line 的方式調用
當代碼通過 in-line handler 執行的時候,this 同樣指向擁有該
handler 的頁面元素。
看下面的代碼:
document.write('<button onclick="console.log(this)">Show this</button>');
// <button onclick="console.log(this)">Show this</button>
document.write('<button onclick="(function(){console.log(this);})()">Show this</button>');
// window
在第一行代碼中,正如上面 in-line handler 所描述的,this 將指向 "button" 這個 element。但是,對于第二行代碼中的匿名函數,是一個上下文無關(context-less)的函數,所以 this 會被默認的設置為
window。
前面我們已經介紹過了 bind 函數,所以,通過下面的修改就能改變上面例子中第二行代碼的行為:
document.write('<button onclick="((function(){console.log(this);}).bind(this))()">Show this</button>');
// <button onclick="((function(){console.log(this);}).bind(this))()">Show this</button>
11. this & that
在 JavaScript 中,經常會存在嵌套函數,這是因為函數可以作為參數,并可以在合適的時候通過函數表達式創建。這會引發一些問題,如果一個方法包含一個普通函數,而你又想在后者的內部訪問到前者,方法中的 this 會被普通函數的 this 覆蓋,比如下面的例子:
var person = {
name: 'halfrost',
friends: [ 'AA', 'BB'],
loop: function() {
'use strict';
this.friends.forEach(
function(friend) { // (1)
console.log(this.name + ' knows ' + friend); // (2)
}
);
}
};
上述這個例子中,假設(1)處的函數想要在(2)這一行訪問到 loop 方法里面的 this,該怎么做呢?
如果直接去調用 loop 方法是不行的,會發現報了下面這個錯誤。
person.loop();
// Uncaught TypeError: Cannot read property 'name' of undefined
因為(1)處的函數擁有自己的 this,是沒有辦法在里面調用外面一層的 this 的。那怎么辦呢?
解決辦法有3種:
(1) that = this
我們可以把外層的 this 保存一份,一般會使用 that ,self,me,這些變量名暫存 this。
var person = {
name: 'halfrost',
friends: [ 'AA', 'BB'],
loop: function() {
'use strict';
var that = this;
this.friends.forEach(
function(friend) { // (1)
console.log(that.name + ' knows ' + friend); // (2)
}
);
}
};
person.loop();
// halfrost knows AA
// halfrost knows BB
這樣就可以正確的輸出想要的答案了。
(2) bind()
借助 bind() 函數,直接給回調函數的 this 綁定一個固定值,即函數的 this:
var person = {
name: 'halfrost',
friends: [ 'AA', 'BB'],
loop: function() {
'use strict';
var that = this;
this.friends.forEach(
function(friend) { // (1)
console.log(this.name + ' knows ' + friend); // (2)
}.bind(this)
);
}
};
person.loop();
// halfrost knows AA
// halfrost knows BB
(3) forEach() 的 thisValue
這種方法特定于 forEach()中,因為在這個方法的回調函數里面提供了第二個參數,我們可以利用這個參數,讓它來為我們提供 this:
var person = {
name: 'halfrost',
friends: [ 'AA', 'BB'],
loop: function() {
'use strict';
var that = this;
this.friends.forEach(
function(friend) { // (1)
console.log(this.name + ' knows ' + friend); // (2)
}, this );
}
};
person.loop();
// halfrost knows AA
// halfrost knows BB
12. 箭頭函數
箭頭函數是 ES6 增加的新用法。
var numbers = [1, 2];
(function() {
var get = () => {
console.log(this === numbers); // => true
return this;
};
console.log(this === numbers); // => true
get(); // => [1, 2]
// 箭頭函數使用 .apply() 和 .call()
get.call([0]); // => [1, 2]
get.apply([0]); // => [1, 2]
// Bind
get.bind([0])(); // => [1, 2]
}).call(numbers);
從上面的例子可以看出:
箭頭函數里面的 this 對象就是定義時候所在的對象,而不是使用時所在的對象。
-
箭頭函數不能被用來當做構造函數,于是也不能使用 new 命令。否則會報錯
TypeError: get is not a constructor
。this 指向的固化,并不是因為箭頭函數內部有綁定 this 的機制,實際原因是箭頭函數根本就沒有自己的 this ,導致內部的 this 就是外層代碼塊的 this。正因為它沒有 this,所以也就不能作為構造函數了。
箭頭函數也不能使用 arguments 對象,因為 arguments 對象在箭頭函數體內不存在,如果要使用,可以用 rest 參數代替。同樣的,super,new.target 在箭頭函數里面也是不存在的。所以,arguments、super、new.target 這3個變量在箭頭函數里面都不存在。
箭頭函數里面也不能使用 yield 命令,因此箭頭函數也不能用作 Generator 函數。
由于箭頭函數沒有自己的 this ,當然就不能用 call()、apply()、bind() 這些方法去改變 this 的指向。
13. 函數綁定
雖然在 ES6 中引入了箭頭函數可以綁定 this 對象,大大減少了顯示綁定 this 對象的寫法(call、apply、bind)。鑒于箭頭函數有上述說到的4個缺點(不能當做構造函數,不能使用 arguments 對象,不能使用 yield 命令,不能使用call、apply、bind),所以在 ES7 中又提出了函數綁定運算符。用來取代 call、apply、bind 的調用。
函數綁定運算符是并排的雙冒號(::),雙冒號左邊是一個對象,右邊是一個函數。該運算符會自動的將左邊的對象作為上下文環境(即
this 對象)綁定到右邊的函數上。
foo::bar // 等同于 bar.bind(foo)
foo::bar(...arguments) // 等同于 bar.apply(foo,arguments)
(三). Eval執行上下文
14. Eval 函數
Eval 函數比較特殊,this 指向就是當前作用域的對象。
var name = 'halfrost';
var person = {
name: 'YDZ',
getName: function(){
eval("console.log(this.name)");
}
}
person.getName(); // YDZ
var getName=person.getName;
getName(); // halfrost
這里的結果和方法作為對象的屬性被調用的結果是一樣的。
總結
如果要判斷一個運行中函數的 this 綁定,就需要找到這個函數的直接調用位置。找到之后
就可以順序應用下面這四條規則來判斷 this 的綁定對象。
- 函數是否在new中調用(new綁定)?如果是的話this綁定的是新創建的對象。
var bar = new foo() - 函數是否通過call、apply(顯式綁定)或者硬綁定調用?如果是的話,this綁定的是 指定的對象。
var bar = foo.call(obj2) - 函數是否在某個上下文對象中調用(隱式綁定)?如果是的話,this綁定的是那個上 下文對象。
var bar = obj1.foo() - 如果都不是的話,使用默認綁定。如果在嚴格模式下,就綁定到undefined,否則綁定到 全局對象。
var bar = foo()
ES6 中的箭頭函數并不會使用四條標準的綁定規則,而是根據當前的詞法作用域來決定 this,具體來說,箭頭函數會繼承外層函數調用的 this 綁定(無論 this 綁定到什么)。這 其實和ES6之前代碼中的self = this機制一樣。
Reference:
《ECMAScript 6 Primer》
《javascript 高級程序設計》
《JavaScript This 之謎(譯文)》 原文https://rainsoft.io/gentle-explanation-of-this-in-javascript/
《你不知道的JavaScript(上卷)》
GitHub Repo:Halfrost-Field
Follow: halfrost · GitHub