深入理解Js中的this

WindrunnerMax發表於2021-02-05

深入理解Js中的this

JavaScript作用域為靜態作用域static scope,但是在Js中的this卻是一個例外,this的指向問題就類似於動態作用域,其並不關心函式和作用域是如何宣告以及在何處宣告的,只關心它們從何處呼叫,this的指向在函式定義的時候是確定不了的,只有函式執行的時候才能確定this到底指向誰,當然實際上this的最終指向的是那個呼叫它的物件。

作用域

我們先來了解一下JavaScript的作用域,以便理解為什麼說this更類似於動態作用域,通常來說,一段程式程式碼中所用到的名字並不總是有效或可用的,而限定這個名字的可用性的程式碼範圍就是這個名字的作用域scope,當一個方法或成員被宣告,他就擁有當前的執行上下文context環境,在有具體值的context中,表示式是可見也都能夠被引用,如果一個變數或者其他表示式不在當前的作用域,則將無法使用。作用域也可以根據程式碼層次分層,以便子作用域可以訪問父作用域,通常是指沿著鏈式的作用域鏈查詢,而不能從父作用域引用子作用域中的變數和引用。
JavaScript作用域為靜態作用域static scope,也可以稱為詞法作用域lexical scope,其主要特徵在於,函式作用域中遇到既不是引數也不是函式內部定義的區域性變數時,去函式定義時上下文中查,而與之相對應的是動態作用域dynamic scope則不同,其函式作用域中遇到既不是引數也不是函式內部定義的區域性變數時,到函式呼叫時的上下文中去查。

var a = 1;
var s = function(){
    console.log(a);
};

(function(){
    var a = 2;
    s(); // 1
})();

呼叫s()是列印的a1,此為靜態作用域,也就是宣告時即規定作用域,而假如是動態作用域的話在此處會列印2。現在大部分語言都採用靜態作用域,比如CC++JavaPHPPython等等,具有動態作用域的語言有Emacs LispCommon LispPerl等。

全域性作用域

直接宣告在頂層的變數或方法就執行在全域性作用域,借用函式的[[Scopes]]屬性來檢視作用域,[[Scopes]]是儲存函式作用域鏈的物件,是函式的內部屬性無法直接訪問但是可以列印來檢視。

function s(){}
console.dir(s);
/*
  ...
  [[Scopes]]: Scopes[1]
    0: Global ...
*/
// 可以看見宣告的s函式執行的上下文環境是全域性作用域

函式作用域

當宣告一個函式後,在函式內部宣告的方法或者成員的執行環境就是此函式的函式作用域

(function localContext(){
    var a = 1;
    function s(){ return a; }
    console.dir(s);
})();
/*
  ...
  [[Scopes]]: Scopes[2]
    0: Closure (localContext) {a: 1}
    1: Global ...
*/
// 可以看見宣告的s函式執行的上下文環境是函式localContext的作用域,也可以稱為區域性作用域

塊級作用域

程式碼塊內如果存在let或者const,程式碼塊會對這些命令宣告的變數從塊的開始就形成一個封閉作用域。

{
    let a = 1;
    function s(){return a;}
    console.dir(s);
    /*
      ...
      [[Scopes]]: Scopes[2]
        0: Block {a: 1}
        1: Global ...
    */
}
// 可以看見宣告的s函式執行的上下文環境是Block塊級作用域,也是區域性作用域

分析

我們在使用this之前有必要了解為什麼在JavaScript中要有this這個設計,在這之前我們先舉個小例子,通常我們使用this時可能會遇到的典型問題就類似於下面這樣,雖然我們執行的都是同一個函式,但是執行的結果可能會不同。

var obj = {
    name: 1,
    say: function() {
        return this.name;
    }
};

window.name = 2;
window.say = obj.say;

console.log(obj.say()); // 1
console.log(window.say()); // 2

