從ECMAScript規範深度分析JavaScript(四):This

本文譯自Dmitry Soshnikov的《ECMA-262-3 in detail》系列教程。其中會加入一些個人見解以及配圖舉例等等,來幫助讀者更好的理解JavaScript。

前言

一句強調的話:我們不僅要知其然,還要知其所以然。
如果有人問我們JavaScript中不同情況下的this值是什麼,我們可能會很容易說出來,但是有沒有想過爲什麼this的值是那樣?並且其中有哪些我們不知道的細節轉換?本文帶你一起深度分析this指向,搞懂本文,再也不會搞錯關於this值的問題。

在本章中我們討論關於執行期上下文相關的更多細節,這章的主題是this關鍵字。這個主題很難,並且經常在不同的執行期上下文中導致確定this值的問題。許多程序員習慣於認爲編程語言中的this關鍵字與面向對象密切相關,其準確指向構造函數新創建的對象。在ECMAScript規範中,這個概念也是這樣實現的,但是正如我們看到的那樣,它不僅僅限於創建對象的定義。

讓我們來詳細瞭解一下,在ECMAScript中,this值到底是什麼。

閱讀本文前需要對執行期上下文有所瞭解,可以看我的前兩篇文章《從ECMAScript規範深度分析JavaScript(一):執行期上下文》《從ECMAScript規範深度分析JavaScript(二):變量對象(上)》來進行深入理解

定義

this是執行期上下文的屬性,他是代碼執行時上下文的特殊對象。

activeExecutionContext = {
  VO: {...},
  this: thisValue
};

VO(variable object)是我們在上兩章中討論的變量對象。

this和上下文的可執行代碼的類型直接相關,值在進入上下文期間確定,並且在上下文運行代碼期間不可改變。接下來讓我們來詳細討論這些細節

全局代碼中的this值

在這種情況下是非常簡單的。在全局代碼中,this值總是全局對象本身,因此可以間接引用它:

// 全局對象的顯示屬性定義
this.a = 10; // global.a = 10
console.log(a); // 10
 
// 通過賦值給非限定標誌符隱式定義
b = 20;
console.log(this.b); // 20
 
// 通過變量聲明隱式定義
// 因爲全局上下文的變量對象就是全局對象本身
var c = 30;
console.log(this.c); // 30

函數代碼中的this值

當在函數代碼中使用this值的時候,就會變得有趣起來,這種中情況是最困難的,並且會導致許多問題。

在這種類型的代碼中,this值的第一個特性就是它不是靜態綁定到函數的。

正如上面提到的那樣,this值在進入上下文時確定的,也就是說,對於函數代碼,this值每次都可能完全不同。然而,在代碼運行時this值是不可改變的,也就是說,不能給他賦一個新值,因爲他不是一個變量(相反,對於python編程語言,它顯示定義的self對象可以在運行時被反覆修改):

var foo = {x: 10};
 
var bar = {
  x: 20,
  test: function () {
 
    console.log(this === bar); // true
    console.log(this.x); // 20
     
    this = foo; // 錯誤,沒法改變this的值
  
    console.log(this.x); // 如果沒有報錯,這裏將會是10,而不是20
 
  }
 
};
 
// 在進入上下文時,this值被定義爲bar; 我們下面會詳細討論爲什麼
 
bar.test(); // true, 20
 
foo.test = bar.test;
 
// 但是這裏的this值指向foo,即使我們調用了相同的函數
 
foo.test(); // false, 10

那麼是什麼影響了函數代碼中this值的變化呢?有幾個因素。

首先,在通常的函數調用中,它是由激活上下文的代碼的調用者提供的,即調用函數的父上下文。並且this值由調用表達式的形式決定(換句話說,由調用函數的語法形式決定)。

爲了能夠在任何上下文中毫無問題地確定this值,理解並記住這一點是非常重要的。確切地說:

調用表達式的形式,即調用函數的形式,影響了調用上下文的this值,沒有其他什麼了。

就像我們甚至可以看到一些文章和書籍的JavaScript,聲稱“這個值取決於函數的定義:如果是全局函數那麼this值被設置爲全局對象,如果函數是對象的一個方法this值總是被設置爲這個對象”——這是錯誤的描述!!!

