一文徹底搞懂 Javascript 的 this(含 ES6+)

WonderfulWorldHa發表於2020-04-04

前言

關於 Javascript 的 this 相信大家已經閱讀過一些關於它的文章,也在實踐中有些知識積累,作為 JavaScript 中最令人困惑的機制之一,即便是一些老練的開發也不一定完全掌握 this 這個神奇的東西,今天我結合 ES6+ 的一些語法重新梳理下 this,希望對大家有所幫助。

this的定義

ECMAScript 規範(5.1)中是這樣描述 this 的:

The this keyword evaluates to the value of the ThisBinding of the current execution context.

意思是當前執行上下文的 ThisBinding 的值就是 this。 this 是一個物件,與執行的上下文環境息息相關,也可以把 this 稱為上下文物件,啟用執行上下文的上下文。

執行上下文

什麼是執行上下文?

簡而言之,執行上下文是評估和執行 JavaScript 程式碼的環境的抽象概念。每當 Javascript 程式碼在執行的時候,它都是在執行上下文中執行。

執行上下文有 3 種:全域性執行上下文、函式執行上下文和 eval 執行上下文。

全域性執行上下文的 this

1. 瀏覽器:

console.log(this);
// window
複製程式碼

2. Node:

console.log(this)
// global
複製程式碼

總結:在全域性作用域中它的 this 執行當前的全域性物件(瀏覽器端是 Window,node 中是 global)。

知識補充

Web中,可以通過 window、self 或者 frames 取到全域性物件,但是在Web Workers中,只有 self 可以。在Node.js中,它們都無法獲取,必須使用 global。

在鬆散模式下,可以在函式中返回 this 來獲取全域性物件,但是在嚴格模式和模組環境下,this 會返回 undefined。

globalThis旨在通過定義一個標準的全域性屬性來整合日益分散的訪問全域性物件的方法。該提案目前處於第四階段,這意味著它已經準備好被納入 ES2020 標準。所有流行的瀏覽器,包括 Chrome 71+、Firefox 65+ 和 Safari 12.1+,都已經支援這項功能。你也可以在 Node.js 12+ 中使用它。

函式執行上下文的 this

函式執行上下文的 this 有些複雜,主要是因為它不是靜態繫結到一個函式的,而是與函式如何被呼叫有關,也就是說即使是同一個函式,每次執行時的 this 也有可能不一樣。

在 JavaScript 中,this 是指當前函式中正在執行的上下文環境。JavaScript 中共有4種函式呼叫方式

  • 函式呼叫方式;alert('Hello World!')
  • 方法呼叫方式;console.log('Hello World!')
  • 建構函式方式;new RegExp('\\d')
  • 間接呼叫方式(apply/call);alert.call(undefined, 'Hello World')

在以上每一項呼叫中,它都擁有各自獨立的上下文環境,就會造成 this 所指意義有所差別。此外,嚴格模式也會對執行環境造成影響。

以下例子均在瀏覽器環境

1. 函式呼叫

1.1 函式呼叫中的 this

當一個函式並非一個物件的屬性時,那麼它就是被當做一個函式來呼叫的。此時,相當於在全域性上下文環境中呼叫此函式,this 指向全域性物件 。

var funA = function() {
  return this.value;
}
console.log(funA()); //undefined
var value = 233;
console.log(funA()); //233
複製程式碼

1.2 嚴格模式下的函式呼叫

使用嚴格模式,只需要將 'use strict' 置於函式體的頂部。這樣上下文環境中的 this 將為 undefined。

function strictFun() {  
    'use strict';
    console.log(this === undefined); // => true
}
複製程式碼

易錯:物件方法中的內部方法中的 this:

var numbers = {  
    numberA: 5,
    numberB: 10,
    sum: function() {
        console.log(this === numbers); // => true
        function calculate() {
            console.log(this === numbers); // => false
            // this 是 window,而不是numbers
            // 嚴格模式下, this 是 undefined
            return this.numberA + this.numberB;
        }
        return calculate();
    }    
};    
numbers.sum(); // NaN ;嚴格模式下throws TypeError
複製程式碼

