「前端面試題系列4」this的原理以及用法

Micherwa發表於2019-01-20

這是前端面試題系列的第 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 屬性。

「前端面試題系列4」this的原理以及用法

可以看到,函式是一個單獨的值(以地址形式賦值),所以才可以在不同的環境中執行。

又因為,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:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。

「前端面試題系列4」this的原理以及用法

相關文章