無人不識又無人不迷糊的this

华为云开发者联盟發表於2024-03-26

本文分享自華為雲社群《3月閱讀周·你不知道的JavaScript | 無人不識又無人不迷糊的this》,作者: 葉一一。

關於this

this關鍵字是JavaScript中最複雜的機制之一。它是一個很特別的關鍵字,被自動定義在所有函式的作用域中。

為什麼要用this

隨著開發者的使用模式越來越複雜,顯式傳遞上下文物件會讓程式碼變得越來越混亂,使用this則不會這樣。

比如下面的例子:

function identify() {
  return this.name.toUpperCase();
}

function speak() {
  var greeting = "Hello, I'm " + identify.call(this);
  console.log(greeting);
}

var me = {
  name: 'Kyle',
};

var you = {
  name: 'Reader',
};

console.log(identify.call(me)); 
console.log(identify.call(you)); 

speak.call(me); 
speak.call(you); 

列印一下結果:

2-1.png

上面的程式碼可以在不同的上下文物件(me和you)中重複使用函式identify()和speak(),不用針對每個物件編寫不同版本的函式。如果不使用this,那就需要給identify()和speak()顯式傳入一個上下文物件。

誤解

有兩種常見的對於this的解釋,但是它們都是錯誤的。

1、指向自身

人們很容易把this理解成指向函式自身。

那麼為什麼需要從函式內部引用函式自身呢?常見的原因是遞迴(從函式內部呼叫這個函式)或者可以寫一個在第一次被呼叫後自己解除繫結的事件處理器。

看下面這段程式碼,思考foo會被呼叫了多少次?

function foo(num) {
  console.log('foo: ' + num);

  // 記錄foo被呼叫的次數
  this.count++;
}

foo.count = 0;

var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i);
  }
}

// foo被呼叫了多少次?
console.log(foo.count);

列印結果:

2-2.png

console.log語句產生了4條輸出,證明foo(..)確實被呼叫了4次,但是foo.count仍然是0。顯然從字面意思來理解this是錯誤的。

執行foo.count = 0時,的確向函式物件foo新增了一個屬性count。但是函式內部程式碼this.count中的this並不是指向那個函式物件。

2、它的作用域

第二種常見的誤解是,this指向函式的作用域。這個問題有點複雜,因為在某種情況下它是正確的,但是在其他情況下它卻是錯誤的。

this在任何情況下都不指向函式的詞法作用域。

function foo() {
  var a = 2;
  this.bar();
}

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

foo();

直接列印上面的程式碼會得到一個報錯:

2-3.png

這段程式碼試圖透過this.bar()來引用bar()函式。這是不可能實現的,使用this不可能在詞法作用域中查到什麼。

每當開發者想要把this和詞法作用域的查詢混合使用時,一定要提醒自己,這是無法實現的。

this到底是什麼

this的繫結和函式宣告的位置沒有任何關係,只取決於函式的呼叫方式。

當一個函式被呼叫時,會建立一個活動記錄(有時候也稱為執行上下文)。這個記錄會包含函式在哪裡被呼叫(呼叫棧)、函式的呼叫方式、傳入的引數等資訊。this就是這個記錄的一個屬性,會在函式執行的過程中用到。

this全面解析

呼叫位置

在理解this的繫結過程之前,首先要理解呼叫位置:呼叫位置就是函式在程式碼中被呼叫的位置(而不是宣告的位置)。

尋找呼叫位置就是尋找“函式被呼叫的位置”。最重要的是要分析呼叫棧(就是為了到達當前執行位置所呼叫的所有函式)。呼叫位置就在當前正在執行的函式的前一個呼叫中。

透過下面的程式碼來看什麼是呼叫棧和呼叫位置:

function baz() {
  // 當前呼叫棧是:baz
  // 因此,當前呼叫位置是全域性作用域

  console.log('baz');
  bar(); // <-- bar的呼叫位置
}

function bar() {
  // 當前呼叫棧是baz -> bar
  // 因此,當前呼叫位置在baz中

  console.log('bar');
  foo(); // <-- foo的呼叫位置
}

function foo() {
  // 當前呼叫棧是baz -> bar -> foo
  // 因此,當前呼叫位置在bar中

  console.log('foo');
}

baz(); // <-- baz的呼叫位置

列印的結果如下:

2-4.png

繫結規則

來看看在函式的執行過程中呼叫位置如何決定this的繫結物件。

首先必須找到呼叫位置,然後判斷需要應用下面四條規則中的哪一條。

充分理解四條規則之後,再理解多條規則都可用時它們的優先順序如何排列。

1、預設繫結

首先要介紹的是最常用的函式呼叫型別:獨立函式呼叫。可以把這條規則看作是無法應用其他規則時的預設規則。

var a = 2;

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

foo(); // 2

列印結果是2。也就是當呼叫foo()時,this.a被解析成了全域性變數a。函式呼叫時應用了this的預設繫結,因此this指向全域性物件。

2、隱式繫結

另一條需要考慮的規則是呼叫位置是否有上下文物件,或者說是否被某個物件擁有或者包含,不過這種說法可能會造成一些誤導。

思考下面的程式碼:

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

var obj = {
  a: 2,
  foo: foo,
};

obj.foo(); // 2

