深入JavaScript系列(二):執行上下文

Logan70發表於2018-12-09

一、執行上下文(Exexution Contexts)

執行上下文(Exexution Contexts):用來通過ECMAScript編譯器來追蹤程式碼執行時計算的一種規範策略。

執行上下文簡單理解就是程式碼執行時所在環境的抽象。

執行上下文同時包含變數環境元件(VariableEnvironment)詞法環境元件(LexicalEnvironment),這兩個元件多數情況下都指向相同的詞法環境(Lexical Environment),那為什麼還要存在兩個環境元件呢?我們稍後將進行詳細討論。如果不太瞭解詞法環境的可以看下我的上一篇文章深入ECMAScript系列(一):詞法環境

ExecutionContext = {
    VariableEnvironment: { ... },
    LexicalEnvironment: { ... },
}
複製程式碼

二、執行上下文棧

執行上下文棧(Execution Context Stack):是一個後進先出的棧式結構(LIFO),用來跟蹤維護執行上下文。執行執行上下文(running execution context) 始終位於執行上下文棧的頂層。那麼什麼時候會建立新的執行上下文呢?

ECMAScript可執行程式碼有四種型別:全域性程式碼,函式程式碼,模組程式碼和eval。每當從當前執行程式碼執行至其他可執行程式碼時,會建立新的執行上下文,將其壓入執行上下文棧併成為正在執行的執行上下文。當相關程式碼執行完畢返回後,將正在執行的執行上下文從執行上下文棧刪除,之前的執行上下文又成為了正在執行的執行上下文。

我們通過一個動圖來看一下執行上下文棧的工作過程

深入JavaScript系列(二):執行上下文

  1. 開始執行任何JavaScript程式碼前,會建立全域性上下文並壓入棧,所以全域性上下文一直在棧底。
  2. 每次呼叫函式都會建立新的執行上下文(即便在函式內部呼叫自身),並壓入棧。
  3. 函式執行完畢返回,其執行上下文出棧。
  4. 所有程式碼執行完畢,執行上下文棧只剩全域性執行上下文。

三、執行上下文的建立、入棧及出棧

上面提到過ECMAScript可執行程式碼有四種型別:全域性程式碼,函式程式碼,模組程式碼和eval

這裡雖然說是全域性程式碼,但是JavaScript引擎其實是按照script標籤來解析執行的,也就是說script標籤按照它們出現的順序解析執行,這也就是為什麼我們平時要將專案依賴js庫放在前面引入的原因。

JavaScript引擎是按可執行程式碼塊來執行程式碼的,在任意的JavaScript可執行程式碼被執行時,執行步驟可按如下理解:

  1. 建立一個新的執行上下文(Execution Context)
  2. 建立一個新的詞法環境(Lexical Environment)
  3. 將該執行上下文的 變數環境元件(VariableEnvironment)詞法環境元件(LexicalEnvironment) 都指向新建立的詞法環境
  4. 將該執行上下文 推入執行上下文棧 併成為 正在執行的執行上下文
  5. 對程式碼塊內的 識別符號進行例項化及初始化
  6. 執行程式碼
  7. 執行完畢後執行上下文出棧

變數提升(Hoisting)及暫時性死區(temporal dead zone,TDZ)

我們平常所說的變數提升就發生在上述執行步驟的第四步,對程式碼塊內的識別符號進行例項化及初始化的具體表現如下:

  1. 執行程式碼塊內的letconstclass宣告的識別符號合集記錄為lexNames
  2. 執行程式碼塊內的varfunction宣告的識別符號合集記錄為varNames
  3. 如果lexNames內的任何識別符號在varNameslexNames內出現過,則報錯SyntaxError

    這就是為什麼可以用varfunction宣告多個同名變數,但是不能用letconstclass宣告多個同名變數。

  4. varNames內的var宣告的識別符號例項化並初始化賦值undefined,如果有同名識別符號則跳過

    這就是所謂的變數提升,我們用var宣告的變數,在宣告位置之前訪問並不會報錯,而是返回undefined

  5. lexNames內的識別符號例項化,但並不會進行初始化,在執行至其宣告處程式碼時才會進行初始化,在初始化前訪問都會報錯。

    這就是我們所說的暫時性死區letconstclass宣告的變數其實也提升了,只不過沒有被初始化,初始化之前不可訪問。

  6. 最後將varNames內的函式宣告例項化並初始化賦值對應的函式體,如果有同名函式宣告,則前面的都會忽略,只有最後一個宣告的函式會被初始化賦值。

    函式宣告會被直接賦值,所有我們在函式宣告位置之前也可以呼叫函式。

四、為什麼需要兩個環境元件

首先明確這兩個環境元件的作用,變數環境元件(VariableEnvironment)用於記錄var宣告的繫結,詞法環境元件(LexicalEnvironment)用於記錄其他宣告的繫結(如letconstclass等)。

一般情況下一個Exexution Contexts內的VariableEnvironmentLexicalEnvironment指向同一個詞法環境,之所以要區分兩個元件,主要是為了實現塊級作用域的同時不影響var宣告及函式宣告

眾所周知,ES6之前並沒有塊級作用域的概念,但是ES6及之後我們可以通過新增的letconst等命令來實現塊級作用域,並且不影響var宣告的變數和函式宣告,那麼這是怎麼實現的呢?

  1. 首先在一個正在執行的執行上下文(running Execution Context)內,詞法環境由VariableEnvironmentLexicalEnvironment構成,此執行上下文內的所有識別符號的繫結都記錄在兩個元件的環境記錄內。
  2. 當執行至塊級程式碼時,會將LexicalEnvironment記錄下來,我們將其記錄為oldEnv
  3. 然後建立一個新的LexicalEnvironment(外部詞法環境outer指向oldEnv),我們將其記錄為newEnv,並將newEnv設定為running Execution ContextLexicalEnvironment
  4. 然後塊級程式碼內的letconst等宣告就會繫結在這個newEnv上面,但是var宣告和函式宣告還是繫結在原來的VariableEnvironment上面。

    塊級程式碼內的函式宣告會被當做var宣告,會被提升至外部環境,塊級程式碼執行前其值為初始值undefined

    console.log(foo) // 輸出:undefined
    {
        function foo() {console.log('hello')}
    }
    console.log(foo) // 輸出: ƒ foo() {console.log('hello')}
    複製程式碼
  5. 塊級程式碼執行完畢後,又將oldEnv還原為running Execution ContextLexicalEnvironment

目前包括塊級程式碼(在一對大括號內的程式碼)、for迴圈語句、switch語句、TryCatch語句中的catch從句以及with語句(with語句建立的新環境為物件式環境,其他皆為宣告式環境)都是這樣來實現塊級作用域的。

系列文章

準備將之前寫的部分深入ECMAScript文章重寫,加深自己理解,使內容更有乾貨,目錄結構也更合理。

深入ECMAScript系列目錄地址(持續更新中...)

歡迎前往閱讀系列文章,如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

菜鳥一枚,如果有疑問或者發現錯誤,可以在相應的 issues 進行提問或勘誤,與大家共同進步。

相關文章