【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等

?Badd發表於2018-12-25

現如今,web開發者(我們更喜歡被叫做前端工程師)用一門指令碼語言就能做任何事情,從提供瀏覽器中的互動,到開發電腦遊戲、桌面工具、跨平臺移動應用,甚至可以在服務端部署(如最流行的Node.js)來連結任意資料庫。因此,瞭解Javascript的內部構造很重要,這樣才能更優更高效的使用它。這也是本文的主旨所在。

Javascript的生態正在變得越來越複雜。要構建一個現代web應用,會不可避免的用到Webpack、Babel、ESLint、Mocha、Karma、Grunt……我該用哪個?這些都是幹嘛的?我找到了這個漫畫,它完美詮釋瞭如今的web開發者的水深火熱:

Javascript疲勞症——學習Javascript是什麼感覺
Javascript疲勞症——學習Javascript是什麼感覺

在一頭扎進框架和庫的海洋之前,每個Javascript開發者首先需要了解Javascript在底層是如何實現的。差不多每個JS開發者都聽過“V8”這個術語,但有些人可能根本不知道這個詞到底什麼意思、幹嘛用的。在我職業開發生涯的第一年裡,我對這些花裡胡哨的術語所知甚少,我更關心先完成工作。但這樣並不能滿足我的好奇心,我好奇Javascript是他喵的怎麼能做到這一切的。我決定要深挖一番,我翻遍Google,找到一些優秀的部落格,包括Philip Robertsa great talk at JSConf on the event loop。所以我決定總結我的學習經驗並分享出來。鑑於有太多東西要了解,我把本文分為兩個部分。這一部分會介紹常用術語,第二部分則會闡述這些術語之間的關聯。

Javascript是一個單執行緒單併發的語言,也就是說它一次只能處理一個任務,執行一條程式碼。它的呼叫棧連同堆、佇列一起構成了Javascript併發模型(在V8中實現)。讓我們一個個地看這幾個詞。

Visual Representation of JS Model(credits)
Visual Representation of JS Model

  1. 呼叫棧(Call Stack):它是記錄我們在程式中呼叫函式的資料結構。假如我們呼叫一個函式來執行,就是在把某種記錄推入到呼叫棧的頂端;當我們從一個函式中返回出來,就從呼叫棧頂端彈出記錄。

JS Stack Visualization
JS Stack Visualization

當我們執行上圖中的程式碼,我們會先尋找所有執行的開端——主函式。在上例中,一系列執行開始於console.log(bar(6)),那麼這一次執行就被推入呼叫棧中,它上面一層就是函式bar及其引數,函式bar轉而呼叫函式foofoo也被推入棧中;而foo隨即return了某個值,所以被彈出呼叫棧;類似地,bar隨後彈出,最後console語句列印了結果並彈出。所有這些舉動都依次發生在須臾之間。

你們肯定都在瀏覽器控制檯見過那個又長又紅的報錯棧,它用一種從上到下的恰如棧的方式,簡單表明了呼叫棧的當前狀態以及在函式中何處報錯(見下圖)。

Error stack trace
Error stack trace

有時候,當我們以遞迴的形式多次呼叫一個函式,就會陷入無限迴圈中,而對於Chrome瀏覽器來說,它對呼叫棧的大小的限制是16000層,超出限制就會終止程式並丟擲達到棧上限錯誤(見下圖)。

【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等

  1. :物件會被分配到堆——記憶體中的鬆散結構。所有的針對變數和物件的記憶體分配都在堆中進行。
  2. 佇列:一種Javascript執行時,包含了一個訊息佇列,這個佇列就是一系列將被處理的資訊和要執行的相關回撥函式。當呼叫棧有足夠空間,就從佇列中取出一條訊息並進行處理,該訊息呼叫相關聯的函式(並因此產生一個初始化棧層)。當棧再次清空時,訊息處理也就結束了。簡單說,這些訊息被排成佇列,指定回撥函式來響應外部非同步事件(例如滑鼠點選或HTTP請求的響應)。諸如使用者點選按鈕而沒有相應回撥函式的情況,就不會有訊息放入佇列中。