接下來,我們看到即使是正常的全局函數也可以被不同形式的調用表達式激活,這些調用表達式會產生不同的this值:

function foo() {
  console.log(this);
}
 
foo(); // global
 
console.log(foo === foo.prototype.constructor); // true
 
// 但是通過這個函數的另外一種調用表達式的形式,this值是不同的

foo.prototype.constructor(); // foo.prototype

類似地,也可以調用定義爲某個對象的方法的函數,但是this值不會被設置爲這個對象:

var foo = {
  bar: function () {
    console.log(this);
    console.log(this === foo);
  }
};
 
foo.bar(); // foo, true
 
var exampleFunc = foo.bar;
 
console.log(exampleFunc === foo.bar); // true
 
// 再次通過這個函數的另外一種調用表達式的形式,this值是不同的
 
exampleFunc(); // global, false

所以,調用表達式的形式是如何影響this值的呢?爲了完全理解this值的定義,有必要詳細考慮一種內部類型——Reference類型。

Reference類型

使用僞代碼可以將Reference類型表示爲一個擁有兩個屬性的對象:base(擁有屬性的那個對象)和這個base中propertyName:

var valueOfReferenceType = {
  base: <base object>,
  propertyName: <property name>
};

注意:從ES5,Reference也包含一個名爲strict的屬性——這是一個reference是否在strict模式中處理的標誌:

'use strict';
 
// Access foo.
foo;
 
// Reference for `foo`.
const fooReference = {
  base: global,
  propertyName: 'foo',
  strict: true,
};

Reference類型的值只有兩種情況:

  • 當我們處理一個標誌符時
  • 使用屬性訪問器時

標誌符是由標誌符解析過程來處理的,之後會寫一篇作用域鏈中詳細介紹。這裏我們只關注這個算法總是返回一個Reference類型的值(這對於this值很重要)。

標誌符是變量名,函數名,函數參數名,和全局對象的非限定屬性名。例如,對於以下標誌符的值:

var foo = 10;
function bar() {}

在操作的中間結果中,對應的引用類型值如下:

var fooReference = {
  base: global,
  propertyName: 'foo'
};
 
var barReference = {
  base: global,
  propertyName: 'bar'
};

對於從Reference類型的值中獲取對象的真實值,有GetValue方法,其僞代碼描述如下:

function GetValue(value) {
 
  if (Type(value) != Reference) {
    return value;
  }
  var base = GetBase(value);
  
  if (base === null) {
    throw new ReferenceError;
  }
  return base.[[Get]](GetPropertyName(value));
}

爲了從Reference類型中得到一個對象真正的值,在僞代碼中可以使用GetValue方法來表示,如下:

function GetValue(value) {
 
  if (Type(value) != Reference) {
    return value;
  }
  var base = GetBase(value);
 
  if (base === null) {
    throw new ReferenceError;
  }
  return base.[[Get]](GetPropertyName(value));
 
}

內部[[Get]]方法返回對象屬性的真實值,包括從原型鏈繼承來的屬性的分析:

GetValue(fooReference); // 10
GetValue(barReference); // function object "bar"

屬性訪問器都應該熟悉。它有兩種變體:點(.)語法(此時屬性名是正確的標示符,且事先知道),或括號語法([])

foo.bar();
foo['bar']();

在中間計算的返回中,我們獲取到了Reference類型的值:

var fooBarReference = {
  base: foo,
  propertyName: 'bar'
};
 
GetValue(fooBarReference); // function object "bar"

那麼,從最重要的意義上講,Reference類型的值如何與函數上下文的this值相關的呢?接下來是本文的主要內容!!!核心內容。在函數上下文中確定this值的通用規則如下:

  1. 函數上下文中的this值由調用者提供,並由調用表達式(函數調用的語法編寫方式)的當前形式決定。
  2. 如果在調用括號(…)的左邊,有一個Reference類型的值,然後將該值設置爲該Reference類型值的base對象。
  3. 在所有其他情況下(即不同於Reference類型的任何其他值類型),該值總是設置爲null。但是由於null對於this值沒有任何意義,所以它被隱式地轉換爲全局對象。