產生這樣的結果的原因就是因為使用了this關鍵字,前文已經提到了this必須要在執行時才能確定,在這裡,對於obj.say()來說,say()執行的環境是obj物件,對於window.say()來說,say()執行的環境是window物件,所以兩者執行的結果不同。
此時我們就來了解一下,為什麼JavaScript會有this這樣一個設計,我們首先來了解一下JavaScript的記憶體結構中的堆疊,堆heap是動態分配的記憶體,大小不定也不會自動釋放,棧stack為自動分配的記憶體空間,在程式碼執行過程中自動釋放。JavaScript在棧記憶體中提供一個供Js程式碼執行的環境,關於作用域以及函式的呼叫都是棧記憶體中執行的。Js中基本資料型別StringNumberBooleanNullUndefinedSymbol,佔用空間小且大小固定,值直接儲存在棧記憶體中,是按值訪問,對於Object引用型別,其指標放置於棧記憶體中,指向堆記憶體的實際地址,是通過引用訪問。
那麼此時我們來看一下上邊的示例,在記憶體中對於obj物件是存放在堆記憶體的,如果在物件中的屬性值是個基本資料型別,那麼其會跟這個物件儲存在同一塊記憶體區域,但是這個屬性值同樣可能是一個引用型別,那麼對於say這個函式也是存在於堆記憶體中的,實際上在此處我們可以將其理解為這個函式的實際定義在一個記憶體區域(以一個匿名函式的形式存在),而obj這個物件同樣在其他的一個記憶體區域,obj通過say這個屬性指向了這個匿名函式的記憶體地址,obj --say--> funtion,那麼此時問題來了,由於這種記憶體結構,我們可以使任何變數物件等指向這個函式,所以在JavaScript的函式中是需要允許我們取得執行環境的值以供使用的,我們必須要有一種機制,能夠在函式體內部獲得當前的執行環境context,所以this就出現了,它的設計目的就是在函式體內部,指代函式當前的執行環境。

使用

我們需要記住,this是在執行時進行繫結的,並不是在定義時繫結,它的context取決於函式呼叫時的各種條件,簡單來說this的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式,再簡單來說this永遠指向呼叫者,但箭頭函式除外,接下來我們介紹一下五種this的使用情況。

預設繫結

最常用的函式呼叫型別即獨立函式呼叫,這個也是優先順序最低的一個,此時this指向全域性物件,注意如果使用嚴格模式strict mode,那麼全域性物件將無法使用預設繫結,因此this會變為undefined

var a = 1; //  變數宣告到全域性物件中
function f1() {
    return this.a;
}

function f2() {
    "use strict";
    return  this;
}

console.log(f1()); // 1 // 實際上是呼叫window.f1()而this永遠指向呼叫者即window
console.log(f2()); // undefined // 實際上是呼叫 window.f2() 此時由於嚴格模式use strict所以在函式內部this為undefined

隱式繫結

物件屬性引用鏈中只有最頂層或者說最後一層會影響this,同樣也是this永遠指向呼叫者,具體點說應該是指向最近的呼叫者,當然箭頭函式除外,另外我們可能有意無意地建立間接引用地情況,這個情況下同樣也適用於this指向呼叫者,在上文分析那部分使用的示例就屬於間接引用的情況。

function f() {
    console.log(this.a);
}
var obj1 = {
    a: 1,
    f: f
};
var obj2 = {
    a: 11,
    obj1: obj1
};
obj2.obj1.f(); // 1 // 最後一層呼叫者即obj1
function f() {
    console.log(this.a);
}
var obj1 = {
    a: 1,
    f: f
};
var obj2 = {
    a: 11,
};
obj2.f = obj1.f; // 間接引用
obj2.f(); // 11 // 呼叫者即為obj2

顯示繫結

如果我們想把某個函式強制在某個環境即物件上,那麼就可以使用applycallbind強制繫結this去執行即可,每個Function物件都存在apply()call()bind()方法,其作用都是可以在特定的作用域中呼叫函式,等於設定函式體內this物件的值,以擴充函式賴以執行的作用域,此外需要注意使用bind繫結this的優先順序是大於applycall的,即使用bind繫結this後的函式使用applycall是無法改變this指向的。

window.name = "A"; // 掛載到window物件的name
document.name = "B"; // 掛載到document物件的name
var s = { // 自定義一個物件s
    name: "C"
}

