一文梳理JavaScript中的this

川南煙雨發表於2020-06-20

最近零零碎碎看了許多關於this的文章,本著“好記性不如爛筆頭”的思想,特在這裡整理一下this有關的知識點。【長文警告!!!】

接下來,筆者將按照以下目錄對this進行闡述:

  • this是什麼?
  • this指向
    • this在全域性範圍內
    • this在物件的建構函式內
    • this在物件的方法內
    • this在簡單函式內
    • this在箭頭函式內
    • this在一個事件偵聽器內
  • this繫結規則
    • 預設繫結
    • 隱式繫結
    • 顯示繫結(this修改)
    • 優先順序
  • 箭頭函式

1. this是什麼?

this是JavaScript的一個關鍵字,但它時常蒙著面紗讓人無法捉摸,許多對this不明就裡的同學,常常會有這樣的錯誤認知:

  • this在函式內指向函式自身

    •   function foo(num){
            console.log("foo: " + num);
        
            //記錄foo被呼叫次數
            this.count++;
        }
        foo.count = 0;
        for(let i=0; i<10; i++){
            if(i > 5){
                foo(i);
            }
        }
        console.log(foo.count); // 0, this並沒有指向foo函式,foo.count沒有進行任何操作
      
  • this在函式內指向函式的作用域

    •   function foo(){
            var a = 2;
            this.bar();
        }
        function bar(){
            console.log(this.a);
        }
        foo();// undefined, window物件沒有bar這一屬性
      

2. this指向

this的指向取決於他所處的環境. 大致上,可以分為下面的6種情況:

  • this在全域性範圍內
  • this在物件的建構函式內
  • this在物件的方法內
  • this在一個簡單的函式內
  • this在箭頭函式內
  • this在一個事件偵聽器內

2.1 this在全域性範圍內

this在全域性範圍內繫結什麼呢?這個相信只要學過JS,應該都知道答案。如果不知道,同學真的應該反思自己的學習態度和方法是否存在問題了。話不多說,直接上程式碼,一探究竟,揭開this在全域性範圍下的真面目:

console.log(this); // Window

不出意外,this在全域性範圍內指向window物件()。通常, 在全域性環境中, 我們很少使用this關鍵字, 因此對它也沒那麼在意. 讓我們繼續看下一個環境.

2.2 this在物件的建構函式內

當我們使用new建立建構函式的例項時會發生什麼呢?以這種方式呼叫建構函式會經歷以下四個步驟:

  • 建立一個空物件;

  • 將建構函式的作用域賦給新物件(this指向了這個新物件),繼承函式的原型;

  • 執行建構函式中的程式碼;

  • 返回新物件。

看完上面的內容,大家想必也知道this在物件的建構函式內的指向了吧!當你使用new關鍵字建立一個物件的新的例項時, this關鍵字指向這個例項 .

舉個例子:

function Human (age) {
    this.age = age;
}
let greg = new Human(22);
let thomas = new Human(24);

console.log(greg); // this.age = 22
console.log(thomas); // this.age = 24

// answer
Person { age:22}
Person { age:24}

2.3 this在物件方法內

方法是與物件關聯的函式的通俗叫法, 如下所示:

let o = {
    sayThis(){
        console.log(this);
    }
}

如上所示,在物件的任何方法內的this都是指向物件本身 .

好了,繼續下一個環境!

2.4 this在簡單函式內

可能看到這裡,許多同學心裡會有疑問,什麼是簡單函式?

其實簡單函式大家都很熟悉,就像下面一樣,以相同形式編寫的匿名函式也被認為是簡單函式(非箭頭函式)。

function hello(){
    console.log("hello"+this);
}

這裡需要注意,在瀏覽器中,不管函式宣告在哪裡,匿名或者不匿名,只要不是直接作為物件的方法,this指向始終是window物件(除非使用call,apply,bind修改this指向)。

舉個例子說明一下:

// 顯示函式,直接定義在sayThis方法內,this指向依舊不變
function simpleFunction() {
    console.log(this);
}

var o = {
    sayThis() {
        simpleFunction();
    }
}

simpleFunction(); // Window
o.sayThis(); // Window


// 匿名函式
var o = {
    sayThis(){
        (function(){consoloe.log(this);})();
    }
} 
o.sayThis();// Window

對於初學者來說,this在簡單函式內的表現時常讓他們懵逼不已,難道this不應該指向物件本身?這個問題曾經也出現在我的腦海裡過,沒錯,在寫程式碼時我也踩過這個坑。