舉個例子:

function foo() {
  return this;
}
 
foo(); // global

我們看到在括號的左邊是一個Reference類型值(因爲foo是一個標誌符)

var fooReference = {
  base: global,
  propertyName: 'foo'
};

相應的,this值被設置爲這個Reference類型值的base對象,即全局對象。
類似的,使用屬性訪問器:

var foo = {
  bar: function () {
    return this;
  }
};
 
foo.bar(); // foo

同樣,我們擁有一個Reference類型的值,其base是foo對象,在函數bar激活時將base設置給this:

var fooBarReference = {
  base: foo,
  propertyName: 'bar'
};

但是,使用另外一種調用表達式激活相同的函數,我們會得到其他的this值:

var test = foo.bar;
test(); // global

因爲test稱爲了標誌符,產生了Reference類型的其他值,這時base(此時是全局對象)被作爲this值使用

var testReference = {
  base: global,
  propertyName: 'test'
};

注意:在ES5的嚴格模式中,this值不強制賦值爲全局對象,而是被設置爲undefined。(我們會在嚴格模式中討論這種情況)

現在我們可以準確地說,爲什麼同一個函數被不同形式的調用表達式激活,它的this值也不同——答案就在不同Reference類型的中間值中:

function foo() {
  console.log(this);
}
 
foo(); // global, because
 
var fooReference = {
  base: global,
  propertyName: 'foo'
};
 
console.log(foo === foo.prototype.constructor); // true
 
// another form of the call expression
 
foo.prototype.constructor(); // foo.prototype, because
 
var fooPrototypeConstructorReference = {
  base: foo.prototype,
  propertyName: 'constructor'
};

另外一個動態決定通過調用表達式形式的經典例子:

function foo() {
  console.log(this.bar);
}
 
var x = {bar: 10};
var y = {bar: 20};
 
x.test = foo;
y.test = foo;
 
x.test(); // 10
y.test(); // 20

函數調用和非Reference類型

那麼,像我們所說,當調用括號的左邊不是Reference類型值而是其他類型的時候,this值將自動設置爲null,最終被轉化爲全局對象。
來看一下這個表達式例子:

(function () {
  console.log(this); // null => global
})();

在這種情況下,我們有一個函數對象,但不是Reference對象(不是標誌符,也不是屬性訪問器),相應的,this值最終被設爲全局對象。

更加複雜的一個例子:

var foo = {
  bar: function () {
    console.log(this);
  }
};
 
foo.bar(); // Reference, OK => foo
(foo.bar)(); // Reference, OK => foo
 
(foo.bar = foo.bar)(); // global?
(false || foo.bar)(); // global?
(foo.bar, foo.bar)(); // global?

那麼,爲什麼有了一個屬性存取器,中間結果應該是Reference類型的值,而事實上調用我們麼獲取到的this值不是base對象(foo),而是全局對象?是我們的規則出問題了嗎?顯然不是,是因爲後三個調用,在使用一些操作後,調用括號左邊的值不再是引用類型了。

第一個例子很明顯——明顯的引用類型,結果是,this爲base對象,即foo。

在第二個例子中,分組操作符(這裏的分組操作符就是指foo.bar外面的括號"()")沒有調用從引用類型中獲得一個對象真正的值的方法,即GetValue,回顧一下。相應的,在分組操作的返回值中———我們得到的仍是一個引用類型。這就是this的值爲什麼再次被設爲base對象,即 foo。

第三個例子中,與分組操作符不同,賦值操作符調用了GetValue方法。返回的結果已經是函數對象(不是Reference類型),這意味着this的值被設爲null,實際最終結果是被設置爲全局對象。

第四個和第五個也是一樣——逗號操作符和邏輯操作符(OR)調用了GetValue 方法,相應地,我們失去了Reference類型的值而得到了函數類型的值,所以this的值再次被設爲global對象。

引用類型與this值爲null

有一種調用括號左邊的調用表達式確定了是Reference類型的值,然而this值被設置爲null,並且最終被轉換爲全局對象,當Reference類型值的base對象是激活對象時會出現這種情況。

