JS夯實之執行上下文與詞法環境

前言

JavaScript是一門解釋性動態語言,但同時它也是一門充滿神祕感的語言。若是要成爲一名優秀的JS開發者,那麼對JavaScript程序的內部執行原理要有所瞭解。javascript

本文以最新的ECMA規範中的第八章節爲基礎,理清JavaScript的詞法環境和執行上下文的相關內容。這是理解JavaScript其餘概念(let/const暫時性死區、變量提高、閉包等)的基礎。html

本文參考的是最新發布的第十代ECMA-262標準,即ES2019,點擊官方文檔地址。 ES2019與ES6在詞法環境和執行上下文的內容上是近似的,ES2019在細節上作了部分補充,所以本文直接採用ES2019的標準。你也能夠對比兩個版本的標準的差別。前端

執行上下文(Execution Context)

執行上下文是用來跟蹤記錄代碼運行時環境的抽象概念。每一次代碼運行都至少會生成一個執行上下文。代碼都是在執行上下文中運行的。java

你能夠將代碼運行與執行上下文的關係類比爲進程與內存的關係,在代碼運行過程當中的變量環境信息都放在執行上下文中,當代碼運行結束,執行上下文也會銷燬。node

在執行上下文中記錄了代碼執行過程當中的狀態信息,根據不一樣運行場景,執行上下文會細分爲以下幾種類型:git

  • 全局執行上下文:當運行代碼是處於全局做用域內,則會生成全局執行上下文,這也是程序最基礎的執行上下文。
  • 函數執行上下文:當調用函數時,都會爲函數調用建立一個新的執行上下文。
  • eval執行上下文:eval函數執行時,會生成專屬它的上下文,因eval不多使用,故不做討論。

執行棧

有了執行上下文,就要有合理管理它的工具。而執行棧(Execution Context Stack)是用來管理執行期間建立的全部執行上下文的數據結構,它是一個LIFO(後進先出)的棧,它也是咱們熟知的JS程序運行過程當中的調用棧。 程序開始運行時,會先建立一個全局執行上下文並壓入到執行棧中,以後每當有函數被調用,都會建立一個新的函數執行上下文並壓入棧內。github

咱們從一小段代碼來看下執行棧的工做過程:瀏覽器

<script> console.log('script') function foo(){ function bar(){ console.log('bar', isNaN(undefined)) } bar() console.log('foo') } foo() </script>
複製代碼

當這段JS程序開始運行時,它會建立一個全局執行上下文GlobalContext,其中會初始化一些全局對象或全局函數,如代碼中的console,undefined,isNaN。將全局執行上下文壓入執行棧,一般JS引擎都有一個指針running指向棧頂元素:數據結構

JS引擎會將全局範圍內聲明的函數(foo)初始化在全局上下文中,以後開始一行行的執行代碼,運行到console就在running指向的上下文中的詞法環境中找到全局對象console並調用log函數。閉包

PS:固然,當調用log函數時,也是要新建函數上下文並壓棧到調用棧中的。這裏爲了簡單流程,忽略了log上下文的建立過程。

運行到foo()時,識別爲函數調用,此時建立一個新的執行上下文FooContext併入棧,將FooContext內詞法環境的outer引用指向全局執行上下文的詞法環境,移動running指針指向這個新的上下文:

在完成FooContext建立後,進入到FooContext中繼續執行代碼,運行到bar()時,同理仍須要新建一個執行上下文BarContext,此時BarContext內詞法環境的outer引用會指向FooContext的詞法環境:

繼續運行bar函數,因爲函數上下文內有outer引用實現層層遞進引用,所以在bar函數內仍能夠獲取到console對象並調用log

以後,完成barfoo函數調用,會依次將上下文出棧,直至全局上下文出棧,程序結束運行。

執行上下文的建立

執行上下文建立會作兩件事情:

  1. 建立詞法環境LexicalEnvironment
  2. 建立變量環境VariableEnvironment

所以一個執行上下文在概念上應該是這樣子的:

ExecutionContext = {
  LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
  VariableEnvironment = <ref. to VariableEnvironment in  memory>,
}
複製代碼

在全局執行上下文中,this指向全局對象,window in browser / global in nodejs

詞法環境(LexicalEnvironment)

詞法環境是ECMA中的一個規範類型 —— 基於代碼詞法嵌套結構用來記錄標識符和具體變量或函數的關聯。 簡單來講,詞法環境就是創建了標識符——變量的映射表。這裏的標識符指的是變量名稱或函數名,而變量則是實際變量原始值或者對象/函數的引用地址。

LexicalEnvironment中由三個部分構成:

  • 環境記錄EnvironmentRecord:存放變量和函數聲明的地方;
  • 外層引用outer:提供了訪問父詞法環境的引用,可能爲null;
  • this綁定ThisBinding:肯定當前環境中this的指向;

