JavaScript中執行環境和棧

在這篇文章中,我會深入理解JavaScript最根本的組成之一 : “執行環境(執行上下文)”。文章結束後,你應該對解釋器試圖做什麼,爲什麼一些函數/變量在未聲明時就可以調用並且他們的值是如何確定的有一個清晰的認識。

什麼是執行環境(執行上下文)
當代碼在JavaScript中運行的時候,代碼在環境中被執行是非常重要的,它會被評估爲以下之一類型來運行:
全局代碼:默認環境,你的代碼第一時間在這兒運行。
函數代碼:當執行流進入一個函數體的時候。
Eval代碼:在eval()函數中的文本。

你可以在網上查找關於作用域的大量資料,這篇文章的目的就是讓事情變得更容易理解。讓我們把執行環境作爲環境/作用域,當前代碼被評估在這個環境/作用域中。現在,讓我們來看一個例子,代碼被評估某個類型,這個例子中類型包括全局和函數環境:

這裏並沒有什麼特別的,我們有一個全局環境,全局環境由紫色邊框表示,還有三個不同的函數環境分別由綠色邊框,藍色邊框和橙色邊框表示。這裏只能由一個全局環境,在你的程序中,全局環境可以被其他環境訪問。

你可以由很多的函數環境,每個函數都會創建一個新的函數環境,在新的函數環境中,會創建一個私有作用域,在這個函數中創建的任何聲明都不能被當前函數作用域之外的地方訪問。在上面例子中,一個函數可以訪問當前環境外部定義的變量,但是在外部卻無法訪問函數內部聲明的變量。爲什麼這樣?這段代碼究竟是如何評估的?

執行環境棧

JavaScript解釋器在瀏覽器中是單線程的,這意味着瀏覽器在同一時間內只執行一個事件,對於其他的事件我們把它們排隊在一個稱爲 執行棧的地方。下表是一個單線程棧的抽象視圖。

我們已經知道,當瀏覽器第一次加載你的script,它默認的進了全局執行環境。如果在你的全局代碼中你調用了一個函數,那麼順序流就會進入到你調用的函數當中,創建一個新的執行環境並且把這個環境添加到執行棧的頂部。

如果你在當前的函數中調用了其他函數,同樣的事會再次發生。執行流進入內部函數,並且創建一個新的執行環境,把它添加到已經存在的執行棧的頂部。瀏覽器始終執行當前在棧頂部的執行環境。一旦函數完成了當前的執行環境,它就會被彈出棧的頂部, 把控制權返回給當前執行環境的下個執行環境。下面例子展示了一個遞歸函數和該程序的執行棧:

(function foo(i) {
if (i === 3) {
return;
}
else {
foo(++i);
}
}(0));

這段代碼簡單地調用了自己三次,由1遞增i的值。每次函數foo被調用,一個新的執行環境就會被調用。一旦一個環境完成了執行,它就會被彈出執行棧並且把控制權返回給當前執行環境的下個執行環境直到再次到達全局執行環境。

記住執行棧,這兒有五個關鍵點

  1. 單線程
  2. 同步執行
  3. 一個全局環境
  4. 無限的函數環境
  5. 函數被調用就會創建一個新的執行環境,甚至調用自己。

執行環境的詳情

現在我們直到,一個函數被調用就會創建一個新的執行環境。然而解釋器的內部,每次調用執行環境會有兩個階段:

  1. 創建階段
  • 當函數被調用,但是爲執行內部代碼之前:
  • 創建一個作用域鏈
  • 創建變量,函數和參數。
  • 確定this的值。
  1. 激活/代碼執行階段
  • 賦值,引用函數,解釋/執行代碼。

這可能意味着每個執行環境在概念上作爲一個對象並帶有三個屬性

executionContextObj = {
scopeChain: { /* variableObject + all parent execution context's variableObject */ },
//作用域鏈:{變量對象+所有父執行環境的變量對象}
variableObject: { /* function arguments / parameters, inner variable and function declarations */ },
//變量對象:{函數形參+內部的變量+函數聲明(但不包含表達式)}
this: {}
}