我們通過一個從父函數來調用內部函數的例子來看一下這種情況,我們在變量對象一節中知道,本地變量,內部函數和形參都是存儲在給定函數的激活對象中:

function foo() {
  function bar() {
    console.log(this); // global
  }
  bar(); // the same as AO.bar()
}

激活對象總是返回this值爲bull(用僞代碼表示AO.bar() 等同於null.bar())。然後就是如上所講的那樣,this值再一次被設置爲全局對象。

當是with語句塊且with對象包含函數名屬性中的函數調用時情況會有所不同。with語句將其對象添加到作用域鏈(後續會有作用域鏈的篇章來講解作用域鏈)的前面,即在激活對象之前。因此,如果有類型Reference的值(通過標識符或屬性訪問器),那麼base對象就不是一個激活對象,而是with語句的對象。順便說一下,它不僅在內部函數是這樣,在全局函數中也是如此,因爲with對象會覆蓋(指在作用域的前面)作用域鏈中更高的對象(全局對象或激活對象):

var x = 10;
 
with ({
 
  foo: function () {
    console.log(this.x);
  },
  x: 20
 
}) {
 
  foo(); // 20
 
}
 
// because
 
var  fooReference = {
  base: __withObject,
  propertyName: 'foo'
};

類似的情況應該是調用catch子句的實際參數的函數調用:在這種情況下,catch對象也被添加到作用域鏈的前面,即在激活或全局對象之前。然而,給定的行爲被認爲是ECMA-262-3的一個bug,並在新版本的ECMA-262-5標準中得到了修復,即給定激活中的這個值應該設置爲全局對象,而不是catch對象:

try {
  throw function () {
    console.log(this);
  };
} catch (e) {
  e(); // __catchObject - in ES3, global - fixed in ES5
}
 
// on idea
 
var eReference = {
  base: __catchObject,
  propertyName: 'e'
};
 
// but, as this is a bug
// then this value is forced to global
// null => global
 
var eReference = {
  base: global,
  propertyName: 'e'
};

下面這種情況Dmitry Soshnikov將其單獨列出來,我個人認爲這種方式和內部函數的調用方式是相同的(即父激活對象),而Dmitry Soshnikov認爲這時Reference類型的base對象是有區別的,有不同理解的朋友可以留言進行討論:

遞歸調用命名函數表達式的情況也是如此(後面會有專門出講述關於函數的篇章)。在函數的第一次調用時,base對象是父激活對象(或全局對象),在遞歸調用時base對象應該是存儲函數表達式的可選名稱的特殊對象。然而,在這種情況下,this值也總是設置爲全局的:

(function foo(bar) {
 
  console.log(this);
 
  !bar && foo(1); // "should" be special object, but always (correct) global
 
})(); // global

作爲構造器調用的函數中的this值

在函數上下文中還有一種與this值相關的情況——它是函數作爲構造函數的調用:

function A() {
  console.log(this); // newly created object, below - "a" object
  this.x = 10;
}
 
var a = new A();
console.log(a.x); // 10

在這種情況下,new操作符調用函數A的內部[[Construct]]方法,而函數A在創建對象之後,又調用內部[[Call]]方法,將新創建的對象作爲this值提供。

手動設置一個函數調用的this值

在Function.prototype上定義了兩種方法(因此這是所有函數都具有的),允許手動定義函數調用的this值,就是apply和call方法。
它們都接受第一個參數作爲調用上下文中this值的使用。這些方法之間的區別是不重要的:對於apply,第二個參數必須是一個數組(或者類數組的對象,例如arguments),反過來,call方法可以接受任何參數;兩個方法的必傳的參數只是第一個參數——this值,比如:

var b = 10;
 
function a(c) {
  console.log(this.b);
  console.log(c);
}
 
a(20); // this === global, this.b == 10, c == 20

總結

在本文中我們詳細討論了不同情況下的this值的指向問題,理解本篇對於我們後面學習以及進行JavaScript編程具有很重要的意義。

希望此文能夠解決大家工作和學習中的一些疑問,避免不必要的時間浪費,有不嚴謹的地方,也請大家批評指正,共同進步!
轉載請註明出處,謝謝!

交流方式:QQ1670765991

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