JavaScript 執行原理解析

鈞嘢嘢發表於2018-01-16

說到JavaScript的執行原理,自然繞不開JS引擎,執行上下文,單執行緒,事件迴圈,事件驅動,回撥函式等概念。本文主要參考文章[1,2]。

為了更好的理解JavaScript如何工作的,首先要理解以下幾個概念。

  • JS Engine(JS引擎)
  • Runtime(執行上下文)
  • Call Stack (呼叫棧)
  • Event Loop(事件迴圈)
  • Callback (回撥)

1.JS Engine

簡單來說,JS引擎主要是對JS程式碼進行詞法、語法等分析,通過編譯器將程式碼編譯成可執行的機器碼讓計算機去執行。

目前最流行的JS引擎非V8莫屬了,Chrome瀏覽器和Node.js採用的引擎就是V8引擎。引擎的結構可以簡單由下圖表示:

JS Engine 結構

就如JVM虛擬機器一樣,JS引擎中也有堆(Memory Heap)和棧(Call Stack)的概念。

  • 棧。用來儲存方法呼叫的地方,以及基礎資料型別(如var a = 1)也是儲存在棧裡面的,會隨著方法呼叫結束而自動銷燬掉(入棧-->方法呼叫後-->出棧)。

  • 堆。JS引擎中給物件分配的記憶體空間是放在堆中的。如var foo = {name: 'foo'} 那麼這個foo所指向的物件是儲存在堆中的。

此外,JS中存在閉包的概念,對於基本型別變數如果存在與閉包當中,那麼也將儲存在堆中。詳細可見此處1,3

關於閉包的情況,就涉及到Captured Variables。我們知道Local Variables是最簡單的情形,是直接儲存在棧中的。而Captured Variables是對於存在閉包情況和with,try catch情況的變數。

function foo () {
  var x; // local variables
  var y; // captured variable, bar中引用了y

  function bar () {
  // bar 中的context會capture變數y
    use(y);
  }

  return bar;
}
複製程式碼

如上述情況,變數y存在與bar()的閉包中,因此y是captured variable,是儲存在堆中的。

2.RunTime

JS在瀏覽器中可以呼叫瀏覽器提供的API,如window物件,DOM相關API等。這些介面並不是由V8引擎提供的,是存在與瀏覽器當中的。因此簡單來說,對於這些相關的外部介面,可以在執行時供JS呼叫,以及JS的事件迴圈(Event Loop)和事件佇列(Callback Queue),把這些稱為RunTime。有些地方也把JS所用到的core lib核心庫也看作RunTime的一部分。

The RunTime

同樣,在Node.js中,可以把Node的各種庫提供的API稱為RunTime。所以可以這麼理解,Chrome和Node.js都採用相同的V8引擎,但擁有不同的執行環境(RunTime Environments)[4]。

3.Call Stack

JS被設計為單執行緒執行的,這是因為JS主要用來實現很多互動相關的操作,如DOM相關操作,如果是多執行緒會造成複雜的同步問題。因此JS自誕生以來就是單執行緒的,而且主執行緒都是用來進行介面相關的渲染操作 (為什麼說是主執行緒,因為HTML5 提供了Web Worker,獨立的一個後臺JS,用來處理一些耗時資料操作。因為不會修改相關DOM及頁面元素,因此不影響頁面效能),如果有阻塞產生會導致瀏覽器卡死。

如果一個遞迴呼叫沒有終止條件,是一個死迴圈的話,會導致呼叫棧記憶體不夠而溢位,如:

function foo() {
    foo();
}
foo();
複製程式碼

例子中foo函式迴圈呼叫其本身,且沒有終止條件,瀏覽器控制檯輸出呼叫棧達到最大呼叫次數。

call_stack_overflow

JS執行緒如果遇到比較耗時操作,如讀取檔案,AJAX請求操作怎麼辦?這裡JS用到了Callback回撥函式來處理。

對於Call Stack中的每個方法呼叫,都會形成它自己的一個執行上下文Execution Context,關於執行上下文的詳細闡述請看這篇文章

4.Event Loop & Callback

