【譯】JS執行時環境

Goldbeener發表於2019-03-03

原文地址: The Javascript Runtime Environment

原文作者: Jamie Uttariello

譯者語:
本文是在學習的過程中發現的一篇講述JS機制比較明瞭的文章,因此嘗試翻譯了一下。
不是專業的,因此難免有偏頗,歡迎交流指正。
複製程式碼

通過本文,我們一起了解一下瀏覽器的JS執行時環境,探究Chrome瀏覽器V8引擎是如何解析程式碼,以及事件迴圈(Event Loop)機制是如何實現在JS單執行緒中以同步的方式以及某種意義上的非同步的方式執行程式碼。最後,通過一個常見的例子來更加清楚的解釋一下這一系列過程是如何進行的。

回到最初的起點

當你用諸如chrome、火狐、Edge或者Safari等瀏覽器訪問一個wed站點時,事實上每個瀏覽器都有一個JS執行時環境。瀏覽器對外暴露的供開發者使用的Web API就位於其中。

AJAX、DOM樹、以及其他的API,都是Javascript的一部分,它們本質上就是瀏覽器提供的、在JS執行時環境中可呼叫的、擁有一些列屬性和方法的物件

除此之外,用來解析程式碼的Javascript引擎也是位於JS執行時環境中的。每一個瀏覽器的JS引擎都有自己的版本。Chrome瀏覽器用的是自產的V8引擎,後文中我們將以它為例進行分析。

V8 JS引擎

當Chrome接收到JS程式碼或網頁上的指令碼,V8引擎就開始解析工作。首先,它會檢查語法錯誤,如果沒有,按編寫順序解讀程式碼最終的目標是將JS程式碼轉換成計算機可以識別的機器語言。但是,在我們搞清楚JS引擎到底做了什麼來解析程式碼之前,我們首先需要知道解析工作所發生的環境。

JS執行時環境

我們可以把JS的執行時環境看作一個大的容器,裡面有一些其他的小容器。當JS引擎解析程式碼時,就是把程式碼片段分發到不同的容器裡。

JS執行時示意圖

執行時環境中的第一個容器就是堆記憶體,它也是V8引擎的一部分。當V8引擎遇到變數宣告和函式宣告的時候,就把它們儲存在裡面。

環境中的第二個容器叫做呼叫棧, 它也是V8引擎的一部分。當引擎遇到像函式呼叫之類的可執行單元,就會把它們推入呼叫棧

當函式一被推入執行棧,JS引擎就開始解析函式體,在堆裡建立變數、把新的函式呼叫推入棧頂、或者把自身分發給WEB API呼叫所在的第三個容器。

當函式有了返回值,或者被分發到Web API容器,它就會被彈出棧同時下一個函式呼叫會被推入棧頂。如果JS引擎執行完一個函式,並且該函式沒有明確的指明返回值,JS引擎會預設的返回undefined然後再將之彈出棧。人們通常說的JS同步執行指的就是JS引擎解析函式然後彈出棧(再執行下一個函式)的執行流程。簡言之,在單執行緒下同一時間只做一件事。

Note:棧是一種LOFO的資料結構 - 後進先出。只有棧頂的函式會被處理,除非前一個函式(處理完畢)被彈出棧,否則JS引擎不會去處理下一個函式。

Web API容器

呼叫棧內的Web API呼叫會被分發到Web API容器內,比如事件監聽函式、HTTP/AJAX請求、或者是定時器函式,這些事件會在該容器內直到達到觸發條件。要麼是一個點選事件被觸發、或者是HTTP請求完成從資料來源獲取資料、或者是定時器達到觸發的時間點,一旦達到觸發條件,一個回撥函式就會被推入第四個也是最後一個容器: 回撥佇列。

回撥佇列

回撥佇列會按照新增的順序儲存所有的回撥函式,然後等待執行棧為空。當執行棧為空的時候,回撥佇列會把佇列首部的那個回撥函式推入執行棧。當執行棧再次為空的時候,再將此時佇列首部函式推入。

Note: 佇列是一個FIFO的資料結構-先進先出。相比於棧的在尾部新增、移除資料,佇列是在尾部新增資料,在首部移除資料。

事件迴圈

事件迴圈可以被看作是JS執行時環境中的這樣的一個東西:它的工作是持續的檢測呼叫棧和回撥佇列,如果檢測到呼叫棧為空,它就會通知回撥佇列把佇列中的第一個回撥函式推入執行棧。呼叫棧和執行佇列可能在某一段時間內是閒置的,但是事件迴圈是永不停歇的檢測前兩者的。在任意時間,只要Web API容器中的事件達到觸發條件,就可以把回撥函式新增到回撥佇列中去。