通常的,當我們要在物件方法內呼叫函式,而這個函式需要用到this時,我們都會建立一個變數來儲存物件中的this的引用. 通常, 這個變數名稱叫做self或者that。具體說下所示:

const o = {
    doSomethingLater() {
        const self = this;
        setTimeout(function() {
            self.speakLeet();
        }, 1000);
    },
    speakLeet() {
        console.log(`1337 15 4W350M3`);
    }
}

o.doSomethingLater(); // `1337 15 4W350M3`

心細的同學可能已經發現,這裡的簡單函式沒有將箭頭函式包括在內,那麼下一個環境是什麼想必也能猜到啦,那麼現在進入下一個環境,看看this指向什麼。

2.5 this在箭頭函式內

和簡單函式表現不太一樣,this在箭頭函式中總是跟它在箭頭函式所在作用域的this一樣(在它直接作用域). 所以, 如果你在物件中使用箭頭函式, 箭頭函式中的this總是指向這個物件本身, 而不是指向Window.

下面我們使用箭頭函式,重寫一下上面的案例:

const o = {
    doSomethingLater() {
        setTimeout(() => this.speakLeet(), 1000);
    },
    speakLeet() {
        console.log(`1337 15 4W350M3`);
    }
}
o.doSomethingLater(); // `1337 15 4W350M3`

最後,讓我們來看看最後一種環境 - 事件偵聽器.

2.6 this在事件偵聽器內

在事件偵聽器內, this被繫結的是觸發這個事件的元素:

let button = document.querySelector('button');

button.addEventListener('click', function() {
    console.log(this); // button
});

3. this繫結規則

事實上,只要記住上面this在不同環境的繫結值,足以應付大部分工作。然而,好學的同學總是會忍不住想說,為什麼呢?對,為什麼this在這些情況下繫結這些值呢?學習,我們不能只知其然,而不知所以然。所以,現在就讓我們來探尋,this值獲取的真相吧。

現在,讓我們回憶一下,在講什麼是this的時候,我們說到“this的繫結取決於他所處的環境”。這句話其實不是十分準確,準確的說,this不是編寫時繫結,而是執行時繫結。它依賴於函式呼叫的上下文條件this繫結和函式宣告的位置無關,反而和函式被呼叫的方式有關

當一個函式被呼叫時,會建立一個活動記錄,也稱為執行環境。這個記錄包含函式是從何處(call-stack)被呼叫的,函式是 如何被呼叫的,被傳遞了什麼引數等資訊。這個記錄的屬性之一,就是在函式執行期間將被使用的this引用。this實際上是在函式被呼叫時建立的一個繫結,它指向什麼是完全由函式被呼叫的呼叫點來決定的

僅僅是規則

現在我們將注意力轉移到呼叫點 如何 決定在函式執行期間this指向哪裡。

你必須考察call-site並判定4種規則中的哪一個適用。我們將首先獨立的解釋一下這4種規則中的每一種,之後我們來展示一下如果有多種規則可以適用呼叫點時,它們的優先順序。

3.1 預設繫結規則

第一種規則來源於函式呼叫的最常見的情況:獨立函式呼叫。可以認為這種this規則是在沒有其他規則適用時的預設規則。我們給它一個稱呼“預設繫結”.

現在來看這段程式碼:

function foo(){
    console.log(this); 
}
var a = 2;
demo(); // 2

當foo()被呼叫時,this.a解析為我們的全域性變數a。為什麼?因為在這種情況下,對此方法呼叫的this實施了 預設繫結,所以使this指向了全域性物件。

在我們的程式碼段中,foo()是被一個直白的,毫無修飾的函式引用呼叫的。沒有其他的我們將要展示的規則適用於這裡,所以 預設繫結 在這裡適用。

如果strict mode在這裡生效,那麼對於 預設繫結 來說全域性物件是不合法的,所以this將被設定為undefined。

'use strict'
function foo(){
    console.log(this.a); // TypeError: Cannot read property 'a' of undefined
}
const a = 1;
foo();
function foo(){
	'use strict'
    console.log(this.a); // TypeError: Cannot read property 'a' of undefined
}
const a = 1;
foo();

微妙的是,即便所有的this繫結規則都是完全基於呼叫點,如果foo()的 內容 沒有在strint mode下執行,對於 預設繫結 來說全域性物件是 唯一 合法的;foo()的call-site的strict mode狀態與此無關。

function foo(){
    console.log(this.a); 
}
var a = 1;
(function(){
	'use strict';
	foo(); // 1
})();

