深入理解JS:執行上下文中的this(一)

目錄

  • 執行上下文與執行上下文棧
  • this
    • 全局環境
    • 函數環境
  • 總結
  • 參考

1.執行上下文與執行上下文棧

(1)什麼是執行上下文?

在 JavaScript 程式碼運行時,解釋執行全局程式碼、調用函數或使用 eval 函數執行一個字元串表達式都會創建並進入一個新的執行環境,而這個執行環境被稱之為執行上下文。因此執行上下文有三類:全局執行上下文、函數執行上下文、eval 函數執行上下文。

執行上下文可以理解為一個抽象的對象,如下圖:

執行上下文抽象對象

Variable object:變數對象,用於存儲被定義在執行上下文中的變數 (variables) 和函數聲明 (function declarations) 。

Scope chain:作用域鏈,是一個對象列表 (list of objects) ,用以檢索上下文程式碼中出現的標識符 (identifiers) 。

thisValue:this 指針,是一個與執行上下文相關的特殊對象,也被稱之為上下文對象。

(2)什麼是執行上下文棧?

在全局程式碼中調用函數,或函數中調用函數(如遞歸)等,都會涉及到在一個執行上下文中創建另一個新的執行上下文,並且等待這個新的上下文執行完畢,才會返回之前的執行上下文接著繼續執行,而這樣的調用方式就形成了執行上下文棧

示例程式碼:

function A() {
  console.log('function A')
  B()
}

function B() {
  console.log('function B')
  C()
}

function C() {
  console.log('function C')
}

A()

上述示例程式碼,當執行到函數 C時,此時的執行上下文棧如下圖:

執行上下文棧

2.this

首先需要清楚,this 是執行上下文的一個屬性,而不是某個變數對象的屬性,是一個與執行上下文相關的特殊對象。由於在開發中不推薦或應盡量避免使用 eval 函數,所以在這裡我們主要討論全局執行上下文(全局環境)和函數執行上下文(函數環境)中的 this。

(1)全局環境

無論是否在嚴格模式下,在全局環境中(在任何函數體外部的程式碼),this 始終指向全局對象(在瀏覽器中即 window)。

示例程式碼(瀏覽器中):

console.log(this === window) // true

a = 1;

console.log(window.a) // 1
console.log(this.a === window.a) // true

this.b = "test"

console.log(window.b) // test
console.log(b) //test

(2)函數環境

在大多數情況下,函數的調用方式決定了 this 的值。 this 是不能夠在執行期間被賦值修改的,並且在每次函數被調用時其 this 可能不同(通過 apply 或 call 方法顯示設置 this 等)。

另外,ES5 引入了 bind 方法來設置函數的 this 值,而不用考慮函數如何被調用的。ES6 引入了支援 this 詞法解析的箭頭函數(它在閉合的執行環境內設置 this 的值)。

接下來我們主要分析:函數的調用方式是如何決定 this 的值?(對於 bind 方法以及箭頭函數將留於下一篇文章進行詳細分析)

要弄明白這個問題,我們來看看 EcmaScript 5.1標準的規定,了解一下 函數調用 的規範:

11.2.3 函數調用

產生式 CallExpression : MemberExpression Arguments 按照下面的過程執行 :

  1. 令 ref 為解釋執行 MemberExpression 的結果 .
  2. 令 func 為 GetValue(ref).
  3. 令 argList 為解釋執行 Arguments 的結果 , 產生參數值們的內部列表 (see 11.2.4).
  4. 如果 Type(func) is not Object ,拋出一個 TypeError 異常 .
  5. 如果 IsCallable(func) is false ,拋出一個 TypeError 異常 .
  6. 如果 Type(ref) 為 Reference,那麼 如果 IsPropertyReference(ref) 為 true,那麼 令 thisValue 為 GetBase(ref). 否則 , ref 的基值是一個環境記錄項 , 令 thisValue 為 GetBase(ref).ImplicitThisValue().
  7. 否則 , 假如 Type(ref) 不是 Reference. 令 thisValue 為 undefined.
  8. 返回調用 func 的 [[Call]] 內置方法的結果 , 傳入 thisValue 作為 this 值和列表 argList 作為參數列表

產生式 CallExpression : CallExpression Arguments以完全相同的方式執行,除了第1步執行的是其中的CallExpression。

簡單解析:

第1步,令 ref 為 MemberExpression 解釋執行的結果。

11.2 左值表達式 中有提到,MemberExpression 可以是以下五種表達式中的任意一種:

  • PrimaryExpression // 原始表達式
  • FunctionExpression // 函數定義表達式
  • MemberExpression [ Expression ] // 屬性訪問表達式
  • MemberExpression . IdentifierName // 屬性訪問表達式
  • new MemberExpression Arguments // 對象創建表達式

簡單理解 MemberExpression 就是調用一個函數的()左側的部分。