詞法環境的類型

  • 全局環境(GlobalEnvironment):在JavaScript代碼運行伊始,宿主(瀏覽器、NodeJs等)會事先初始化全局環境,在全局環境的EnvironmentRecord中會綁定內置的全局對象(Infinity等)或全局函數(evalparseInt等),其餘聲明的全局變量或函數也會存儲在全局詞法環境中。全局環境的outer引用爲null

這裏說起的全局對象就有咱們熟悉的全部內置對象,如Math、Object、Array等構造函數,以及Infinity等全局變量。全局函數則包含了eval、parseInt等函數。

  • 模塊環境(ModuleEnvironment):你若寫過NodeJs程序就會很熟悉這個環境,在模塊環境中你能夠讀取到exportmodule等變量,這些變量都是記錄在模塊環境的ER中。模塊環境的outer引用指向全局環境。

  • 函數環境(FunctionEnvironment):每一次調用函數時都會產生函數環境,在函數環境中會涉及this的綁定或super的調用。在ER中也會記錄該函數的lengtharguments屬性。函數環境的outer引用指向調起該函數的父環境。在函數體內聲明的變量或函數則記錄在函數環境中。

環境記錄ER

代碼中聲明的變量和函數都會存放在EnvironmentRecord中等待執行時訪問。 環境記錄EnvironmentRecord也有兩個不一樣類型,分別爲declarativeobjectdeclarative是較爲常見的類型,一般函數聲明、變量聲明都會生成這種類型的ER。object類型能夠由with語句觸發的,而with使用場景不多,通常開發者不多用到。

若是你在函數體中遇到諸如var const let class module import 函數聲明,那麼環境記錄就是declarative類型的。

值得一提的是**全局上下文的ER**有一點特殊,由於它是object ERdeclarative ER的混合體。在object ER中存放的是全局對象函數、function函數聲明、asyncgeneratorvar關鍵詞變量。在declarative ER則存放其餘方式聲明的變量,如let const class等。因爲標準中將object類型的ER視做基準ER,所以這裏咱們仍將全局ER的類型視做object

GlobalExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            type: 'object',  // 混合 object + declarative
            NaN,
            parseInt,
            Object,
            myFunc,
            a,
            b,
            ...
        },
        outer: null,
        this: <globalObject> } } 複製代碼

LexicalEnvironment只存儲函數聲明和let/const聲明的變量,與下文的VariableEnvironment有所區別。

好比,咱們有以下代碼:

let a = 10;
function foo(){
    let b = 20
    console.log(a, b)
}
foo()

// 它們的詞法環境僞碼以下:
GlobalEnvironment: {
    EnvironmentRecord: {
        type: 'object',
        a: <uninitialized>,
        foo: <func>
    },
    outer: <null>,
    this: <globalObject>
}

FunctionEnvironment: {
    EnvironmentRecord: {
        type: 'declarative',
        arguments: {length: 0},
        b: <uninitialized>,
    },
    outer: <GlobalEnvironment>,
    this: <globalObject>  // 嚴格模式下爲undefined
}
複製代碼

函數環境記錄

因爲函數環境是咱們平常開發過程最多見的詞法環境,所以須要更加深刻的研究一下函數環境的運行機制,幫助咱們更好理解一些語言特性。

當咱們調用一個函數時,會生成函數執行上下文,這個函數執行上下文的詞法環境的環境記錄就是函數類型的,有點拗口,用樹形圖表明一下:

FunctionContext
    |LexicalEnvironment
        |EnvironmentRecord  //--> 函數類型
複製代碼

爲何要強調這個類型呢?由於ECMA針對函數式環境記錄會額外增長一些內部屬性:

內部屬性 Value 說明 補充
[[ThisValue]] Any 函數內調用this時引用的地址,咱們常說的函數this綁定就是給這個內部屬性賦值
[[ThisBindingStatus]] "lexical" / "initialized" / "uninitialized" 若等於lexical,則爲箭頭函數,意味着this是空的; 強行new箭頭函數會報錯TypeError錯誤
FunctionObject Object 在這個對象中有兩個屬性[[Call]][[Construct]],它們都是函數,如何賦值取決於如何調用函數 正常的函數調用賦值[[Call]],而經過newsuper調用函數則賦值[[Construct]]
[[HomeObject]] Object / undefined 若是該函數(非箭頭函數)有super屬性(子類),則[[HomeObject]]指向父類構造函數 若你寫過extends就知道我在說什麼
[[NewTarget]] Object / undefined 若是是經過[[Construct]]方式調用的函數,那麼[[NewTarget]]非空 在函數中能夠經過new.target讀取到這個內部屬性。以此來判斷函數是否經過new來調用的

此外,函數環境記錄中還存有一個arguments對象,記錄了函數的入參信息。

ThisBinding

this綁定是一個老生常談的問題,因爲存在多種分析場景,這裏不便展開。👉 《JS夯實之ThisBinding的四條準則》 this綁定的目的是在執行上下文建立之時就明確this的指向,在函數執行過程當中讀取到正確的this引用的對象。

