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

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