注意: 在程式碼中故意混用strict mode和非strict mode通常是讓人皺眉頭的。你的程式整體可能應當不是 Strict 就是非Strict。然而,有時你可能會引用與你的 Strict 模式不同的第三方包,所以對這些微妙的相容性細節要多加小心。

3.2 隱式繫結

另一種要考慮的規則是:呼叫點是否有一個環境物件(context object),也稱為擁有者(owning)或容器(containing)物件。

讓我們來看這段程式碼:

function foo() {
    console.log(this.a);
}
let o = {
    a: 2,
    foo,
}
o.foo(); // 2

這裡,我們注意到foo函式被宣告然後作為物件o的方法,無論foo()是否一開始就在obj上被宣告,還是後來作為引用新增(如上面程式碼所示),都是這個 函式 被obj所“擁有”或“包含”。這裡,呼叫點使用obj環境來引用函式,所以可以說 obj物件在函式被呼叫的時間點上“擁有”或“包含”這個 函式引用。

當一個方法引用存在一個環境物件時,隱式繫結 規則會說:是這個物件應當被用於這個函式呼叫的this繫結。

只有物件屬性引用鏈的最後一層是影響呼叫點的。比如:

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

var obj1 = {
    a:2,
    obj2:obj2
};
var obj2 = {
    a:42,
    foo:foo
};
obj1.obj2.foo(); // 42

隱式繫結的隱患

當一個 隱含繫結丟失了它的繫結,這通常意味著它會退回到 預設繫結, 根據strict mode的狀態,結果不是全域性物件就是undefined。

下面來看這段程式碼:

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

var obj = {
    a:2,
    foo
};
var bar = obj.foo;
var a = "Global variable";
bar(); // "Global variable"

儘管bar似乎是obj.foo的引用,但實際上它只是另一個foo自己的引用而已。另外,起作用的呼叫點是bar(),一個直白,毫無修飾的呼叫,因此 預設繫結 適用於這裡。

這種情況發生的更加微妙,更常見,更意外的方式,是當我們考慮傳遞一個回撥函式時:

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

function doFoo(fn){
	fn();
}

var obj = {
    a:2,
    foo,
};
var a = "Global variable";
dooFoo(obj.foo); // "Global variable"

引數傳遞僅僅是一種隱含的賦值,而且因為我們在傳遞一個函式,它是一個隱含的引用賦值,所以最終結果和我們前一個程式碼段一樣。同樣的,語言內建,如setTimeout也一樣,如下所示

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

var obj = {
    a:2,
    foo,
};
var a = "Global variable";
setTimeout(obj.foo, 100); // "Global variable"

把這個粗糙的setTimeout()假想實現當做JavaScript環境內建的實現的話:

function setTimeout(fn, delay){
    // 等待delay毫秒
    fn();
}

正如我們看到的, 隱含繫結丟失了它的繫結是十分常見的,不管哪一種意外改變this的方式,你都不能真正地控制你的回撥函式引用將如何被執行,所以你(還)沒有辦法控制呼叫點給你一個故意的繫結。但是我們可以使用顯示繫結強行固定this。

3.3 顯示繫結

我們看到隱含繫結,需要我們不得不改變目標物件使它自身包含一個對函式的引用,而後使用這個函式引用屬性來間接地(隱含地)將this繫結到這個物件上。

但是,如果你想強制一個函式呼叫使用某個特定物件作為this繫結,而不在這個物件上放置一個函式引用屬性呢?

js有提供call()、apply()方法,ES5中也提供了內建的方法 Function.prototype.bind,可以引用一個物件時進行強制繫結呼叫。

考慮這段程式碼:

function foo(){
    console.log(this.a);
}
var obj = {
    a:2,
};
foo.call(obj); // 2

通過foo.call(..)使用 明確繫結 來呼叫foo,允許我們強制函式的this指向obj。

如果你傳遞一個簡單原始型別值(string,boolean,或 number型別)作為this繫結,那麼這個原始型別值會被包裝在它的物件型別中(分別是new String(..),new Boolean(..),或new Number(..))。這通常稱為“boxing(封箱)”。

注意: 就this繫結的角度講,call(..)和apply(..)是完全一樣的。它們確實在處理其他引數上的方式不同,但那不是我們當前關心的。

單獨依靠call和apply,仍然可能出現函式“丟失”自己原本的this繫結,或者被第三方覆蓋等問題。

但有一個技巧可以避免出現這些問題

考慮這段程式碼:

function foo(){
    console.log(this.a);
}
var obj = {
	a:2
};
var bar = function(){
	foo.call(obj);
}
bar(); // 2
setTimeout(bar, 100); // 2
bar.call(window); // 2

