JavaScript是一門解釋性動態語言,但同時它也是一門充滿神祕感的語言。若是要成爲一名優秀的JS開發者,那麼對JavaScript程序的內部執行原理要有所瞭解。javascript
本文以最新的ECMA規範中的第八章節爲基礎,理清JavaScript的詞法環境和執行上下文的相關內容。這是理解JavaScript其餘概念(let/const暫時性死區、變量提高、閉包等)的基礎。html
本文參考的是最新發布的第十代ECMA-262標準,即ES2019,點擊官方文檔地址。 ES2019與ES6在詞法環境和執行上下文的內容上是近似的,ES2019在細節上作了部分補充,所以本文直接採用ES2019的標準。你也能夠對比兩個版本的標準的差別。前端
執行上下文是用來跟蹤記錄代碼運行時環境的抽象概念。每一次代碼運行都至少會生成一個執行上下文。代碼都是在執行上下文中運行的。java
你能夠將代碼運行與執行上下文的關係類比爲進程與內存的關係,在代碼運行過程當中的變量環境信息都放在執行上下文中,當代碼運行結束,執行上下文也會銷燬。node
在執行上下文中記錄了代碼執行過程當中的狀態信息,根據不一樣運行場景,執行上下文會細分爲以下幾種類型:git
有了執行上下文,就要有合理管理它的工具。而執行棧(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
。
以後,完成bar
和foo
函數調用,會依次將上下文出棧,直至全局上下文出棧,程序結束運行。
執行上下文建立會作兩件事情:
LexicalEnvironment
;VariableEnvironment
;所以一個執行上下文在概念上應該是這樣子的:
ExecutionContext = {
LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>,
}
複製代碼
在全局執行上下文中,this指向全局對象,window in browser / global in nodejs
。
詞法環境是ECMA中的一個規範類型 —— 基於代碼詞法嵌套結構用來記錄標識符和具體變量或函數的關聯。 簡單來講,詞法環境就是創建了標識符——變量的映射表。這裏的標識符指的是變量名稱或函數名,而變量則是實際變量原始值或者對象/函數的引用地址。
在LexicalEnvironment
中由三個部分構成:
EnvironmentRecord
:存放變量和函數聲明的地方;outer
:提供了訪問父詞法環境的引用,可能爲null;ThisBinding
:肯定當前環境中this的指向;詞法環境的類型
GlobalEnvironment
):在JavaScript代碼運行伊始,宿主(瀏覽器、NodeJs等)會事先初始化全局環境,在全局環境的EnvironmentRecord
中會綁定內置的全局對象(Infinity
等)或全局函數(eval
、parseInt
等),其餘聲明的全局變量或函數也會存儲在全局詞法環境中。全局環境的outer
引用爲null
。這裏說起的全局對象就有咱們熟悉的全部內置對象,如Math、Object、Array等構造函數,以及Infinity等全局變量。全局函數則包含了eval、parseInt等函數。
模塊環境(ModuleEnvironment
):你若寫過NodeJs程序就會很熟悉這個環境,在模塊環境中你能夠讀取到export
、module
等變量,這些變量都是記錄在模塊環境的ER中。模塊環境的outer
引用指向全局環境。
函數環境(FunctionEnvironment
):每一次調用函數時都會產生函數環境,在函數環境中會涉及this
的綁定或super
的調用。在ER中也會記錄該函數的length
和arguments
屬性。函數環境的outer
引用指向調起該函數的父環境。在函數體內聲明的變量或函數則記錄在函數環境中。
環境記錄ER
代碼中聲明的變量和函數都會存放在EnvironmentRecord
中等待執行時訪問。 環境記錄EnvironmentRecord
也有兩個不一樣類型,分別爲declarative
和object
。declarative
是較爲常見的類型,一般函數聲明、變量聲明都會生成這種類型的ER。object
類型能夠由with
語句觸發的,而with
使用場景不多,通常開發者不多用到。
若是你在函數體中遇到諸如var const let class module import 函數聲明
,那麼環境記錄就是declarative
類型的。
值得一提的是**全局上下文的ER
**有一點特殊,由於它是object ER
與declarative ER
的混合體。在object ER
中存放的是全局對象函數、function函數聲明、async
、generator
、var
關鍵詞變量。在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]] ,而經過new 或super 調用函數則賦值[[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
複製代碼
在ES6前,聲明變量都是經過var
關鍵詞聲明的,在ES6中則提倡使用let
和const
來聲明變量,爲了兼容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 的區別這裏再也不贅述
存放位置
從上一結中,咱們知道了let/const
聲明的變量是歸屬於LexicalEnvironment
,而var
聲明的變量歸屬於VariableEnvironment
。
初始化(詞法階段)
let/const
在初始化時會被置爲<uninitialized>
標誌位,在沒有執行到let xxx
或 let xxx = ???
(賦值行)的具體行時,提早讀取變量會報ReferenceError
的錯誤。(這個特性又叫暫時性死區
) var
在初始化時先被賦值爲undefined
,即便沒有執行到賦值行,仍能夠讀取var
變量(undefined
)。
塊環境記錄(塊做用域)
在ECMA標準中提到,當遇到Block
或CaseBlock
時,將會新建一個環境記錄,在塊中聲明的let/const
變量、函數、類都存放這個新的環境記錄中,這些變量與塊強綁定,在塊外界則沒法讀取這些聲明的變量。這個特性就是咱們熟悉的塊做用域。
什麼是Block? 被花括號({})括起來的就是塊。
在Block
中的let/const
變量僅在塊中有效,塊外界沒法讀取到塊內變量。var
變量不受此限制。
var
無論在哪,都會變量提高~
若是你瞭解ES5版本的有關執行上下文的內容,會感到奇怪爲啥有關VO
、AO
、做用域、做用域鏈等內容沒有在本文中說起。其實二者概念並不衝突,一個是ES3規範中的定義,而詞法環境則是ES6規範的定義。不一樣時期,不一樣稱呼。
ES3 --> ES6 做用域 --> 詞法環境 做用域鏈 --> outer引用 VO|AO --> 環境記錄 這裏有個stackoverflow的討論
你問我該學哪一個?立足如今,銘記歷史,擁抱將來。
本文關於執行上下文的理論知識比較多,不容易立刻吸取理解,建議你逐漸消化、反覆閱讀理解。當你熟悉了執行上下文和詞法環境,相信去理解認識更多JS特性和概念時,會更加輕鬆容易。
碼字不易,若是:
您的支持與關注,是我持續創做的最大動力!
本文首發於個人Blog倉庫