事件迴圈(event loop)

當我們評估JS程式碼的效能時,要知道呼叫棧中的函式會讓程式或快或慢,console.log()會很快,但用forwhile迭代成千上萬次就會慢一些,並且讓呼叫棧一直被佔用被阻塞著。這就叫做阻塞指令碼,你可能在Webpage Speed Insights中見過。

網路請求會慢,圖片請求會慢,但萬幸,服務請求可以通過AJAX這種非同步函式完成。假如那些網路請求用同步函式來完成,將會如何?網路請求傳送到伺服器——伺服器也就是某處的某種機器罷了,現在假設伺服器返回響應可能會緩慢,此時,如果我點選一些CTA(call-to-action)按鈕,或者其他一些需要完成的渲染,就不會有什麼反應,因為呼叫棧還被之前的網路請求阻塞著。在Ruby等多執行緒語言中,這種情況可以控制,但像Javascript這種單執行緒語言,除非呼叫棧中的函式返回值,否則就一直堵著。瀏覽器沒有任何反應,網頁就會崩潰。這樣我們可沒辦法為終端使用者提供流暢的使用者介面。那我們怎麼辦?

“JS中的併發——一次只做一件事,非同步回撥除外”

最早的解決方案就是用非同步回撥,這意味著我們給某部分程式碼加一個回撥,該回撥會在這段程式碼執行完成後執行。我們肯定都遇到過諸如AJAX請求用的$.get()setTimeout()setInterval()Promises的非同步回撥。Node都是基於非同步函式執行的。所有那些非同步回撥不會像console.log()等同步函式那樣立刻執行,而是在之後的某個時刻執行,所以不會立刻就推到呼叫棧中去。那它們到底去哪裡了?怎麼控制它們?

【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等

如上例,若一個網路請求在Javascript中執行:

1. 請求函式被執行,給`onreadystatechange`事件傳一個匿名函式作為回撥,用來在將來響應就緒的時候執行。
2. “Script call done!”立刻輸出到控制檯。
3. 後續某時刻,響應被返回,回撥被執行,響應體被輸出到控制檯。
複製程式碼

在等待非同步操作完成並解除回撥執行之時,響應的解耦呼叫允許Javascript執行時做別的事。瀏覽器插入進來呼叫了它的API,這是用C++實現的API,用來建立執行緒以控制諸如DOM事件、http請求、setTimeout等非同步事件。

那些web介面不能自己把執行程式碼推入呼叫棧,如果能,那麼該介面會隨機出現在你的程式碼中(執行順序不可控)。上面討論過的訊息回撥佇列說明了這一點。任何web介面在執行完畢後,都會把回撥推入這個佇列。事件迴圈此時就要負責控制佇列中的回撥的執行,並在棧空時把回撥推入棧中。事件迴圈的基本工作就是監聽呼叫棧和任務佇列,當它看到棧空了,就把佇列中第一個任務推入棧。每個訊息或者回撥都在上一個任務處理完再開始處理。

while (queue.waitForMessage()) {
  queue.processNextMessage();
}
複製程式碼

【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等
Javascript Event Loop Visual Representation

在web瀏覽器中,一旦某事件發生並繫結了事件監聽器,訊息就立即新增到佇列中。如果沒有監聽器,那就意味著事件丟失了。因此點選一個繫結了點選事件處理器,就會新增一個訊息,其他事件亦如此。對其回撥的呼叫將會是呼叫棧中的初始層,而由於Javascript是單執行緒的,在呼叫棧中所有呼叫都return之前,後續的訊息的輪詢和處理就暫停了。之後的(同步的)函式呼叫會向呼叫棧中增加新的呼叫層。

在下一部分,我會通過一個動畫來展示上述過程的程式碼執行,深入解釋什麼是不同型別的非同步函式、佇列中誰優先執行,以及諸如零延遲等功能的技巧。

相關文章