第2~5步,獲取調用函數的參數列表以及檢測所調用的函數是否合法,否則拋出相應異常。

第6、7步,就是決定函數調用的 this 的值的關鍵步驟,翻譯一下,如同下面的偽程式碼:

var thisValue = getThisValue(ref)

function getThisValue(ref) {
  // 判斷 ref 的類型是否是 Reference,如果不是,直接返回 undefined
  if(Type(ref) !== Reference) return undefined
  
  // 是否是 Object, Boolean, String, Number
  if(IsPropertyReference(ref)) {
    return GetBase(ref)
  } else {
    // 是一個環境記錄項(Environment record),調用其 ImplicitThisValue 方法
    return GetBase(ref).ImplicitThisValue()
  }
}

關於 GetBase 和 IsPropertyReference 方法:

  • GetBase(V), 返回引用值 V 的基值 (Reference 的基值 base,詳見下面提到的 Reference 的組成)。
  • HasPrimitiveBase(V), 如果基值是 Boolean, String, Number,那麼返回 true。
  • IsPropertyReference(V), 如果基值是個對象或 HasPrimitiveBase(V) 是 true,那麼返回 true;否則返回 false。

而對於 ImplicitThisValue 方法,其屬於環境記錄項(Environment record)的方法。而環境記錄項分為兩種:

  • 聲明式環境記錄項:每個聲明式環境記錄項都與一個包含變數和(或)函數聲明的 ECMA 腳本的程式作用域相關聯。聲明式環境記錄項用於綁定作用域內定義的一系列標識符。其 ImplicitThisValue 永遠返回 undefined。

  • 對象式環境記錄項:每一個對象式環境記錄項都有一個關聯的對象,這個對象被稱作 綁定對象 。對象式環境記錄項直接將一系列標識符與其綁定對象的屬性名稱建立一一對應關係。其 ImplicitThisValue 通常返回 undefined,除非其 provideThis 標識的值為 true。具體如下:

    1. 令 envRec 為函數調用時對應的聲明式環境記錄項。
    2. 如果 envRec 的 provideThis 標識的值為 true,返回 envRec 的綁定對象。
    3. 否則返回 undefined。

    對象式環境記錄項可以通過配置的方式,將其綁定對象合為函數調用時的隱式 this 對象的值。這一功能用於規範 With 表達式(12.10 章 )引入的綁定行為。該行為通過對象式環境記錄項中布爾類型的 provideThis 值控制,默認情況下,provideThis 的值為 false。(只有使用了 with 表達式,才會將 provideThis 標識的值為 true)

而上面提到了兩種新的類型: 引用規範類型 (Reference)與 環境記錄項(Environment record)都是屬於ECMAScript 的規範類型,相當於 meta-values,是用來用演算法描述 ECMAScript 語言結構和 ECMAScript 語言類型的。

而與規範類型相對於的就是語言類型:就是開發者直接使用的類型,即Undefined, Null, Boolean, String, Number, 和 Object。(ECMAScript的類型分為語言類型和規範類型)

從上面的偽程式碼中可以看到 thisValue 的值與 ref 是否是引用規範類型(Reference)有直接關聯,即調用一個函數時,其()左側的部分的解釋執行的結果的類型是不是 Reference 類型,將直接影響 thisValue 的值。

EcmaScript 5.1標準中的 Reference 的規範:

8.7 引用規範類型 (Reference)

Reference 類型是用來說明 delete,typeof,賦值運算符這些運算符的行為。

一個 Reference 是個已解決的命名綁定。其由三部分組成, 基值 (base) , 引用名稱(referenced name) 和布爾值 嚴格引用 (strict reference) 標誌。

基值是 undefined, Object, Boolean, String, Number, Environment record 中的任意一個。基值是 undefined 表示此引用可以不解決一個綁定。引用名稱是一個字元串。嚴格引用標誌表示是否在嚴格模式下解釋執行的程式碼。

而引用規範類型(Reference)會被用在標識符解析中,標識符執行的結果總是一個 Reference 類型的值。

EcmaScript 5.1標準中的 標識符解析 的規範:

10.3.1 標識符解析

標識符解析是指使用正在運行的執行環境中的詞法環境,通過一個 標識符 獲得其對應的綁定的過程。在 ECMA 腳本程式碼執行過程中,PrimaryExpression : Identifier 這一語法產生式將按以下演算法進行解釋執行:

  1. 令 env 為正在運行的執行環境的 詞法環境 。
  2. 如果正在解釋執行的語法產生式處在 嚴格模式下的程式碼 中,則僅 strict 的值為 true,否則令 strict 的值為 false。
  3. 以 env,Identifier 和 strict 為參數,調用 GetIdentifierReference 函數,並返回調用的結果。

解釋執行一個標識符得到的結果必定是 Reference 類型的對象,且其引用名屬性的值與 Identifier 字元串相等。