JS通過回撥的方式,非同步處理耗時的任務。一個簡單的例子:

var result = ajax('...');
console.log(result);
複製程式碼

此時並不會得到result的值,result是undefined。這是因為ajax的呼叫是非同步的,當前執行緒並不會等到ajax請求到結果後才執行console.log語句。而是呼叫ajax後請求的操作交給回撥函式,自己是立刻返回。正確的寫法應該是:

ajax('...', function(result) {
    console.log(result);
})
複製程式碼

此時才能正確輸出請求返回的結果。

JS引擎其實並不提供非同步的支援,非同步支援主要依賴於執行環境(瀏覽器或Node.js)。

So, for example, when your JavaScript program makes an Ajax request to fetch some data from the server, you set up the “response” code in a function (the “callback”), and the JS Engine tells the hosting environment: “Hey, I’m going to suspend execution for now, but whenever you finish with that network request, and you have some data, please call this function back.”

The browser is then set up to listen for the response from the network, and when it has something to return to you, it will schedule the callback function to be executed by inserting it into the event loop.

上面這兩段話摘自於How JavaScript works,以通俗的方式解釋了JS如何呼叫回撥函式實現非同步處理。

所以什麼是Event Loop?

Event Loop只做一件事情,負責監聽Call Stack和Callback Queue。當Call Stack裡面的呼叫棧執行完變成空了,Event Loop就把Callback Queue裡面的第一條事件(其實就是回撥函式)放到呼叫棧中並執行它,後續不斷迴圈執行這個操作。

一個setTimeout的例子以及對應的Event Loop動態圖

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');
複製程式碼

event_loop動態圖

setTimeout有個要注意的地方,如上述例子延遲5s執行,不是嚴格意義上的5s,正確來說是至少5s以後會執行。因為Web API會設定一個5s的定時器,時間到期後將回撥函式加到佇列中,此時該回撥函式還不一定會馬上執行,因為佇列中可能還有之前加入的其他回撥函式,而且還必須等到Call Stack空了之後才會從佇列中取一個回撥執行。

所以常見的setTimeout(callback, 0) 的做法就是為了在常規的呼叫介紹後馬上執行回撥函式。

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');
// 輸出
// Hi
// Bye
// callback
複製程式碼

在說一個容易犯錯的栗子:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000 * i);
}
	
// 輸出:5 5 5 5 5
複製程式碼

上面這個栗子並不是輸出0,1,2,3,4,第一反應覺得應該是這樣。但梳理了JS的時間迴圈後,應該很容易明白。

呼叫棧先執行 for(var i = 0; i < 5; i++) {...}方法,裡面的定時器會到時間後會直接把回撥函式放到事件佇列中,等for迴圈執行完在依次取出放進呼叫棧。當for迴圈執行完時,i的值已經變成5,所以最後輸出全都是5。

關於定時器又可以看看這篇有意思的文章

最後關於Event Loop,可以參考下這個視訊。到目前為止說的event loop是前端瀏覽器中的event loop,關於Nodejs的Event Loop的細節闡述,請看我的另一篇文章Node.js design pattern : Reactor (Event Loop)。兩者的區別對比可檢視這篇文章你不知道的Event Loop,對兩種event loop做了相關總結和比較。

總結

最後總結一下,JS的執行原理主要有以下幾個方面:

  • JS引擎主要負責把JS程式碼轉為機器能執行的機器碼,而JS程式碼中呼叫的一些WEB API則由其執行環境提供,這裡指的是瀏覽器。

  • JS是單執行緒執行,每次都從呼叫棧出取出程式碼進行呼叫。如果當前程式碼非常耗時,則會阻塞當前執行緒導致瀏覽器卡頓。

  • 回撥函式是通過加入到事件佇列中,等待Event Loop拿出並放到呼叫棧中進行呼叫。只有Event Loop監聽到呼叫棧為空時,才會從事件佇列中從隊頭拿出回撥函式放進呼叫棧裡。

主要參考

1.How JavaScript works: an overview of the engine, the runtime, and the call stack

2.How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await

3.Philip Roberts: What the heck is the event loop anyway?

相關文章