小結

概念類型太多,有一些凌亂了。簡單速記一下:

詞法環境分類 = 全局 / 函數 / 模塊
詞法環境 = ER + outer + this
ER分類 = declarative(DER) + object(OER)
全局ER = DER + OER
複製代碼

VariableEnvironment 變量環境

在ES6前,聲明變量都是經過var關鍵詞聲明的,在ES6中則提倡使用letconst來聲明變量,爲了兼容var的寫法,因而使用變量環境來存儲var聲明的變量。

var關鍵詞有個特性,會讓變量提高,而經過let/const聲明的變量則不會提高。爲了區分這兩種狀況,就用不一樣的詞法環境去區分。

變量環境本質上還是詞法環境,但它只存儲var聲明的變量,這樣在初始化變量時能夠賦值爲undefined

有了這些概念,一個完整的執行上下文應該是什麼樣子的呢?來點例子🌰:

let a = 10;
const b = 20;
var sum;

function add(e, f){
    var d = 40;
    return d + e + f 
}

let utils = {
    add
}

sum = utils.add(a, b)
複製代碼

完整的執行上下文以下所示:

GlobalExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            type: 'object',
            add: <function>,
            a: <uninitialized>,
            b: <uninitialized>,
            utils: <uninitialized>,
        },
        outer: null,
        this: <globalObject>
    },
    VariableEnvironment: {
        EnvironmentRecord: {
            type: 'object',
            sum: undefined
        },
        outer: null,
        this: <globalObject>
    },
}

// 當運行到函數add時纔會建立函數執行上下文
FunctionExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            type: 'declarative',
            arguments: {0: 10, 1: 20, length: 2},
            [[ThisValue]]: <utils>,
            [[NewTarget]]: undefined,
            ...
        },
        outer: <GlobalLexicalEnvironment>,
        this: <utils>
    },
    VariableEnvironment: {
        EnvironmentRecord: {
            type: 'declarative',
            d: undefined
        },
        outer: <GlobalLexicalEnvironment>,
        this: <utils>
    },
}
複製代碼

執行上下文建立後,進入到執行環節,變量在執行過程當中賦值、讀取、再賦值等。直至程序運行結束。 咱們注意到,在執行上下文建立時,變量a``b都是<uninitialized>的,而sum則被初始化爲undefined。這就是爲何你能夠在聲明以前訪問var定義的變量(變量提高),而訪問let/const定義的變量就會報引用錯誤的緣由。

let/const 與 var

簡單聊聊同是變量聲明,二者有何區別?

let 與 const 的區別這裏再也不贅述

存放位置
從上一結中,咱們知道了let/const聲明的變量是歸屬於LexicalEnvironment,而var聲明的變量歸屬於VariableEnvironment

初始化(詞法階段)
let/const在初始化時會被置爲<uninitialized>標誌位,在沒有執行到let xxxlet xxx = ???(賦值行)的具體行時,提早讀取變量會報ReferenceError的錯誤。(這個特性又叫暫時性死區var在初始化時先被賦值爲undefined,即便沒有執行到賦值行,仍能夠讀取var變量(undefined)。

塊環境記錄(塊做用域)
ECMA標準中提到,當遇到BlockCaseBlock時,將會新建一個環境記錄,在塊中聲明的let/const變量、函數、類都存放這個新的環境記錄中,這些變量與塊強綁定,在塊外界則沒法讀取這些聲明的變量。這個特性就是咱們熟悉的塊做用域。

什麼是Block? 被花括號({})括起來的就是塊。

Block中的let/const變量僅在塊中有效,塊外界沒法讀取到塊內變量。var變量不受此限制。

var無論在哪,都會變量提高~

與ES3的區別

若是你瞭解ES5版本的有關執行上下文的內容,會感到奇怪爲啥有關VOAO、做用域、做用域鏈等內容沒有在本文中說起。其實二者概念並不衝突,一個是ES3規範中的定義,而詞法環境則是ES6規範的定義。不一樣時期,不一樣稱呼。

ES3 --> ES6 做用域 --> 詞法環境 做用域鏈 --> outer引用 VO|AO --> 環境記錄 這裏有個stackoverflow的討論

你問我該學哪一個?立足如今,銘記歷史,擁抱將來。

總結

本文關於執行上下文的理論知識比較多,不容易立刻吸取理解,建議你逐漸消化、反覆閱讀理解。當你熟悉了執行上下文和詞法環境,相信去理解認識更多JS特性和概念時,會更加輕鬆容易。

參考

最後

碼字不易,若是:

  • 這篇文章對你有用,請不要吝嗇你的小手爲我點贊;
  • 有不懂或者不正確的地方,請評論,我會積極回覆或勘誤;
  • 指望與我一同持續學習前端技術知識,請關注我吧;
  • 轉載請註明出處;

您的支持與關注,是我持續創做的最大動力!

本文首發於個人Blog倉庫

相關文章
相關標籤/搜索