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)建的』倏荩總結(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 代碼的運行過程借卧。