var rollCall = {
    name: "Teacher",
    sayName: function(){
        console.log(this.name);
    }
}
rollCall.sayName(); // Teacher

// apply
rollCall.sayName.apply(); // A // 不傳參預設繫結window
rollCall.sayName.apply(window); // A // 繫結window物件
rollCall.sayName.apply(document); // B // 繫結document物件
rollCall.sayName.apply(s); // C // 繫結自定義物件

// call
rollCall.sayName.call(); // A // 不傳參預設繫結window
rollCall.sayName.call(window); // A // 繫結window物件
rollCall.sayName.call(document); // B // 繫結document物件
rollCall.sayName.call(s); // C // 繫結自定義物件

// bind // 最後一個()是為讓其執行
rollCall.sayName.bind()(); //A // 不傳參預設繫結window
rollCall.sayName.bind(window)(); //A // 繫結window物件
rollCall.sayName.bind(document)(); //B // 繫結document物件
rollCall.sayName.bind(s)(); // C // 繫結自定義物件

new繫結

JavaScriptnew是一個語法糖,可以簡化程式碼的編寫,可以批量建立物件例項,在new的過程實際上進行了以下操作。

  1. 建立一個空的簡單JavaScript物件即{}
  2. 連結該物件(即設定該物件的建構函式)到另一個物件。
  3. 將步驟1新建立的物件作為this的上下文context
  4. 如果該函式沒有返回物件,則返回步驟1建立的物件。
function _new(base,...args){
    var obj = {};
    obj.__proto__ = base.prototype;
    base.apply(obj, args);
    return obj;
}

function Funct(a) {
    this.a = a;
}
var f1 = new Funct(1);
console.log(f1.a); // 1

var f2 = _new(Funct, 1);
console.log(f2.a); // 1

箭頭函式

箭頭函式沒有單獨的this,在箭頭函式的函式體中使用this時,會取得其上下文context環境中的this。箭頭函式呼叫時並不會生成自身作用域下的this,它只會從自己的作用域鏈的上一層繼承this。由於箭頭函式沒有自己的this指標,使用applycallbind僅能傳遞引數而不能動態改變箭頭函式的this指向,另外箭頭函式不能用作構造器,使用new例項化時會丟擲異常。

window.name = 1;
var obj = {
    name: 11,
    say: function(){
        const f1 = () => {
            return this.name;
        }
        console.log(f1()); // 11 // 直接呼叫者為window 但是由於箭頭函式不繫結this所以取得context中的this即obj物件
        const f2 = function(){
            return this.name;
        }
        console.log(f2()); // 1 // 直接呼叫者為window 普通函式所以
        return this.name;
    }
}

console.log(obj.say()); // 11 // 直接呼叫者為obj 執行過程中的函式內context的this為obj物件

示例

function s(){
    console.log(this);
}

// window中直接呼叫 // 非 use strict
s(); // Window // 等同於window.s(),呼叫者為window
// window是Window的一個例項 // window instanceof Window //true

// 新建物件s1
var s1 = {
    t1: function(){ // 測試this指向呼叫者
        console.log(this); // s1
        s(); // Window // 此次呼叫仍然相當 window.s(),呼叫者為window
    },
    t2: () => { // 測試箭頭函式,this並未指向呼叫者
        console.log(this);
    },
    t3: { // 測試物件中的物件
      tt1: function() {
           console.log(this);
      }  
    },
    t4: { // 測試箭頭函式以及非函式呼叫this並未指向呼叫者
      tt1: () => {
           console.log(this);
      }  
    },
    t5: function(){ // 測試函式呼叫時箭頭函式的this的指向,其指向了上一層物件的呼叫者
        return {
            tt1: () => {
                console.log(this);
            }
        }
    }
}
s1.t1(); // s1物件 // 此處的呼叫者為 s1 所以列印物件為 s1
s1.t2(); // Window
s1.t3.tt1(); // s1.t3物件
s1.t4.tt1(); // Window
s1.t5().tt1(); // s1物件

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/6882527259584888845
https://www.cnblogs.com/raind/p/10767622.html
http://www.ruanyifeng.com/blog/2018/06/javascript-this.html

相關文章