GetIdentifierReference 函數就是返回一個 Reference 類型的對象,類似如下對象:

var valueOfReferenceType = {
    base: <base object>, // Identifier 所處的環境(Environment Record)或者 Identifier 屬性所屬的對象
    propertyName: <property name>, // 與 Identifier 字元串相等
    strict: <boolean>
};

因此,我們可以來看一些相關的示例程式碼。

第一組:非嚴格模式和嚴格模式的全局函數

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

function bar() {
  'use strict'
  console.log(this)
}

foo() // global
bar() // undefined

// foo 標識符對應的 Reference
var fooReference = {
  base: EnvironmentRecord,
  propertyName: 'foo',
  strict: false
}

// bar 標識符對應的 Reference
var barReference = {
  base: EnvironmentRecord,
  propertyName: 'bar',
  strict: true
}

上述程式碼中,對於 fooReference,根據函數調用規範可知其 this = getThisValue(fooReference) = GetBase(fooReference).ImplicitThisValue() = undefined,而 barReference 也是一樣。

但為什麼 foo() 輸出的是 global 全局對象而不是 undefined 呢?這是因為在非嚴格模式下, 當 this 的值為 undefined 時,會被隱式轉換為全局對象。而在嚴格模式下,指定的 this 不再被封裝為對象。

第二組:對象的屬性訪問

var foo = {
  bar: function () {
      console.log(this)
  }
}

foo.bar() // foo

// foo 的 bar 屬性對應的 Reference
var barReference = {
  base: foo,
  propertyName: 'bar',
  strict: false
}

上述程式碼中,對於 barReference,根據函數調用規範可知 this = getThisValue(barReference) = GetBase(barReference) = foo

foo.bar()中,MemberExpression 計算的結果是 foo.bar,為什麼它是一個 Reference 類型呢?

EcmaScript 5.1標準中的 屬性訪問 的規範:

11.2.1 屬性訪問

  1. 返回一個 Reference 類型的值,其基值為 baseValue 且其引用名為 propertyNameString, 嚴格模式標記為 strict.

這裡只引用了最後一步,屬性訪問最終返回的值是一個 Reference 類型。

第三組:非 Reference 類型的函數調用

首先,需要j簡單了解一下 GetValue 方法,其作用是獲取 Reference 類型具體的值,返回結果不再是一個 Reference。例如:

var foo = 1

// foo 標識符對應的 Reference
var fooReference = {
  base: EnvironmentRecord,
  propertyName: 'foo',
  strict: false
}

GetValue(fooReference) // 1

示例程式碼:

value = 1

var foo = {
  value: 2,
  bar: function () {
    console.log(this.value)
  }
};

foo.bar();   // 2
(foo.bar)(); // 2

(false || foo.bar)();   // 1
(foo.bar = foo.bar)();  // 1
(foo.bar, foo.bar)();   // 1

在上述示例程式碼中:

  1. 對於 (foo.bar),foo.bar 被 () 包住,使用了分組運算符,查看規範 11.1.6 分組操作符,可知分組表達式不會調用 GetValue 方法, 所以 (foo.bar)仍舊是一個 Reference 類型,因此 this 為 Reference 類型的 base 對象,即 foo。
  2. 對於 (false || foo.bar),有邏輯與演算法,查看規範 11.11 二元邏輯運算符,可知二元邏輯運算符調用了 GetValue 方法,所以false || foo.bar不再是一個 Reference 類型,因此 this 為 undefined,非嚴格模式下,被隱式轉化為 global 對象。
  3. 對於 (foo.bar = foo.bar),有賦值運算符,查看規範 11.13.1 簡單賦值,可知簡單賦值調用了 GetValue 方法,所以foo.bar = foo.bar不再是一個 Reference 類型,因此 this 為 undefined,非嚴格模式下,被隱式轉化為 global 對象。
  4. 對於 (foo.bar, foo.bar),有逗號運算符,查看規範 11.14 逗號運算符,可知逗號運算符調用了 GetValue 方法,所以foo.bar, foo.bar不再是一個 Reference 類型,因此 this 為 undefined,非嚴格模式下,被隱式轉化為 global 對象。

3.總結

  1. 在全局環境(全局執行上下文)中(在任何函數體外部的程式碼),this 始終指向全局對象
  2. 在函數環境(函數執行上下文)中,絕大多數情況,函數的調用方式決定了 this 的值,這與調用函數的()左側的部分 MemberExpression 的解釋執行的結果的類型是不是 Reference 類型直接關聯。

4.參考

this 關鍵字 – JavaScript | MDN – Mozilla

深入理解JavaScript系列(10):JavaScript核心(晉級高手必讀篇)

深入理解JavaScript系列(13):This? Yes,this!

JavaScript深入之從ECMAScript規範解讀this

ECMAScript5.1中文版

ECMAScript 5.1 pdf(英)