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

本文分享自華爲雲社區《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機制一樣。

 

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章