我們建立了一個函式bar(),在它的內部手動呼叫foo.call(obj),由此強制this繫結到obj並呼叫foo。無論你過後怎樣呼叫函式bar,它總是手動使用obj呼叫foo。這種繫結即明確又堅定,該方法被開發者稱為 硬繫結(顯示繫結的變種)(hard binding)

用硬繫結將一個函式包裝起來的最典型的方法,是為所有傳入的引數和傳出的返回值建立一個通道:

function foo(something){
    console.log(this.a, something);
    return this.a + something;
}
var obj = {
    a:2
};
var bar = function() {
    return foo.apply(obj, arguments);
}
var b = bar(3);
console.log(b); //  5

另一種表達這種模式的方法是建立一個可複用的幫助函式:

function foo(something){
    console.log(this.a, something);
    return this.a + something;
}

function bind(fn, obj){
    return function(){
        return fn.apply(obj, arguments);
    };
}

var obj = { a:2};
var bar = bind(foo, obj);
var b = bar(3);
console.log(b); // 5

由於 硬繫結 是一個如此常用的模式,它已作為ES5的內建工具提供,即前文提到的Function.prototype.bind:

function foo(something){
    console.log(this.a, something);
    return this.a + something;
}
var obj = { a:2};
var bar = foo.bind(obj);
var b = bar();
cobsole.log(b); // 5

bind(..)返回一個硬編碼的新函式,它使用你指定的this環境來呼叫原本的函式。

注意: 在ES6中,bind(..)生成的硬繫結函式有一個名為.name的屬性,它源自於原始的 目標函式(target function)。舉例來說:bar = foo.bind(..)應該會有一個bar.name屬性,它的值為"bound foo",這個值應當會顯示在呼叫棧軌跡的函式呼叫名稱中。

3.4new 繫結

第四種也是最後一種this繫結規則

當在函式前面被加入new呼叫時,也就是構造器呼叫時,下面這些事情會自動完成:

  • 一個全新的物件會憑空建立(就是被構建)
  • 這個新構建的物件會被接入原形鏈([[Prototype]]-linked)
  • 這個新構建的物件被設定為函式呼叫的this繫結
  • 除非函式返回一個它自己的其他 物件,這個被new呼叫的函式將 自動 返回這個新構建的物件。

考慮這段程式碼:

function foo(a){
    console.log(this.a);
}
var bar = new foo(2);
console.log(bar.a); // 2

通過在前面使用new來呼叫foo(..),我們構建了一個新的物件並這個新物件作為foo(..)呼叫的this。 new是函式呼叫可以繫結this的最後一種方式,我們稱之為 new繫結(new binding)。

3.5 優先順序

  • new繫結
  • 顯示繫結
  • 隱式繫結
  • 預設繫結(嚴格模式下會繫結到undefined)

4. 箭頭函式

箭頭函式並非使用function關鍵字進行定義,而是通過所謂的“大箭頭”操作符:=>,所以不會使用上面所講解的this四種標準規範,箭頭函式從封閉它的(function或global)作用域採用this繫結,即箭頭函式會繼承自外層函式呼叫的this繫結。

執行 fruit.call(apple)時,箭頭函式this已被繫結,無法再次被修改。

function fruit(){
    return () => {
        console.log(this.name);
    }
}
var apple = {
    name: '蘋果'
}
var banana = {
    name: '香蕉'
}
var fruitCall = fruit.call(apple);
fruitCall.call(banana); // 蘋果

5. 小結

this是JavaScript的一個關鍵字,this不是編寫時繫結,而是執行時繫結。它依賴於函式呼叫的上下文條件。this繫結和函式宣告的位置無關,反而和函式被呼叫的方式有關。為執行中的函式判定this繫結需要找到這個函式的直接呼叫點。找到之後,4種規則將會以 這個 優先順序施用於呼叫點:

  • 被new呼叫?使用新構建的物件。
  • 被call或apply(或 bind)呼叫?使用指定的物件。
  • 被持有呼叫的環境物件呼叫?使用那個環境物件。
  • 預設:strict mode下是undefined,否則就是全域性對

與這4種繫結規則不同,ES6的箭頭方法使用詞法作用域來決定this繫結,這意味著它們採用封閉他們的函式呼叫作為this繫結(無論它是什麼)。它們實質上是ES6之前的self = this程式碼的語法替代品。

參考文章:

深入理解JavScript中的this

詳解JavaScript中的this

你不懂this:豁然開朗

你不懂this:this是什麼?

相關文章