2.方法呼叫

2.1 方法呼叫中的 this 當在一個物件裡呼叫方法時,this 指的是物件自身。

var obj = {
  value:233,
  funA:function(){
    return this.value;//通過obj.funA()呼叫時,this指向obj
  }
}
console.log(obj.funA());//233
複製程式碼

obj.funA() 呼叫意味著上下文執行環境在 obj 物件裡。

我們還可以這麼寫:

function funA() {
  console.log(this.value);
}
var obj = {
  value: 233,
  foo: funA
}
obj.funA();// 233
複製程式碼

易錯:當把物件的方法賦值給一個變數後執行

var obj = {
  value:233,
  funA:function(){
    return this.value;//通過funB()呼叫時,this指向全域性物件
  }
}
var value = 1;
var funB = obj.funA;
funB(); // 1
複製程式碼

上例方法在執行的時候,是被當作一個普通函式來直接呼叫,因此,this 指向全域性物件。

易錯:回撥裡的 this

var obj = {
  funA: function() {
    console.log(this);
  },
  funB: function() {
    console.log(this);
    setTimeout(this.funA, 1000);
  }
}
obj.funB(); 
// obj
// window
複製程式碼

上例 funA 執行時的 this 指向全域性物件,原因是當 funB 執行時,this.funA 作為一個 setTimeout 方法的一個引數傳入(fun = this.funA),當執行時只是 fun() 執行,此時的執行上下文已與 obj 物件無關。

易錯:setTimeout 下的嚴格模式

'use strict';
function foo() {
  console.log(this); // window
}
setTimeout(foo, 1);
複製程式碼

上例 foo 方法中的 this 指向全域性物件,大家有可能會有些奇怪,不是說好的嚴格模式下方法中的 this 是 undefined 嗎?這裡卻是全域性物件,難道是嚴格模式失效了嗎?

MDN 關於 SetTimeout 的文件中有個標註:

即使是在嚴格模式下,setTimeout() 的回撥函式裡面的 this 仍然預設指向 window 物件,並不是 undefined。此特性也同樣適用於 setInterval。

3.建構函式呼叫

建構函式呼叫時,this 指向了這個建構函式呼叫時候例項化出來的物件;

function Person(name) {
  this.name = name;
  console.log(this);
}
var p = new Person('Eason');
// Person {name: "Eason"}
複製程式碼

在使用 class 語法時也是同樣的情況(在 ES6 中),初始化只發生在它的 constructor 方法中。

class Person {
  constructor(name){
    this.name = name;
    console.log(this)
  }
}
var p = new Person('Eason');
// Person {name: "Eason"}
複製程式碼

當執行 new Person() 時,JavaScript 建立了一個空物件並且它的上下文環境為 constructor 方法。

4.間接呼叫

當一個函式使用了 .call() 或者 .apply() 方法時則為間接呼叫。

在間接呼叫中,this 指向的是 .call() 和 .apply()傳遞的第一個引數

  • call
fun.call(thisArg[, arg1[, arg2[, ...]]])
複製程式碼
  • apply
fun.apply(thisArg[, [arg1, arg2, ...]])
複製程式碼

當函式執行需要特別指定上下文時,間接呼叫非常有用,它可以解決函式呼叫中的上下文問題(this 指向 window 或者嚴格模式下指向 undefined),同時也可以用來模擬方法呼叫物件。

var eason = { name: 'Eason' };  
function concatName(str) {  
  console.log(this === eason); // => true
  return `${str} ${this.name}`;
}
concatName.call(eason, 'Hello ');  // => 'Hello Eason'  
concatName.apply(eason, ['Bye ']); // => 'Bye Eason' 
複製程式碼

另一個實踐例子為,在 ES5 中的類繼承中,呼叫父級構造器。

function Animal(name) {  
  console.log(this instanceof Cat); // => true
  this.name = name;  
}
function Cat(name, color) {  
  console.log(this instanceof Cat); // => true
  // 間接呼叫,呼叫了父級構造器
  Animal.call(this, name);
  this.color = color;
}
var tom = new Cat('Tom', 'orange');  
tom; // { name: 'Tom', color: 'orange' }
複製程式碼

