天天看點

帶你真正了解 JavaScript 中的 thisthis 的誤解this 是什麼調用棧this 的綁定規則

this 的誤解

  • this 預設指向函數自己。

任何情況下,this 都不會預設指向函數自己,除非使用 bind 綁定的方式修改 this 為函數自己。

  • this 指向函數作用域或上下文對象。

需要明确,任何情況下,this 都不預設指向函數的詞法作用域或上下文對象,作用域或者說上下文對象确實與對象類似,可見的辨別符都是其屬性,但是該對象隻存在于 js 引擎内部,無法在 js 環境下被通路。

this 是什麼

本質上,作用域工作模型分兩種,一種是詞法作用域,一種是動态作用域。

  • 詞法作用域:詞法作用域指的是在詞法階段産生的作用域,由書寫者在寫代碼時所寫的變量及作用域的位置所決定。引擎根據這些位置資訊來查找辨別符即變量的位置。例如:無論函數在哪裡、如何被調用,它的詞法作用域都隻由被聲明時所處的位置決定。
  • 動态作用域:動态作用域是一個在運作時被動态确定的形式,而不是在靜态時被确定。動态作用域不關心函數與作用域如何嵌套或何處聲明,隻關心它們在何處調用,也就是說。它的作用域鍊是基于調用棧而非作用域嵌套。例:
function foo() {
  console.log(a);
}
function bar() {
  var a = 3;
  foo();
}
var a = 2;
bar();           

複制

如果是詞法作用域,根據作用域規則,最終列印為 2; 可是動态作用域會順着調用棧去尋找變量,是以列印結果為 3。

js 的作用域規則屬于詞法作用域規則。

而 this 的機制與動态作用域的機制相近。this 在函數運作時綁定,不在編寫時綁定,其上下文取決于調用時的條件。this 綁定與函數聲明位置無關,取決于函數調用方式。

當一個函數被調用時,建立一個活動記錄(也稱執行上下文對象),此記錄對象包含函數調用棧、調用方式、傳入參數等資訊,this 是這個記錄的一個屬性。

調用棧

調用棧,其實就是函數的調用鍊,而目前函數的調用位置就在調用棧的倒數第二個位置(浏覽器開發者工具中,給某函數第一行打斷點 debugger,運作時,可以展示調用清單 call stack) 。示例:

//全局作用域下
function func(val) {
  if (val <= 0) return;
  console.log(val); 
  func(val - 1);
}
func(5);           

複制

執行棧用來存儲運作時的執行環境。當然,棧遵循先進後出的規則。

上面代碼的執行棧如下:執行建立時:建立全局執行環境 => func(5) => func(4) => func(3) => func(2) => func(1)。

執行完畢銷毀時:func(1) => func(2) => func(3) => func(4) => func(5) => 建立全局執行環境。

this 的綁定規則

上面的可以完全不記,隻要這部分牢記,就完全夠用了

預設綁定

産生于獨立函數調用時,可以了解為無法應用其他規則時的預設規則。預設綁定下的 this 在非嚴格模式的情況下,預設指向全局的 window 對象,而在嚴格模式的情況下,則指向 undefined。

ps1:以下規則,都是以函數環境為前提的,也就是說,this 是放在函數體内執行的。在非函數環境下,也就是浏覽器的全局作用域下,不論是否嚴格模式,this 将一直指向 window。一個冷知識:浏覽器環境下的全局對象是 window,其實除此之外還有一個特别的關鍵字,globalThis,在浏覽器環境下列印該對象,指向 window。

ps2: this 所在的詞法作用域在編寫或聲明時添加了"use strict",那麼,運作時 this 指向 undefined,但是,如果 this 所在的函數作用域中并未添加"use strict",而運作或調用該函數的詞法作用域裡有添加,那麼也不影響,依然指向 window。

ps3:對于 JS 代碼中沒有寫執行主體的情況下,非嚴格模式預設都是 window 執行的,是以 this 指向的是 window,但是在嚴格模式下,若沒有寫執行主體,this 指向是 undefined;

隐式綁定

判斷調用位置是否有上下文對象或者說是否有執行主體。簡單說,一個對象調用了它所"擁有"的方法,那麼,這個方法中的 this 将指向這個對象(對象屬性引用鍊中隻有上一層或者說最後一層才在調用位置中起作用,例:a.b.c.func(),func 中的 this 隻會指向 c 對象)。

函數方法并不屬于對象

說到對象與其包含的函數方法的關系,通常人們一提到方法,就會認為這個函數屬于一個對象 ,這是一個誤解,函數永遠不會屬于某個對象,盡管它是對象的方法。其中存在的關系隻是引用關系。示例 1:

//在對象的屬性上聲明一個函數
var obj = { 
  foo: function func() {}
};           

複制

示例 2:

//獨立聲明一個函數然後用對象的屬性引用
function func() {}
var obj = {
  foo: func
};           

複制

上述兩個例子效果是一樣的,沒有任何本質上的差別,很明顯,函數屬于它被聲明時所在的作用域;我們都知道函數本質上是被存儲在堆記憶體中,而函數的引用位址被存放在棧記憶體中友善我們取用,那麼實際上對象中的屬性持有的隻是存在棧記憶體裡函數的位址引用。

