深入理解Javascript之Execution Context

darjun發表於2018-12-06

1.概述

執行上下文(Execution Context)是執行 Javascript 程式碼的環境。可以毫不誇張地說,執行上下文是 Javascript 中最重要的概念。它是其他很多重要概念的基礎。一旦搞清楚了執行上下文是什麼,我們就能很輕鬆地掌握下面這些概念:

  • 頂置(Hoisting)
  • 作用域鏈(Scope Chaining)
  • 閉包(Closure)
  • this以及arguments是如何賦值的
  • 等等等等

2.執行上下文

深入理解 Javascript 之 CallStack&EventLoop一文中,我們已經簡單瞭解了 Javascript 程式是如何執行以及函式呼叫的過程。我們知道每次呼叫一個函式時,都會建立一個“呼叫資訊”結構壓入呼叫棧。其實這個呼叫結構就是執行上下文。因此呼叫棧(Call Stack)也被稱為執行棧(Execution Stack)。

執行上下文有兩種型別:

  • 一種為全域性執行上下文(Global Execution Context),程式開始時建立,有且只有一個。

  • 另一種為區域性執行上下文(Local Execution Context),呼叫函式時建立。區域性執行上下文又稱為函式執行上下文(Function Execution Context)。

看下面的程式碼:

var name = "darjun";
var email = "leedarjun@gmail.com";

function greeting() {
    console.log(`Hi, I'm ${name}, my email is ${email}!`);
}

greeting();
複製程式碼

Javascript 引擎在執行程式碼時會建立一個全域性物件(global object)。在瀏覽器中全域性物件為window物件,在 Node 環境中為global物件。

1) 在所有函式外層定義的變數都會儲存在全域性物件中。

2) 在函式內,未使用varletconst修飾的變數定義也會將變數儲存在全域性物件中。

接下來引擎開始解析程式碼,建立<main>函式包裹程式碼。 然後,<main>函式執行。此時,Javascript 引擎首先會建立一個全域性執行上下文。

執行上下文的建立分為兩個階段:

1)建立階段(Creation Phase)

2)執行階段(Execution Phase)

在全域性執行上下文的建立階段,引擎將進行如下處理:

1)繫結this到全域性物件。

2)建立一個全域性環境物件(Global Environment)。為<main>中定義的變數和函式分配記憶體。var定義的變數初始值為undefined

此時,全域性執行上下文如下所示:

GlobalExecutionContext = {
    Phase: Creation, // 建立階段
    this: GlobalObject,
    GlobalEnvironment: {
        name: undefined,
        email: undefined,
        greeting: fn,
    }
}
複製程式碼

注意:此時程式碼還未執行。

接下來,引擎開始從上到下,一行一行地執行<main>函式。

首先,引擎將全域性執行上下文壓入呼叫棧。這時全域性執行上下文切換為執行階段(Phase: Creation -> Execution)。然後,跳過函式定義。因為greeting函式在建立階段就已經被解析完成並且放入全域性環境物件中了。然後執行到程式碼greeting();呼叫greeting函式。

引擎首先為函式greeting建立一個區域性執行上下文。區域性執行上下文的建立也將經歷建立和執行兩個階段。建立階段時,引擎執行如下處理:

1)根據呼叫方式繫結this變數。在這個例子中,函式greeting是全域性函式,沒有物件限定。this被繫結到全域性物件。

2)建立一個區域性環境物件(Local Environment)。該物件與全域性環境物件作用類似,只不過是為函式中定義的變數和函式分配記憶體。該物件中有一個指向外層環境物件的指標outer

這時的區域性執行上下文如下所示:

Greeting ExecutionContext = {
    Phase: Creation, // 建立階段
    this: GlobalObject,
    LocalEnvironment: {
        // 沒有變數或函式定義
        outer: <GlobalEnvironment>
    },
}
複製程式碼

引擎將該區域性執行上下文壓入呼叫棧開始執行。greeting執行完成之後,從呼叫棧上彈出其區域性執行上下文。此時棧頂只有一個全域性執行上下文,繼續執行<main>

<main>執行完成,將全域性執行上下文從呼叫棧中彈出,程式結束。

4.應用執行上下文理解其他概念

上面我們瞭解了什麼是執行上下文,並且深入到程式執行內部觀察到引擎是怎麼處理函式呼叫的。接下來,我們將運用執行上下文來了解 Javascript 的幾個核心概念。

頂置

頂置其實是由於 Javascript 特殊的執行邏輯而出現的。我們先修改一下前面的示例程式碼:

console.log(name);
console.log(email);

var name = "darjun";
var email = "leedarjun@gmail.com";

function greeting() {
    console.log(`Hi, I'm ${name}, my email is ${email}!`);
}

greeting();
複製程式碼

程式碼前兩行的輸出是什麼?

我們知道一個執行上下文會經歷建立和執行兩個階段。在建立階段時,引擎首先為函式中定義的變數和函式分配記憶體空間並存入環境物件中。var定義的變數初始化為undefined,函式直接解析完成。 然後,引擎壓入該執行上下文,一行一行執行程式碼。

那麼很清楚了,前兩行的輸出都是undefined。因為在執行上下文的建立階段,nameemail會被初始化為undefined。這就造成變數或函式還未定義就能直接使用的假象,看起來好像var變數和函式定義被“提升”或“頂置”到程式碼的最前面一樣。同樣的道理,在程式碼最上面也可以列印函式greeting,將列印出具體的函式物件。因為頂層函式在建立階段就已經存在環境物件中了。快試試?。