Animal.call(this, name) 在 Cat 裡間接呼叫了父級方法初始化物件。

需要注意的是如果這個函式處於非嚴格模式下,則第一個引數不傳入或指定為 null 或 undefined 時會自動替換為指向全域性物件。

5.繫結函式呼叫

.bind() 方法的作用是建立一個新的函式,執行時的上下文環境為 .bind(thisArg) 傳遞的第一個引數,它允許建立預先設定好 this 的函式。

fun.bind(thisArg[, arg1[, arg2[, ...]]])
複製程式碼

thisArg

呼叫繫結函式時作為 this 引數傳遞給目標函式的值。

如果使用new運算子構造繫結函式,提供的 this 值會被忽略,但前置引數仍會提供給模擬函式。

arg1, arg2, ...

當目標函式被呼叫時,被預置入繫結函式的引數列表中的引數。

對比方法 .apply() 和 .call(),它倆都立即執行了函式,而 .bind() 函式返回了一個新方法,繫結了預先指定好的 this ,並可以延後呼叫。

var students = {  
  arr: ['Eason', 'Jay', 'Mayday'],
  getStudents: function() {
    return this.arr;    
  }
};
// 建立一個繫結函式
var boundGetStudents = students.getStudents.bind(students);  
boundGetStudents(); // => ['Eason', 'Jay', 'Mayday'] 
複製程式碼

.bind() 建立了一個永久的上下文環境並不可修改。繫結函式即使使用 .call() 或者 .apply()重新傳入其他不同的上下文環境,也不會更改它之前繫結的上下文環境,不會起任何作用。只有在構造器呼叫時,繫結函式可以改變上下文。

function getThis() {
  'use strict';
  return this;
}
var one = getThis.bind(1);  
// 繫結函式呼叫
one(); // => 1  
// 使用 .apply() 和 .call() 繫結函式
one.call(2);  // => 1  
one.apply(2); // => 1  
// 重新繫結
one.bind(2)(); // => 1  
// 利用構造器方式呼叫繫結函式
new one(); // => Object  
// ES6提供了一種新的例項化方式
Reflect.construct(one,[]);   // => Object
複製程式碼

總結:this 繫結的優先順序

「new 繫結(建構函式)」 > 「顯式繫結(bind)」 > 「隱式繫結(方法呼叫)」 > 「預設繫結(函式呼叫)」

6.箭頭函式中的 this

函式體內的 this 物件,就是定義時所在的物件,而不是使用時所在的物件。

function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}
var id = 21;
foo.call({ id: 42 }); // id: 42
複製程式碼

箭頭函式可以讓 this 指向固定化,這種特性很有利於封裝回撥函式。

this 指向的固定化,並不是因為箭頭函式內部有繫結 this 的機制,實際原因是箭頭函式根本沒有自己的 this,導致內部的 this 就是外層程式碼塊的 this 。正是因為它沒有 this,所以也就不能用作建構函式。

另外,由於箭頭函式沒有自己的this,所以當然也就不能用 call()、apply()、bind() 這些方法去改變 this 的指向。

易錯:物件方法定義

const cat = {
  lives: 9,
  jumps: () => {
    this.lives--; // 此 this 指向全域性物件
  }
}
複製程式碼

因為物件不構成單獨的作用域,導致 jumps 箭頭函式定義時的作用域就是全域性作用域。

易錯:事件回撥的動態 this

var button = document.getElementById('press');
button.addEventListener('click', () => {
  this.classList.toggle('on');
});
複製程式碼

上面程式碼執行時,點選按鈕會報錯,因為 button 的監聽函式是一個箭頭函式,導致裡面的 this就是全域性物件。如果改成普通函式,this 就會動態指向被點選的按鈕物件。

eval 執行上下文

1.直接呼叫

this 指向呼叫 eval 的那個執行上下文。