如果非要把持有引用位址當成一種屬于關系的話,一個函數的位址可以被無數變量引用持有,那麼這所有的變量都算是擁有這個函數,然而,屬于關系是唯一的,是以該觀點并不成立。

隐式丢失,即間接引用

示例 1:

var b = {  
  func: function() {}
};
var a = b.func;
a();           

複制

示例 2:

var b = {
  func: function() {}
};
function foo(fn) {
  fn();
}
foo(b.func);           

複制

這兩種情況下,this 指向丢失(不指向對象),而原理在上面的”函數方法并不屬于對象“裡已經揭露,在這裡,不論是 a 還是 fn(而參數傳遞其實就是一種隐式指派,傳入函數也是),拿到的都隻是函數的引用位址。

我們修改下上面的兩個示例就一目了然了。

示例 1:

function bar() {}
var b = {
  func: bar
};
var a = b.func; //相當于  var a=bar;
a();           

複制

示例 2:

function bar() {}
var b = {
  func: bar
};
function foo(fn) {
  fn();
}
foo(b.func); //相當于foo(bar);           

複制

顯式綁定

隐式綁定中,方法執行時,對象内部包含一個指向函數的屬性,通過這個屬性間接引用函數,進而實作 this 綁定。

顯式綁定也是如此,通過 call,apply 等方法,實作 this 的強制綁定(如果輸入字元串、布爾、數字等類型變量當做 this 綁定對象,那麼這些原始類型會被轉為對象類型,如 new String,new Boolean,new Number,這種行為叫裝箱)。綁定示例 1:

var a = 1;
function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
func.apply(obj); //0           

複制

綁定示例 2:

var a = 1;
function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
func.call(obj); //0           

複制

然而這依然無法解決可能丢失綁定的問題(比如處理回調函數,由于使用 call、apply 就會直接調用,而回調函數的調用無法人為介入控制是以回調函數上用不上 call、apply)。

示例代碼:

var a = 1;
function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
setTimeout(func.call(obj), 1000); //立即執行了,無法滿足延遲執行的需求           

複制

顯式綁定中的硬綁定

bind 是硬綁定,通過使用 bind 方法的硬綁定處理,将回調函數進行包裝,而得到的新函數在被使用時不會丢失綁定(利用了柯理化技術,柯理化技術依托于閉包)。

示例:

var a = 1;
function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
var newFunc = func.bind(obj);
setTimeout(newFunc, 1000); //延遲1秒後列印0           

複制

顯式綁定中的軟綁定

硬綁定降低了函數的靈活性,無法再使用隐式綁定或顯式綁定修改 this。

示例:

function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
var o = {
  a: 2
};
var newFunc = func.bind(obj);
newFunc.apply(o); //0           

複制

為了解決靈活性的問題,我們可以在硬綁定的原理基礎上嘗試 shim 一個新的綁定方式---軟綁定。

示例:

Function.prototype.softBind = function(self) {
  var func = this;
  var oldArg = [...arguments].slice(1);
  return function() {
      var newArgs = oldArg.concat([...arguments]);
      var _this = !this || this === window ? self : this;
      func.apply(_this, newArgs);
  };
};
function func() {
  console.log(this.a);
}
var obj = {
  a: 0
};
var o = {
  a: 2
};
var newFunc = func.softBind(obj);
newFunc(); //0
newFunc.apply(o); //2           

複制

核心代碼:

var _this = (!this || this === window)?self:this;
//如果this綁定到全局或者undefined時,那麼就保持包裝函數softBind被調用時的綁定,否則修改this綁定到目前的新this。           

複制

ps:js 的許多内置函數都提供了可選參數,用來實作綁定上下文對象,例:數組的 forEach、map、filter 等方法,第一個參數為回調函數,第二個為将綁定的上下文對象。

new 綁定

傳統語言中,構造函數是類中的一些特殊方法,使用 new 初始化類時會調用類中的構造函數。而 js 中的所謂"構造函數"其實隻是普通的函數,它們不屬于某個類,也不會執行個體化一個類。實際上 js 中并不存在構造函數,隻有對于函數的構造調用。使用 new 調用函數(構造調用) 時,

  • 執行函數;
  • 建立一個全新對象(若未傳回其他對象時,那麼 new 表達式中的函數調用會自動傳回這個新對象,若傳回了其他對象,則 this 将綁定在傳回的對象上);
  • 新對象會被執行原型連接配接;
  • 新對象會綁定到函數調用的 this。

優先級

new 綁定 > 顯式綁定 > 隐式綁定 > 預設綁定。

箭頭函數 this 綁定

根據該函數所在詞法作用域決定,簡單來說,箭頭函數中的 this 綁定繼承于該函數所在作用域中 this 的綁定。

箭頭函數沒有自己的 this,是以使用 bind、apply、call 無法修改其 this 指向,其 this 依然指向聲明時繼承的 this。

雖然 bind 不能修改其 this 指向,但是依然可以實作預參數的效果;而 apply 與 call 的參數傳遞也是生效的。

ps:箭頭函數不隻沒有自己 this,也沒有 arguments 對象。