這是前端面試題系列的第 4 篇,你可能錯過了前面的篇章,可以在這裡找到:
在前端的面試中,經常會問到有關 this 的指向問題。最近,朋友Z 向我求助說,他一看到 this 的題目就犯難,搞不清楚 this 究竟指向了誰。我為他做了解答,並整理成了這篇文章,希望能幫到有需要的同學。
一道面試題
朋友Z 給我看了這樣一道題:
var length = 10;
function fn () {
console.log(this.length);
}
var obj = {
length: 5,
method: function (fn) {
fn();
arguments[0]();
}
};
obj.method(fn, 1);
複製程式碼
問:瀏覽器的輸出結果是什麼?
它的答案是:先輸出一個 10
,然後輸出一個 2
。
讓我們來解析一下原因:
- 在我們這道題中,雖然 fn 作為 method 的引數傳了進來,但它的呼叫者並不受影響,任然是
window
,所以輸出了 10。 arguments[0]();
這條語句並不常見,可能大家有疑惑的點在這裡。 其實,arguments 是一種特殊的物件。在函式中,我們無需指出引數名,就能訪問。可以認為它是一種,隱式的傳參形式。- 當執行 arguments0; 時,其實呼叫了 fn()。而這時,fn 函式中 this 就指向了 arguments,這個特殊的物件。obj.method 方法接收了 2 個引數,所以 arguments 的 length,很顯然就是 2 了。
文字版解析看得不是很明白的同學,可以看下面這段帶註釋版的:
var length = 10;
function fn () {
// 這裡可以多列印一行 this,來看清每一步的指向
// console.log(this);
console.log(this.length);
}
var obj = {
length: 5,
method: function (fn) {
// 大家可以試著,把 function 裡的引數 fn 去掉,
// 並不影響 fn 的呼叫者,還是 window
fn();
// arguments 有 2 個引數,fn 和 1,
// 當執行 arguments[0]() 等價於呼叫 fn(),
// arguments是一種特殊的物件,在這裡作為 fn 的呼叫者
arguments[0]();
}
};
obj.method(fn, 1);
複製程式碼
改造一下
再來,不少同學對 this 的指向感到疑惑,是因為 this 並沒有指向我們預期的那個物件。
就像這道題,從語義上來看,我們期望 fn() 輸出的是 obj 自己的 length,也就是 5,而不是 10。那麼如果要得到 5 的結果,我們該如何修改這段程式碼呢?
其實只要多做一步處理就好。就是讓 this 指向 obj 自己。這裡,我們可以用 call 來改變 this 的指向,像下面這樣:
var length = 10;
function fn () {
console.log(this.length);
}
var obj = {
length: 5,
method: function (fn) {
// 在這裡用call 將 this 指向 obj 自己
fn.call(this);
}
};
obj.method(fn);
複製程式碼
輸出的結果就是 5 了,搞定。
看吧,this 也沒那麼複雜吧,我們只需要一些簡單的操作,就能控制 this 的指向了。那麼,問題來了,為什麼有時候 this 會失控呢?
其實,這與 this 機制背後的原理有關。不過別急,讓我們從理解 this 的基本概念開始,先來看看 this 到底是什麼?
this 是什麼?
this 是 JavaScript 中的一個關鍵字。它通常被運用於函式體內,依賴於函式呼叫的上下文條件,與函式被呼叫的方式有關。它指向誰,則完全是由函式被呼叫的呼叫點來決定的。
所以,this,是在執行時繫結的,而與編寫時的繫結無關。隨著函式使用場合的不同,this 的值也會發生變化。但是有一個總的原則:那就是this 總會指向,呼叫函式的那個物件。
為什麼要用this?
從概念上理解起來,似乎有點費勁。那我們為什麼還要使用 this 呢?用了 this 會帶來什麼好處?
讓我們先看下面這個例子:
function identify() {
return this.name.toUpperCase();
}
var me = {
name: "Kyle"
};
var you = {
name: "Reader"
};
identify.call( me ); // KYLE
identify.call( you ); // READER
複製程式碼
一開始我們可能太不明白為何這樣輸出。那不如先換個思路,與使用 this 相反,我們可以明確地將環境物件,傳遞給 identify()。像這樣:
function identify(context) {
return context.name.toUpperCase();
}
identify( you ); // READER
複製程式碼
在這個簡單的例子中,結果是一樣的。我們可以把環境物件直接傳入函式,這樣看來比較直觀。但是,當模式越發複雜時,將執行環境作為一個明確的引數傳遞給函式,就會顯得非常混亂了。
而 this 機制,可以提供一種更優雅的方式,來隱含地“傳遞”一個物件的引用,這會使得 API 的設計更加地乾淨,複用也會變得容易。
this 的原理
明白了 this 的概念之後,不經讓我好奇,為何 this 指向的就是函式運的執行環境呢?
之前,看到了 阮老師 的一篇文章,十分透徹地分析了 this 的原理。我根據自己的理解,整理如下。
很多教科書會告訴你,this 指的是函式執行時所在的環境。但是,為什麼會這樣?也就是說,函式的執行環境到底是怎麼決定的?
理解 this 的原理,有助於幫我們更好地理解它的用法。JavaScript 語言之所以有 this 的設計,跟記憶體裡面的資料結構有關係。
來看一個簡單的示例:
var obj = { foo: 5 };
複製程式碼
上面的程式碼將一個物件賦值給變數 obj。JavaScript 引擎會先在記憶體裡面,生成一個物件 { foo: 5 },然後把這個物件的記憶體地址賦值給變數 obj。
也就是說,變數 obj 其實只是一個地址。後面如果要讀取 obj.foo,引擎先從 obj 拿到記憶體地址,然後再從該地址讀出原始的物件,返回它的 foo 屬性。
這樣的結構很清晰,但如果屬性的值是一個函式,又會怎麼樣呢?比如這樣:
var obj = { foo: function () {} };
複製程式碼
這時,JavaScript 引擎會將函式單獨儲存在記憶體中,然後再將函式的地址賦值給 foo 屬性的 value 屬性。
可以看到,函式是一個單獨的值(以地址形式賦值),所以才可以在不同的環境中執行。
又因為,JavaScript 允許在函式體內部,引用當前環境的其他變數。所以需要有一種機制,能夠在函式體內部獲得當前的執行環境(context)。所以,this就出現了,它的設計目的就是在函式體內部,指代函式當前的執行環境。
this 的用法
在理解了 this 的原理之後,我們用下面的 5 種情況,來討論 this 的用法。
1、純粹的函式呼叫
這是函式的最通常用法,屬於全域性性呼叫,因此 this 就代表全域性物件 window。
function test(){
this.x = 1;
console.log(this.x);
}
test(); // 1
複製程式碼
2、作為物件方法的呼叫
函式作為某個物件的方法呼叫,這時 this 就指這個上級物件。
function test(){
console.log(this.x);
}
var o = {};
o.x = 1;
o.m = test;
o.m(); // 1
複製程式碼
3、作為建構函式呼叫
所謂建構函式,就是通過這個函式生成一個新物件(object)。這時,this 就指這個新物件。
function test(){
this.x = 1;
}
var o = new test();
console.log(o.x); // 1
複製程式碼
4、apply呼叫
apply() 是函式物件的一個方法,它的作用是改變函式的呼叫物件,它的第一個引數就表示改變後的呼叫這個函式的物件。因此,this 指的就是這第一個引數。
var x = 0;
function test() {
console.log(this.x);
}
var o = {};
o.x = 1;
o.m = test;
o.m.apply(); //0
複製程式碼
apply() 的引數為空時,預設呼叫全域性物件。因此,這時的執行結果為 0,證明 this 指的是全域性物件。
它與上文中提到的 call 的作用是一樣的,只是寫法上略有區別。由於篇幅原因,我會另啟一篇,來詳述它們的用法。
5、箭頭函式
ES6 中的箭頭函式,在大部分情況下,使得 this 的指向,變得符合我們的預期。但有些時候,它也不是萬能的,一不小心的話,this 同樣會失控。
因為篇幅內容較多,我會另寫一篇文章來介紹。
另一道面試題
最後,讓我們來鞏固一下 this 的概念和用法。來看一道面試題:
window.val = 1;
var obj = {
val: 2,
dbl: function () {
this.val *= 2;
val *= 2;
console.log('val:', val);
console.log('this.val:', this.val);
}
};
// 說出下面的輸出結果
obj.dbl();
var func = obj.dbl;
func();
複製程式碼
答案是輸出:2 、 4 、 8 、 8
。
解析:
- 執行 obj.dbl(); 時, this.val 的 this 指向 obj,而下一行的 val 指向 window。所以,由 window.val 輸出 2,obj.val 輸出 4 。
- 最後一行 func(); 的呼叫者是 window。 所以,現在的 this.val 的 this 指向 window。
- 別忘了剛才的運算結果,window.val 已經是 2 了,所以現在 this.val *= 2; 的執行結果就是 4。
- val *= 2; 的執行結果,就是 8 了。 所以,最終的結果就是輸出 8 和 8 。
同樣的,下面是帶註釋版的程式碼:
window.val = 1;
var obj = {
val: 2,
dbl: function () {
// 第一次的 this 指向 obj。所以,2 * 2 得 4。
// 第二次的呼叫者變為了 window。this.val 等價於 window.val,
// 第一次運算之後,window.val 已經為 2 了, 再 * 2 得到的結果就為 4 了。
this.val *= 2;
// 第一次的 val 的呼叫者是 window。1 * 2 得 2。
// 第二次的 val 的呼叫者還是 window,
// 執行到此處時,window.val 已經為 4 了,再 * 2 的結果就是 8 了。
val *= 2;
// 第二次的呼叫者都是 window,所以兩行列印的結果是一樣的,都是8。
console.log('val:', val);
console.log('this.val:', this.val);
}
};
// 說出下面的輸出結果
obj.dbl();
var func = obj.dbl;
func();
複製程式碼
總結
this 指代了函式當前的執行環境,依賴於函式呼叫的上下文條件,在執行時才會進行繫結。請牢記總原則:this 總會指向,呼叫函式的那個物件。
參考文獻
更新
call 和 apply 具體用法的文章已更新,文末有彩蛋哦!!!詳見《「乾貨」細說 call、apply 以及 bind 的區別和用法》 (2019-01-27)
PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。