eval("console.log(this);"); // window
var obj = {
  method: function () {
    eval('console.log(this === obj)'); // true
  }
}
obj.method(); 
複製程式碼

2.間接呼叫

不管在哪呼叫,則其執行上下文都是複製全域性執行上下文,所以 this 都是全域性物件。

var evalcopy = eval;
evalcopy("console.log(this);"); // window
var obj = {
  method: function () {
    evalcopy('console.log(this)'); //window
  }
}
obj.method();
複製程式碼

什麼是this?

this 不是編寫時繫結,而是執行時繫結。它依賴於函式呼叫的上下文條件。this 繫結和函式宣告的位置無關,而和函式被呼叫的方式有關。

當一個函式被呼叫時,會建立一個活動記錄,也稱為執行環境。這個記錄包含函式是從何處(call-stack)被呼叫的,函式是 如何 被呼叫的,被傳遞了什麼引數等資訊。this 就是這個記錄的一個屬性,會在函式執行的過程中用到。

補充

零散的知識碎片:

還有些Api也支援繫結this:

Array.prototype.every( callbackfn [ , thisArg ] )
Array.prototype.some( callbackfn [ , thisArg ] )
Array.prototype.forEach( callbackfn [ , thisArg ] )
Array.prototype.map( callbackfn [ , thisArg ] )
Array.prototype.filter( callbackfn [ , thisArg ] )
複製程式碼
  • Array.from() 用於將兩類物件轉為真正的陣列:類似陣列的物件(array-like object)和可遍歷(iterable)的物件。它的第二個引數類似 map 方法,用來對每個元素進行處理,將處理後的值放入返回的陣列。它的第三個引數可以繫結 map 方法中的 this。

  • 陣列例項的 find() 和 findIndex() 支援第二個引數用以繫結第一個函式引數裡的 this。

  • flatMap() 方法對原陣列的每個成員執行一個函式(相當於執行 Array.prototype.map()),然後對返回值組成的陣列執行 flat() 方法。該方法返回一個新陣列,不改變原陣列。flatMap() 方法還可以有第二個引數,用來繫結遍歷函式裡面的this。

Reflect 中的 this

  • Reflect.apply 方法等同於 Function.prototype.apply.call(func, thisArg, args),用於繫結 this 物件後執行給定函式。 一般來說,如果要繫結一個函式的 this 物件,可以這樣寫 fn.apply(obj, args),但是如果函式定義了自己的 apply 方法,就只能寫成 Function.prototype.apply.call(fn, obj, args),採用 Reflect 物件可以簡化這種操作。

  • Reflect.get(target, name, receiver) 和 Reflect.set(target, name, value, receiver),如果 name 屬性部署了讀取/賦值函式(getter/setter),則讀取函式的 this 繫結 receiver。

var myObject = {
  foo: 1,
  bar: 2,
  get baz() {
    return this.foo + this.bar;
  },
};
var myReceiverObject = {
  foo: 4,
  bar: 4,
};
Reflect.get(myObject, 'baz', myReceiverObject) // 8
複製程式碼

Proxy

  • apply方法可以接受三個引數,分別是目標物件、目標物件的上下文物件(this)和目標物件的引數陣列。
var handler = {
  apply (target, ctx, args) {
    return Reflect.apply(...arguments);
  }
};
複製程式碼
  • Proxy 的 this 問題

Proxy 代理的情況下,目標物件內部的 this 關鍵字會指向 Proxy 代理。

const target = {
  m: function () {
    console.log(this === proxy);
  }
};
const handler = {};
const proxy = new Proxy(target, handler);
target.m() // false
proxy.m()  // true
複製程式碼

上面程式碼中,一旦 proxy 代理 target.m,後者內部的 this 就是指向 proxy,而不是 target。

ES6 外部的模組指令碼中的 this

  • 模組之中,頂層的 this 關鍵字返回 undefined,而不是指向 window。也就是說,在模組頂層使用 this 關鍵字,是無意義的。利用頂層的 this 等於 undefined 這個語法點,可以偵測當前程式碼是否在 ES6 模組之中。