當foo()被呼叫時,它的前面確實加上了對obj的引用。當函式引用有上下文物件時,隱式繫結規則會把函式呼叫中的this繫結到這個上下文物件。因為呼叫foo()時this被繫結到obj,因此this.a和obj.a是一樣的。

3、顯式繫結

JavaScript提供的絕大多數函式以及你自己建立的所有函式都可以使用call(..)和apply(..)方法。

它們的第一個引數是一個物件,是給this準備的,接著在呼叫函式時將其繫結到this。

因為可以直接指定this的繫結物件,因此我們稱之為顯式繫結。

思考下面的程式碼:

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

var obj = {
  a: 2,
};

foo.call(obj); // 2

透過foo.call(..),我們可以在呼叫foo時強制把它的this繫結到obj上。

4、new繫結

在傳統的面向類的語言中,“建構函式”是類中的一些特殊方法,使用new初始化類時會呼叫類中的建構函式。通常的形式是這樣的:

something = new MyClass(..);

在JavaScript中,建構函式只是一些使用new運算子時被呼叫的函式。它們並不會屬於某個類,也不會例項化一個類。實際上,它們甚至都不能說是一種特殊的函式型別,它們只是被new運算子呼叫的普通函式而已。

優先順序

1、四條規則的優先順序

new繫結 > 顯式繫結 > 隱式繫結 > 預設繫結。

2、判斷this

可以根據優先順序來判斷函式在某個呼叫位置應用的是哪條規則。可以按照下面的順序來進行判斷:

(1)函式是否在new中呼叫(new繫結)?如果是的話this繫結的是新建立的物件。

var bar = new foo();

(2)函式是否透過call、apply(顯式繫結)或者硬繫結呼叫?如果是的話,this繫結的是指定的物件。

var bar = foo.call(obj2);

(3)函式是否在某個上下文物件中呼叫(隱式繫結)?如果是的話,this繫結的是那個上下文物件。

var bar = obj1.foo();

(4)如果都不是的話,使用預設繫結。如果在嚴格模式下,就繫結到undefined,否則繫結到全域性物件。

var bar = foo();

繫結例外

在某些場景下this的繫結行為會出乎意料,你認為應當應用其他繫結規則時,實際上應用的可能是預設繫結規則。

被忽略的this

如果你把null或者undefined作為this的繫結物件傳入call、apply或者bind,這些值在呼叫時會被忽略,實際應用的是預設繫結規則:

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

var a = 2;

foo.call(null); // 2

那麼什麼情況下會傳入null呢?

一種非常常見的做法是使用apply(..)來“展開”一個陣列,並當作引數傳入一個函式。類似地,bind(..)可以對引數進行柯里化(預先設定一些引數),這種方法有時非常有用。

間接引用

另一個需要注意的是,你有可能(有意或者無意地)建立一個函式的“間接引用”,在這種情況下,呼叫這個函式會應用預設繫結規則。

間接引用最容易在賦值時發生:

function foo() {
  console.log(this.a);
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };

o.foo(); // 3
(p.foo = o.foo)(); // 2

賦值表示式p.foo = o.foo的返回值是目標函式的引用,因此呼叫位置是foo()而不是p.foo()或者o.foo()。根據我們之前說過的,這裡會應用預設繫結。

軟繫結

如果可以給預設繫結指定一個全域性物件和undefined以外的值,那就可以實現和硬繫結相同的效果,同時保留隱式繫結或者顯式繫結修改this的能力。

function foo() {
  console.log('name: ' + this.name);
}

var obj = { name: 'obj' },
  obj2 = { name: 'obj2' },
  obj3 = { name: 'obj3' };

var fooOBJ = foo.softBind(obj);

fooOBJ(); // name: obj

obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2 <---- 看!! !

fooOBJ.call(obj3); // name: obj3 <---- 看!

setTimeout(obj2.foo, 10);
// name: obj   <---- 應用了軟繫結

可以看到,軟繫結版本的foo()可以手動將this繫結到obj2或者obj3上,但如果應用預設繫結,則會將this繫結到obj。

this詞法

ES6中介紹了一種無法使用這些規則的特殊函式型別:箭頭函式。箭頭函式並不是使用function關鍵字定義的,而是使用被稱為“胖箭頭”的運算子=>定義的。箭頭函式不使用this的四種標準規則,而是根據外層(函式或者全域性)作用域來決定this。

箭頭函式的詞法作用域:

function foo() {
  // 返回一個箭頭函式
  return a => {
    //this繼承自foo()
    console.log(this.a);
  };
}

var obj1 = {
  a: 2,
};

var obj2 = {
  a: 3,
};

var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是3!

foo()內部建立的箭頭函式會捕獲呼叫時foo()的this。由於foo()的this繫結到obj1,bar(引用箭頭函式)的this也會繫結到obj1,箭頭函式的繫結無法被修改。

總結

我們來總結一下本篇的主要內容:

  • this實際上是在函式被呼叫時發生的繫結,它指向什麼完全取決於函式在哪裡被呼叫。
  • 如果要判斷一個執行中函式的this繫結,就需要找到這個函式的直接呼叫位置。找到之後就可以順序應用下面這四條規則來判斷this的繫結物件。
  • ES6中的箭頭函式並不會使用四條標準的繫結規則,而是根據當前的詞法作用域來決定this,具體來說,箭頭函式會繼承外層函式呼叫的this繫結(無論this繫結到什麼)。這其實和ES6之前程式碼中的self = this機制一樣。

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章