這就是人們常說的***JS可以以非同步的方式執行***的含義。這種說法事實上是不正確的,只是看起來像那麼回事兒。JS在同一時間只能執行一個函式,無論在棧頂的是什麼,它是一個同步語言。但是因為Web API模組可以不斷的向回撥佇列新增回撥函式,而回撥佇列又可以不斷的把回撥函式函式推入執行棧,我們可以認為JS是在非同步執行。這就的是這門語言的強大之處。只擁有同步的能力,卻能夠以非同步的方式執行,像魔法一樣!

阻塞與非阻塞I/O

當我們談起阻塞I/O,想象一個函式在被無限迴圈呼叫。當函式永不停止的執行時,它就永遠不會被推出棧,因此棧內的下一個函式就會被阻塞的永遠無法被呼叫。另一種情況是一個有極其複雜的邏輯和演算法的函式,它必然會花費大量的時間來執行,那麼就會阻塞下一個函式的執行。上述的會造成阻塞場景是我們編碼的時候需要知道的,但是相比於語言的的設計缺點,還會有更多的編碼錯誤和糟糕的寫法會造成阻塞。

常見的一個阻塞I/O的操作就是HTTP請求,例如向某個外部網站傳送資料請求,你必須等待該網站的迴應。而可能永遠得不到迴應,那麼你的程式碼就會被阻塞。好在JS執行時環境中會處理這種情況。它把HTTP請求分發到Web API模組,然後把請求操作彈出棧,這樣當請求在Web API模組內等待響應資料的時候,執行棧內的下一個函式就可以被執行。即使請求無法得到資料,程式的其他部分也可以正常執行。這就是我們所說的JS是一個非阻塞的語言。

一個典型的例子

很多教學視訊和文章都會用類似這樣的例子來解釋JS執行時環境的工作機制:

setTImeout(function(){
    console.log('Hey, Why am I last?')
}, 0)

function sayHi(){
    console.log('Hello')
}

function sayBye(){
    console.log('Goodbye')
}

sayHi()
saybye()
複製程式碼

如果你把這段程式碼貼上在控制檯,將會看到先列印出'Hello', 然後是'Goodbye', 然後是undefined,最後是'Hey, why am I last?'. 儘管setTimeout函式最先被呼叫並且延遲0ms執行,但是它卻是最後輸出的。逐行檢查程式碼嘗試理解JS引擎解析程式碼的機制。嘗試理解為什麼setTimeout函式在sayHi和sayBye函式之後列印輸出。

思考完畢,我們一起來看看V8JS引擎到底是怎樣處理這段程式碼的...

  1. JS引擎會檢查整段程式碼的語法錯誤,如果沒有錯誤,就從頭開始深度解析

  2. 首先遇到setTimeout函式呼叫,把它推入執行棧頂

  3. 解析函式體,發現setTimeout函式是Web API的一種,因此就把它分發到Web API模組然後推出棧

  4. 因為定時器設定了0ms延遲,因此Web API模組立即把它的匿名回撥函式推入到回撥函式函式佇列。事件迴圈檢測執行棧是否是空閒,但是當前棧並不空閒,因為...

  5. (6) 當setTimeout函式一被分發到Web API模組,JS引擎發現了兩個函式宣告,把它們儲存在堆記憶體裡,然後遇到了sayHi函式的呼叫,就把它推入了棧頂

  6. 同5同時

  7. sayHi函式呼叫了console.log函式,因此console.log就被推入了棧頂

  8. JS引擎開始解析console.log的函式體,它接收了一個訊息去列印‘Hi’,然後被彈出棧

  9. JS引擎返回到函式sayHi的執行,遇到函式的結束符號}之後,把它彈出棧

  10. sayHi函式一出棧,緊接著sayBye函式被呼叫,它就被推入棧頂,被解析,呼叫console.log,把console.log推入棧頂,列印一條訊息,彈出棧。然後sayBye函式彈出棧

  11. 同10同時發生

  12. 同10同時發生

  13. 事件迴圈檢測到執行棧終於空閒了,通知回撥佇列,然後回撥佇列把其中的匿名函式推入執行棧

  14. 匿名函式(就是setTimeout的回撥函式)被解析、呼叫console.log,console.log推入棧頂

  15. console.log執行完畢、再出棧

  16. 匿名函式再被推出棧,程式結束。

PS:如果你複製程式碼在控制檯列印,你會發現有一個undefined被輸出,這是因為程式中所有的主函式都沒有返回值,它們只是呼叫console.log函式,當log函式被執行並彈出之後,解析器執行至主函式的結尾,並沒有發現返回值。因此它返回undefined,然後把函式彈出棧。

瀏覽器環境vsNode.js環境

需要注意的時,本文中所討論的環境是瀏覽器下的JS執行環境。雖然Node.js也是用GoogleV8引擎驅動的,但是它提供了一個完全不一樣的執行時環境. Node.js 不會提供DOM樹、AJAX、以及其他的Web API。但是,在Node環境下你可以安裝你需要的包 來構建你的程式。

相關文章