JS的作用域是一個老生常談的話題,本文將深入探討它內部的原理。在正文開始之前,我們先來了解一下和作用域相關的幾個重要的知識點。
JS執行的三個階段
JS引擎運行JS代碼分為三個階段:
語法分析階段
該階段對js代碼塊的語法進行分析:如果發現語法不正確,就向外拋出一個語法錯誤(SyntaxError),停止該js代碼塊的執行,然後繼續查找並加載下一個代碼塊;如果語法正確,則進入預編譯階段。
預編譯階段
在預編譯階段,JS引擎會為代碼創建相應的“執行上下文”。
執行環境
“執行上下文”即“執行環境”,為了簡化概念,我們統稱為“執行環境”,JS引擎在運行JS代碼的時候,會給全局代碼、每個函數、eval函數包裹的代碼創建相應的“執行環境”,並在執行階段將他們“壓”入“執行棧”中執行。共有三種類型的執行環境:
- 全局執行環境
- 函數執行環境
- eval執行環境
而創建執行環境時主要做了以下三件事情:
創建變量對象
創建變量對象主要是經過以下過程,如圖所示:
- 創建arguments對象,檢查當前上下文的參數,建立該對象的屬性與屬性值,僅在函數環境(非箭頭函數)中進行的,全局環境沒有此過程。
- 檢查當前上下文的函數聲明,按照代碼順序查找,將找到的函數提前聲明,如果當前上下文的變量對象沒有該函數名屬性,則在該變量對象以函數名建立一個屬性,屬性值則指向該函數所在堆內存地址引用,如果存在,則會被新的引用覆蓋掉。
- 檢查當前上下文的變量聲明,按代碼順序查找,將找到的變量提前聲明,如果當前上下文的變量對象沒有變量名屬性,則在該變量對象以變量名建立一個屬性,屬性值為undefined;如果存在,則忽略該變量聲明。
函數聲明提前和變量聲明提升是在創建變量對象中進行的,且函數聲明優先級高於變量聲明。
創建作用域鏈
作用域鏈由當前執行環境的活動對象和上層(外層)執行環境的活動對象組成,是一種有序列表或鏈表結構。
確定this的指向。
這一階段比較複雜,其實我們只要記住,對於一個函數來説,誰調用它,this就指向誰;如果沒有指定調用方, 在瀏覽器環境下,this就指向window(嚴格模式下指向undefined)
執行階段
預編譯階段結束後進入執行階段。
在執行階段,JS引擎會將上一階段創建的執行環境推入“執行棧”中執行,此時執行環境中的變量對象上的屬性按順序得到賦值,變成“活動對象”。
在這一階段,會把當前活動對象添加到作用域鏈的前端(起始位置),在訪問一個變量時,會沿着作用域鏈一個個地查找,直到找到為止。若直到最後都找不到會拋出“ReferenceError”錯誤。
下面進入正文。
要理解作用域和作用域鏈,其實只需記住一句話即可:作用域是由函數聲明的位置決定的。
每個函數的執行環境中維護着一個內部變量(只有JS內部可訪問),這個變量指向外部執行環境,我們稱之為outer。作用域鏈正是由outer指向的執行環境的活動對象組成。瀏覽器中,window對象作為作用域鏈的最後一個查找對象,而node.js中這個對象是global。
為了更好地示意,我們以一段代碼為例:
var a = 1 function
fnA(){
a = 2
fnB(3)
console.log(a)
}
function fnB(a){
console.log(a)
var a = 4
console.log(a)
}
fnA()
console.log(a)
相信很多同學根據經驗都能很容易給出上面代碼的輸出值。但從JS運行原理的角度,我們怎麼理解這樣輸出的原因呢?下面我們一步步分析,揭開它的神秘面紗。
這段代碼我們很容易得出,fnA和fnB的執行環境中的outer都是全局執行環境,全局執行環境是最外層的環境。
首先,JS引擎對全局代碼進行語法分析,沒有發現語法錯誤,進入預編譯階段。
在預編譯階段,為全局代碼創建全局執行環境,根據上文講的規則,其結構用我們最熟悉的JS代碼表示如下:
// 注意,執行上下文和其中的變量對象、活動對象、作用域鏈都是JS引擎內部使用的,外部
//(也就是我們編寫JS代碼時)無法訪問,只有this可以通過“this”關鍵字訪問
globalContext = {
VO: { // 變量對象
fnA: function(){ }, // 對應函數體,略
fnB: function(){ }, // 對應函數體,略
a:undefined
},
scope:[window],//outer=window
this:window
}
ps:這裏用JS是為了更好地説明,實際JS引擎是由更底層的語言編寫的。下文的分析為了便於理解簡化了一些細節,須知悉。
做完這些事情,進入執行階段,此時JS引擎將全局執行環境“壓入”執行棧中執行,變量對象上的屬性根據順序賦值,變成活動對象:
globalContext = {
AO: { // 活動對象
fnA: { }, // 對應函數體,略
fnB: { }, // 對應函數體,略
a:1
},
scope:[globalContext.AO,window],
this:window
}
執行全局代碼時遇到fnA(),將fnA的函數體取出,對其中的函數代碼進行語法分析,然後進行預編譯,創建fnA的執行環境:
fnAContext = {
VO: {}, // 變量對象,沒有聲明任何函數和變量
scope: [globalContext.AO,window], // fnAContext中的 outer=globalContext
this: window
}
進入執行階段:
fnAContext = {
AO: {}, // 變量對象,沒有聲明任何函數和變量
scope: [fnAContext.AO,globalContext.AO,window], // 作用域鏈
this: window
}
執行時遇到賦值語句a = 2,在作用域鏈上的globalContext.AO找到a。將其值變為2。到此為止,全局變量a的值已被改變。
接着遇到fnB(3),以同樣方式,最終在globalContext.AO上找到fnB,將其函數代碼取出,進行語法分析、預編譯。
fnB預編譯結果:
fnBContext = {
VO: {
a:undefined, // arguments.a 重複的聲明var a 被忽律
},
scope: [globoalContext.AO,window], // fnBContext中的outer = globalContext
this: window
}
接着進入執行階段:
fnBContext = {
AO: {
a:3, // arguments.a傳入值3
},
scope: [fnBContext.AO,globoalContext.AO,window], // 作用域鏈
this: window
}
fnB第一句代碼輸出a的值,我們在很幸運找到了fnBContext.AO.a,此時它已經被傳入值賦值為3,因此這裏console.log(a)輸出值為3。接着執行時遇到賦值語句a = 4,fnBContext.AO.a的值改變為4,所以下一句的console.log(a)輸出值為4
接着,fnB(3)執行完畢,其執行環境被彈出,棧指針下移動,回到fnA的執行環境,繼續執行下一條語句console.log(a),根據前文分析我們得知其輸出的是globalContext.AO.a的值,因此輸出改變後的值2。
此時回到全局執行環境繼續執行後面的代碼console.log(a),輸出改變後的globalContext.AO.a的值2。
到這裏,全局代碼也執行完了,我們得出所有的輸出是:
3
4
2
2
你猜對了嗎?
最後簡單一提,JS中的with和try-catch語句中的catch所包含的代碼會臨時創建局部的作用域,將作用域鏈延長,在這些代碼執行完後局部的作用域會被銷燬,在寫代碼時需要額外注意,如下面的代碼:
var message = 'hello'
with({message:'hello width'}){
console.log(message)
}
console.log(message)
try{
doSomething('hello')
}catch(err){
console.log(err.message)
}
with語句很好理解,即with緊跟的括號裏的對象被添加到了作用域鏈的前端(第一個位置);而try-catch語句,我們可以這麼理解:當try包裹的發生錯誤或者我們主動拋出錯誤時,我們在catch語句怎麼獲取這個錯誤呢?答案就是,JS引擎幫我們將這個錯誤放到了作用域鏈的前端。如上面的代碼,若原本的作用域鏈為[AO3,AO2,AO1],那麼執行到catch語句時,作用域鏈就變成了[{err:{...}},AO3,AO2,AO1],這樣我們自然就能讀取到它了。
總結
- JS獲取一個變量時是沿着當前所處執行環境的作用域鏈上依次查找的,直到找到為止或找不到拋出錯誤。
- JS運行代碼時會創建相應的執行環境並壓入執行棧中執行,執行棧中執行環境的順序決定了變量的查找順序。
- JS代碼的書寫結構決定了JS代碼的執行時執行環境的順序。
- var聲明的變量會和函數聲明會有聲明提升的現象,順序為函數聲明在前,var聲明在後
- width表達式和try-catch語句會延長作用域鏈
參考:
1.《JS引擎線程的執行過程的三個階段(一)》:https://www.cnblogs.com/BoatGina/p/10433518.html
2.《JavaScript高級程序設計第三版》