var的這種特性經常會造成意想不到的結果,所以 ES6 引入了另一種變數定義方式letlet定義的變數在定義之前引用會丟擲異常。這是怎麼做到的呢?

其實很簡單。在執行上下文的建立階段,let定義的變數也會存入環境物件中。不過,它的初始值為UnInitialized(未初始化)。在執行時,如果引用一個值為UnInitialized的變數,引擎直接丟擲一個錯誤?。

閉包

是指函式中能訪問在函式外層定義的變數,這個函式加上外層的環境就構成了一個閉包。我們還是通過案例來分析:

function makeAdder(num) {
    return function (x) {
        return x + num;
    }
}

var adder2 = makeAdder(2);
console.log(adder2(10)); // 12

var adder5 = makeAdder(5);
console.log(adder5(10)); // 15
複製程式碼

第一次呼叫函式makeAdder時,傳入引數2,返回一個匿名函式賦值給變數adder2。這時,makeAdder函式已返回。但是adder2呼叫時能正確返回12。說明adder2能訪問到之前傳入的引數num

第二次呼叫函式makeAdder時,傳入引數5,返回一個匿名函式賦值給變數adder5。此時,makeAdder函式已返回。但是adder5呼叫時能正確返回15。說明adder5能訪問到之前傳入的引數num並且,adder2adder5訪問到的num變數相互獨立(一個為2,一個為5)

運用執行上下文模擬一次程式執行過程,能很清楚的看到閉包的工作原理。

引數num相當於是在函式內定義的變數。 首先,第一次呼叫makeAdder時。引擎為此次呼叫建立一個新的區域性環境物件,num被儲存在此物件中:

makeAdder LocalEnvironment2 = {
    num: 2,
}
複製程式碼

adder2被呼叫時,引擎會建立一個新的區域性環境物件。該物件中儲存著x = 10,並且其outer指標指向上面的LocalEnvironment2

adder2 LocalEnvironment = {
    x: 10,
    outer: <makeAdder LocalEnvironment2>
}
複製程式碼

adder2執行過程中,訪問變數num。引擎首先在adder2的區域性環境物件中查詢num,沒有找到。然後引擎會到其外層的環境物件中繼續查詢,直到找到該變數。或者直到全域性環境物件中也未能找到,丟擲引用錯誤。 在該示例中,外層環境物件中查詢到num2adder2(10)執行完成,輸出12

第二次呼叫makeAdder時。引擎為此次呼叫建立一個新的區域性環境物件,num被儲存在此物件中:

makeAdder LocalEnvironment5 = {
    num: 5,
}
複製程式碼

adder5被呼叫時,引擎會建立一個新的區域性環境物件。該物件中儲存x = 10,並且其outer指標指向上面的LocalEnvironment5

adder5 LocalEnvironment = {
    x: 10,
    outer: <makeAdder LocalEnvironment5>
}
複製程式碼

執行程式碼return x + num時,按照上面的變數查詢流程,在外層環境物件LocalEnvironment5中找到的num值為5adder5(10)執行完成,輸出15

arguments

我們知道,在函式呼叫中,arguments物件中包含傳入的所有引數、引數的長度以及其他一些資訊。例如:

function f(a, b, c) {
    console.log(arguments);
}

f(1, 2); // Arguments(2) [1, 2, callee: ƒ, Symbol(Symbol.iterator): ƒ]
複製程式碼

引數列表在呼叫時會依次被賦予傳入的實參。呼叫時的區域性物件會包含所有引數變數,arguments等:

LocalEnvironment = {
    a: 1,
    b: 2,
    arguments: [1, 2] // ...
}
複製程式碼

this繫結

先看一段程式碼:

var person = {
    name: "darjun",
    age: 29,
    greeting: function () {
        console.log(`Hi, I'm ${this.name}, ${this.age} years old`);
    }
}

person.greeting(); // 輸出 Hi, I'm darjun, 29 years old

var g = person.greeting;

g(); // 輸出 Hi, I'm undefined, undefined years old
複製程式碼

前面我們知道 Javascript 引擎在執行一個函式前會進行this繫結。具體為this繫結什麼值,視呼叫形式而定。

在上面的程式碼中,第一次呼叫greeting函式時,通過物件person限定,引擎會將person繫結為this。 第二次呼叫前,將person.greeting賦值給變數g。然後直接呼叫函式g,引擎看到此次呼叫沒有.限定符,故而將this繫結為全域性物件。 所以輸出為"Hi, I'm undefined, undefined years old"(注意:輸出視全域性物件中是否有nameage屬性而有所不同)。

4.視覺化

這裡我給大家推薦一個視覺化檢視程式執行的工具:javascript-visualizer

頂置:

深入理解Javascript之Execution Context

閉包:

深入理解Javascript之Execution Context

工具並不完善,但是非常有助於我們理解執行上下文。非常值得一試?。

5.總結

我認為執行上下文是 Javascript 中最最重要的概念。掌握了執行上下文,我們能很深刻地洞悉 Javascript 程式的執行機理,能很輕鬆地理解其他的一些重要概念:頂置(Hoisting)、閉包(Closure)、thisarguments等。

掌握執行上下文,真的能稱霸 Javascript 世界哦?。

6.參考連結

  1. Javascript: What Is The Execution Context? What Is The Call Stack
  2. Understand Execution Context and Execution Stack in Javascript
  3. The Ultimate Guide to Hoisting, Scopes, and Closures in JavaScript

關於我: 個人主頁 簡書 掘金

相關文章