<script type="module" src="./foo.js"></script>
複製程式碼
  • ES6 模組應該是通用的,同一個模組不用修改,就可以用在瀏覽器環境和伺服器環境。為了達到這個目標,Node 規定 ES6 模組之中不能使用 CommonJS 模組的特有的一些內部變數。 首先,就是this關鍵字。ES6 模組之中,頂層的 this 指向 undefined;CommonJS 模組的頂層 this 指向當前模組,這是兩者的一個重大差異。

super 關鍵字

ES6 又新增了一個關鍵字 super,指向當前物件的原型物件。super 關鍵字表示原型物件時,只能用在物件的方法之中,用在其他地方都會報錯。目前,只有物件方法的簡寫法可以讓 JavaScript 引擎確認,定義的是物件的方法。

const proto = {
  x: 'hello',
  foo() {
    console.log(this.x);
  },
};
const obj = {
  x: 'world',
  foo() {
    super.foo();
  }
}
Object.setPrototypeOf(obj, proto);
obj.foo() // "world"
複製程式碼

上面程式碼中,super.foo 指向原型物件 proto 的 foo 方法,但是繫結的 this 卻還是當前物件 obj,因此輸出的就是 world。

Class 中的 this

  • 一個類必須有 constructor 方法,如果沒有顯式定義,一個空的 constructor 方法會被預設新增。constructor 方法預設返回例項物件(即 this ),完全可以指定返回另外一個物件。
  • 與 ES5 一樣,例項的屬性除非顯式定義在其本身(即定義在 this 物件上),否則都是定義在原型上。
  • 類的方法內部如果含有 this ,它預設指向類的例項。如果將這個方法提取出來單獨使用,this 會指向該方法執行時所在的環境(由於 class 內部是嚴格模式,所以 this 實際指向的是 undefined )
  • 靜態方法包含 this 關鍵字,這個 this 指的是類,而不是例項。
  • 類的繼承:
  1. 子類必須在 constructor 方法中呼叫 super 方法,否則新建例項時會報錯。這是因為子類自己的 this 物件,必須先通過父類的建構函式完成塑造,得到與父類同樣的例項屬性和方法,然後再對其進行加工,加上子類自己的例項屬性和方法。如果不呼叫 super 方法,子類就得不到 this 物件。

  2. ES5 的繼承,實質是先創造子類的例項物件 this ,然後再將父類的方法新增到 this 上面(Parent.apply(this))。ES6 的繼承機制完全不同,實質是先將父類例項物件的屬性和方法,加到 this 上面(所以必須先呼叫 super 方法),然後再用子類的建構函式修改 this 。

  3. 在子類的建構函式中,只有呼叫 super 之後,才可以使用 this 關鍵字,否則會報錯。這是因為子類例項的構建,基於父類例項,只有 super 方法才能呼叫父類例項。

  4. ES6 規定,在子類普通方法中通過super呼叫父類的方法時,方法內部的this指向當前的子類例項。由於this指向子類例項,所以如果通過super對某個屬性賦值,這時super就是this,賦值的屬性會變成子類例項的屬性。

class A {
  constructor() {
    this.x = 1;
  }
}
class B extends A {
  constructor() {
    super();
    this.x = 2;
    super.x = 3;
    console.log(super.x); // undefined
    console.log(this.x); // 3
  }
}
let b = new B();
複製程式碼

上面程式碼中,super.x賦值為3,這時等同於對this.x賦值為3。而當讀取super.x的時候,讀的是A.prototype.x,所以返回undefined。

  1. 在子類的靜態方法中通過super呼叫父類的方法時,方法內部的this指向當前的子類,而不是子類的例項。
class A {
  constructor() {
    this.x = 1;
  }
  static print() {
    console.log(this.x);
  }
}
class B extends A {
  constructor() {
    super();
    this.x = 2;
  }
  static m() {
    super.print();
  }
}
B.x = 3;
B.m() // 3
複製程式碼

結語

文中若有錯誤或者補充,還請不吝賜教。

參考文獻

相關文章