活動/變量 對象(AO/VO)

當函數被調用,executionContextObj就被創建,該對象在實際函數執行前就已創建。這就是已知的第一個階段創建階段.在第一階段,解釋器創建了executionContextObj對象,通過掃描函數,傳遞形參,函數聲明和局部變量聲明。掃描的結果成爲了變量對象在executionContextObj中。

  • 這有一個解釋器是如何評估代碼的僞概述:
  1. 找到一些代碼來調用函數
  2. 在執行函數代碼前,創建執行環境
  3. 進入創建階段:
  • 初始化作用域鏈
  • 創建變量對象:
  • 創建arguments對象,檢查環境中的參數,初始化名和值,創建一個參考副本
  • 掃描環境中內的函數聲明:
  • 某個函數被發現,在變量對象創建一個屬性,它是函數的確切名。它是一個指針在內存中,指向這個函數。
  • 如果這個函數名已存在,這個指針的值將會重寫。
  • 掃描環境內的變量聲明
  • 某個變量聲明被發現,在變量對象中創建一個屬性,他是變量的名,初始化它的值爲undefined。
  • 如果變量名在變量對象中已存在,什麼也不做,繼續掃描。
  • 在環境中確定this的值。
  1. 激活/代碼執行階段:在當前上下文上運行/解釋函數代碼,並隨着代碼一行行執行指派變量的值

看下面例子:

function foo(i) {
var a = 'hello';
var b = function privateB() {

};
function c() {

}
}

foo(22);

On calling foo(22), the creation stage looks as follows:
在調用foo(22)時,創建階段像下面這樣:

fooExecutionContext = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: undefined,
b: undefined
},
this: { ... }
}

正如你看到的,創建階段處理了定義屬性的名,但是並不把值賦給變量,不包括形參和實參。一旦創建階段完成,執行流進入函數並且激活/代碼執行階段,在函數執行結束之後,看起來像這樣:

fooExecutionContext = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: 'hello',
b: pointer to function privateB()
},
this: { ... }
}

進階一言

你可以在網上找到大量的術語來描述JavaScript進階。解釋變量和函數聲明被提升到它們函數作用域的頂端。然而,沒有一個詳細的解釋爲什麼這樣, 現在你配備了關於解釋器怎麼創建活動對象的新知識,這會很明白這是爲什麼。看看下面例子:

(function() {

console.log(typeof foo); // function pointer
console.log(typeof bar); // undefined

var foo = 'hello',
bar = function() {
return 'world';
};

function foo() {
return 'hello';
}

}());

現在我們能解答的問題有:

爲什麼在聲明foo之前我們就可以調用?
如果我們按照創建階段進行,我們知道變量在激活/執行階段之前已經被創建了。因此,在函數流開始執行,foo已經在活動對象中被定義了。
foo被聲明瞭兩次, 爲什麼foo展現出來的是functiton,而不是undefined或者string
我們從創建階段知道,儘管foo被聲明瞭兩次,函數在活動對象中是在變量之前被創建的,並且如果屬性名在活動對象已經存在,我們會簡單地繞過這個聲明。

所以,引用函數foo()是在活動對象上第一次被創建的, 當我們解釋到 var foo的時候,我們發現屬性名foo已經存在,所以代碼不會做任何處理,只是繼續進行

爲什麼bar是undefined?

bar確實是一個變量,並且值是一個函數。我們知道變量是在創建階段被創建的,但是它們的值被初始化爲undefined。

總結:
希望現在你能很好的理解JavaScript解釋器是如何評估你的代碼。
瞭解執行環境和棧可以讓你知道你的代碼評估的值爲何與你預期值不同的原因。

你認爲解釋器內部工作開支太多?或者你應該有必要的JavaScript知識?瞭解了執行環境可能會幫助你寫出更好的JavaScript?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章