手把手教會你JavaScript引擎如何執行JavaScript代碼
JavaScript 在運行過程中與其他語言有所不一樣,如果不理解 JavaScript 的詞法環境、執行上下文等內容,很容易會在開發過程中產生 Bug,比如this指向和預期不一致、某個變量不知道為什么被改了,等等。所以今天我們就來聊一聊 JavaScript 代碼的運行過程。
大家都知道,JavaScript 代碼是需要在 JavaScript 引擎中運行的。我們在說到 JavaScript 運行的時候,常常會提到執行環境、詞法環境、作用域、執行上下文、閉包等內容。這些概念看起來都差不多,卻好像又不大容易區分清楚,它們分別都在描述什么呢?
這些詞語都是與 JavaScript 引擎執行代碼的過程有關,為了搞清楚這些概念之間的區別,我們可以回顧下 JavaScript 代碼運行過程中的各個階段。
JavaScript 代碼運行的各個階段
JavaScript 是弱類型語言,在運行時才能確定變量類型。JavaScript 引擎在執行 JavaScript 代碼時,也會從上到下進行詞法分析、語法分析、語義分析等處理,并在代碼解析完成后生成 AST(抽象語法樹),最終根據 AST 生成 CPU 可以執行的機器碼并執行。
這個過程,我們稱之為語法分析階段。除了語法分析階段,JavaScript 引擎在執行代碼時還會進行其他的處理。以 V8 引擎為例,在 V8 引擎中 JavaScript 代碼的運行過程主要分成三個階段。
- 語法分析階段。該階段會對代碼進行語法分析,檢查是否有語法錯誤(SyntaxError),如果發現語法錯誤,會在控制臺拋出異常并終止執行。
- 編譯階段。該階段會進行執行上下文(Execution Context)的創建,包括創建變量對象、建立作用域鏈、確定 this 的指向等。每進入一個不同的運行環境時,V8 引擎都會創建一個新的執行上下文。
- 執行階段。將編譯階段中創建的執行上下文壓入調用棧,并成為正在運行的執行上下文,代碼執行結束后,將其彈出調用棧。
其中,語法分析階段屬于編譯器通用內容,就不再贅述。前面提到的執行環境、詞法環境、作用域、執行上下文等內容都是在編譯和執行階段中產生的概念。
執行上下文的創建
執行上下文的創建離不開 JavaScript 的運行環境,JavaScript 運行環境包括全局環境、函數環境和eval,其中全局環境和函數環境的創建過程如下:
- 第一次載入 JavaScript 代碼時,首先會創建一個全局環境。全局環境位于最外層,直到應用程序退出后(例如關閉瀏覽器和網頁)才會被銷毀。
- 每個函數都有自己的運行環境,當函數被調用時,則會進入該函數的運行環境。當該環境中的代碼被全部執行完畢后,該環境會被銷毀。不同的函數運行環境不一樣,即使是同一個函數,在被多次調用時也會創建多個不同的函數環境。
在不同的運行環境中,變量和函數可訪問的其他數據范圍不同,環境的行為(比如創建和銷毀)也有所區別。而每進入一個不同的運行環境時,JavaScript 都會創建一個新的執行上下文,該過程包括:
- 建立作用域鏈(Scope Chain);
- 創建變量對象(Variable Object,簡稱 VO);
- 確定 this 的指向。
由于建立作用域鏈過程中會涉及變量對象的概念,因此我們先來看看變量對象的創建,再看建立作用域鏈和確定 this 的指向。
創建變量對象
變量對象(VO)
每個執行上下文都會有一個關聯的變量對象,該對象上會保存這個上下文中定義的所有變量和函數。
在瀏覽器中,全局環境的變量對象是window對象,因此所有的全局變量和函數都是作為window對象的屬性和方法創建的。相應的,在 Node 中全局環境的變量對象則是global對象。
創建VO的過程
創建變量對象將會創建arguments對象(僅函數環境下),同時會檢查當前上下文的函數聲明和變量聲明。
- 對于變量聲明:此時會給變量分配內存,并將其初始化為undefined(該過程只進行定義聲明,執行階段才執行賦值語句)。
- 對于函數聲明:此時會在內存里創建函數對象,并且直接初始化為該函數對象。
變量聲明和函數聲明的處理過程,便是我們常說的變量提升和函數提升,其中函數聲明提升會優先于變量聲明提升。因為變量提升容易帶來變量在預期外被覆蓋掉的問題,同時還可能導致本應該被銷毀的變量沒有被銷毀等情況。因此 ES6 中引入了let和const關鍵字,從而使 JavaScript 也擁有了塊級作用域。
作用域
在各類編程語言中,作用域分為靜態作用域和動態作用域。JavaScript 采用的是詞法作用域(Lexical Scoping),也就是靜態作用域。詞法作用域中的變量,在編譯過程中會產生一個確定的作用域。
詞法作用域中的變量,在編譯過程中會產生一個確定的作用域,這個作用域即當前的執行上下文,在 ES5 后我們使用詞法環境(Lexical Environment)替代作用域來描述該執行上下文。因此,詞法環境可理解為我們常說的作用域,同樣也指當前的執行上下文(注意,是當前的執行上下文)。
在 JavaScript 中,詞法環境又分為詞法環境(Lexical Environment)和變量環境(Variable Environment)兩種,其中:
- 變量環境用來記錄var/function等變量聲明;
- 詞法環境是用來記錄let/const/class等變量聲明。
也就是說,創建變量過程中會進行函數提升和變量提升,JavaScript 會通過詞法環境來記錄函數和變量聲明。通過使用兩個詞法環境(而不是一個)分別記錄不同的變量聲明內容,JavaScript 實現了支持塊級作用域的同時,不影響原有的變量聲明和函數聲明。
這就是創建變量的過程,它屬于執行上下文創建中的一環。創建變量的過程會產生作用域,作用域也被稱為詞法環境。
建立作用域鏈
作用域鏈,就是將各個作用域通過某種方式連接在一起。作用域就是詞法環境,而詞法環境由兩個成員組成。
- 環境記錄(Environment Record):用于記錄自身詞法環境中的變量對象。
- 外部詞法環境引用(Outer Lexical Environment):記錄外層詞法環境的引用。
通過外部詞法環境的引用,作用域可以層層拓展,建立起從里到外延伸的一條作用域鏈。當某個變量無法在自身詞法環境記錄中找到時,可以根據外部詞法環境引用向外層進行尋找,直到最外層的詞法環境中外部詞法環境引用為null,這便是作用域鏈的變量查詢。
JavaScript 代碼運行過程分為定義期和執行期,前面提到的編譯階段則屬于定義期,代碼示例如下:
- function foo() { // 定義全局函數foo
- console.dir(bar);
- var a = 1;
- function bar() { // 在foo函數內部定義函數bar
- a = 2;
- }
- }
- console.dir(foo);
- foo();
前面我們說到,JavaScript 使用的是靜態作用域,因此函數的作用域在定義期已經決定了。在上面的例子中,全局函數foo創建了一個foo的[[scope]]屬性,包含了全局[[scope]]:
- foo[[scope]] = [globalContext];
而當我們執行foo()時,也會分別進入foo函數的定義期和執行期。
在foo函數的定義期時,函數bar的[[scope]]將會包含全局[[scope]]和foo的[[scope]]:
- bar[[scope]] = [fooContext, globalContext];
運行上述代碼,我們可以在控制臺看到符合預期的輸出:

可以看到:
- foo的[[scope]]屬性包含了全局[[scope]]
- bar的[[scope]]將會包含全局[[scope]]和foo的[[scope]]
也就是說,JavaScript 會通過外部詞法環境引用來創建變量對象的一個作用域鏈,從而保證對執行環境有權訪問的變量和函數的有序訪問。除了創建作用域鏈之外,在這個過程中還會對創建的變量對象做一些處理。
在編譯階段會進行變量對象(VO)的創建,該過程會進行函數聲明和變量聲明,這時候變量的值被初始化為 undefined。在代碼進入執行階段之后,JavaScript 會對變量進行賦值,此時變量對象會轉為活動對象(Active Object,簡稱 AO),轉換后的活動對象才可被訪問,這就是 VO -> AO 的過程,示例如下:
- function foo(a) {
- var b = 2;
- function c() {}
- var d = function() {};
- }
- foo(1);
在執行foo(1)時,首先進入定義期,此時:
- 參數變量a的值為1
- 變量b和d初始化為undefined
- 函數c創建函數并初始化
- AO = {
- arguments: {
- 0: 1,
- length: 1
- },
- a: 1,
- b: undefined,
- c: reference to function() c() {}
- d:undefined
- }
前面我們也有提到,進入執行期之后,會執行賦值語句進行賦值,此時變量b和d會被賦值為 2 和函數表達式:
- AO = {
- arguments: {
- 0: 1,
- length: 1
- },
- a: 1,
- b: 2,
- c: reference to function c(){},
- d: reference to FunctionExpression "d"
- }
這就是 VO -> AO 過程。
- 在定義期(編譯階段):該對象值仍為undefined,且處于不可訪問的狀態。
- 進入執行期(執行階段):VO 被激活,其中變量屬性會進行賦值。
實際上在執行的時候,除了 VO 被激活,活動對象還會添加函數執行時傳入的參數和arguments這個特殊對象,因此 AO 和 VO 的關系可以用以下關系來表達:
- AO = VO + function parameters + arguments
現在,我們知道作用域鏈是在進入代碼的執行階段時,通過外部詞法環境引用來創建的。總結如下:
- 在編譯階段,JavaScript 在創建執行上下文的時候會先創建變量對象(VO);
- 在執行階段,變量對象(VO)被激活為活動對象( AO),函數內部的變量對象通過外部詞法環境的引用創建作用域鏈。
通過作用域鏈,我們可以在函數內部可以直接讀取外部以及全局變量,但外部環境是無法訪問內部函數里的變量。示例如下:
- function foo() {
- var a = 1;
- }
- foo();
- console.log(a); // undefined
我們在全局環境下無法訪問函數foo中的變量a,這是因為全局函數的作用域鏈里,不含有函數foo內的作用域。
如果我們想要訪問內部函數的變量,可以通過函數foo中的函數bar返回變量a,并將函數bar返回,這樣我們在全局環境中也可以通過調用函數foo返回的函數bar,來訪問變量a:
- function foo() {
- var a = 1;
- function bar() {
- return a;
- }
- return bar;
- }
- var b = foo();
- console.log(b()); // 1
當函數執行結束之后,執行期上下文將被銷毀,其中包括作用域鏈和激活對象。
在上面的實例中;當b()執行時,foo函數上下文包括作用域都已經被銷毀了,但是foo作用域下的a依然可以被訪問到;這是因為bar函數引用了foo函數變量對象中的值,此時即使創建bar函數的foo函數執行上下文被銷毀了,但它的變量對象依然會保留在 JavaScript 內存中,bar函數依然可以通過bar函數的作用域鏈找到它,并進行訪問。這就是閉包;
閉包使得我們可以從外部讀取局部變量,常見的用途包括:
- 用于從外部讀取其他函數內部變量的函數;
- 可以使用閉包來模擬私有方法;
- 讓這些變量的值始終保持在內存中。
注意,在使用閉包的時候,需要及時清理不再使用到的變量,否則可能導致內存泄漏問題。
確定 this 的指向
在 JavaScript 中,this指向執行當前代碼對象的所有者,可簡單理解為this指向最后調用當前代碼的那個對象。
根據 JavaScript 中函數的調用方式不同,this的指向分為以下情況。
在全局環境中,this指向全局對象(在瀏覽器中為window)
在函數內部,this的值取決于函數被調用的方式
- 函數作為對象的方法被調用,this指向調用這個方法的對象
- 函數用作構造函數時(使用new關鍵字),它的this被綁定到正在構造的新對象
- 在類的構造函數中,this是一個常規對象,類中所有非靜態的方法都會被添加到this的原型中
在箭頭函數中,this指向它被創建時的環境
使用apply、call、bind等方式調用:根據 API 不同,可切換函數執行的上下文環境,即this綁定的對象
可以看到,this在不同的情況下會有不同的指向,在 ES6 箭頭函數還沒出現之前,為了能正確獲取某個運行環境下this對象,我們常常會使用以下代碼:
- var that = this;
- var self = this;
這樣的代碼將變量分配給this,便于使用。但是降低了代碼可讀性,不推薦使用,通過正確使用箭頭函數,我們可以更好地管理作用域。
總結
今天我們了解了 JavaScript 代碼的運行過程,該過程分為語法分析階段、編譯階段、執行階段三個階段。
在編譯階段,JavaScript會進行執行上下文的創建,在執行階段,變量對象(VO)會被激活為活動對象(AO),變量會進行賦值,此時活動對象才可被訪問。在執行結束之后,作用域鏈和活動對象均被銷毀,使用閉包可使活動對象依然被保留在內存中。這就是 JavaScript 代碼的運行過程。