【譯】理解Javascript函式執行—呼叫棧、事件迴圈、任務等
- 原文作者:Gaurav Pandvia
- 原文連結:medium.com/@gaurav.pan…
- 文中部分連結可能需要梯子。
- 歡迎批評指正。
現如今,web開發者(我們更喜歡被叫做前端工程師)用一門指令碼語言就能做任何事情,從提供瀏覽器中的互動,到開發電腦遊戲、桌面工具、跨平臺移動應用,甚至可以在服務端部署(如最流行的Node.js)來連結任意資料庫。因此,瞭解Javascript的內部構造很重要,這樣才能更優更高效的使用它。這也是本文的主旨所在。
Javascript的生態正在變得越來越複雜。要構建一個現代web應用,會不可避免的用到Webpack、Babel、ESLint、Mocha、Karma、Grunt……我該用哪個?這些都是幹嘛的?我找到了這個漫畫,它完美詮釋瞭如今的web開發者的水深火熱:
在一頭扎進框架和庫的海洋之前,每個Javascript開發者首先需要了解Javascript在底層是如何實現的。差不多每個JS開發者都聽過“V8”這個術語,但有些人可能根本不知道這個詞到底什麼意思、幹嘛用的。在我職業開發生涯的第一年裡,我對這些花裡胡哨的術語所知甚少,我更關心先完成工作。但這樣並不能滿足我的好奇心,我好奇Javascript是他喵的怎麼能做到這一切的。我決定要深挖一番,我翻遍Google,找到一些優秀的部落格,包括Philip Roberts的a great talk at JSConf on the event loop
Javascript是一個單執行緒單併發的語言,也就是說它一次只能處理一個任務,執行一條程式碼。它的呼叫棧連同堆、佇列一起構成了Javascript併發模型(在V8中實現)。讓我們一個個地看這幾個詞。
- 呼叫棧(Call Stack):它是記錄我們在程式中呼叫函式的資料結構。假如我們呼叫一個函式來執行,就是在把某種記錄推入到呼叫棧的頂端;當我們從一個函式中返回出來,就從呼叫棧頂端彈出記錄。
當我們執行上圖中的程式碼,我們會先尋找所有執行的開端——主函式。在上例中,一系列執行開始於console.log(bar(6))
,那麼這一次執行就被推入呼叫棧中,它上面一層就是函式bar
及其引數,函式bar
轉而呼叫函式foo
,foo
也被推入棧中;而foo
隨即return
了某個值,所以被彈出呼叫棧;類似地,bar
隨後彈出,最後console
語句列印了結果並彈出。所有這些舉動都依次發生在須臾之間。
你們肯定都在瀏覽器控制檯見過那個又長又紅的報錯棧,它用一種從上到下的恰如棧的方式,簡單表明了呼叫棧的當前狀態以及在函式中何處報錯(見下圖)。
有時候,當我們以遞迴的形式多次呼叫一個函式,就會陷入無限迴圈中,而對於Chrome瀏覽器來說,它對呼叫棧的大小的限制是16000層,超出限制就會終止程式並丟擲達到棧上限錯誤(見下圖)。
- 堆:物件會被分配到堆——記憶體中的鬆散結構。所有的針對變數和物件的記憶體分配都在堆中進行。
- 佇列:一種Javascript執行時,包含了一個訊息佇列,這個佇列就是一系列將被處理的資訊和要執行的相關回調函式。當呼叫棧有足夠空間,就從佇列中取出一條訊息並進行處理,該訊息呼叫相關聯的函式(並因此產生一個初始化棧層)。當棧再次清空時,訊息處理也就結束了。簡單說,這些訊息被排成佇列,指定回撥函式來響應外部非同步事件(例如滑鼠點選或HTTP請求的響應)。諸如使用者點選按鈕而沒有相應回撥函式的情況,就不會有訊息放入佇列中。
事件迴圈(event loop)
當我們評估JS程式碼的效能時,要知道呼叫棧中的函式會讓程式或快或慢,console.log()
會很快,但用for
或while
迭代成千上萬次就會慢一些,並且讓呼叫棧一直被佔用被阻塞著。這就叫做阻塞指令碼,你可能在Webpage Speed Insights中見過。
網路請求會慢,圖片請求會慢,但萬幸,服務請求可以通過AJAX這種非同步函式完成。假如那些網路請求用同步函式來完成,將會如何?網路請求傳送到伺服器——伺服器也就是某處的某種機器罷了,現在假設伺服器返回響應可能會緩慢,此時,如果我點選一些CTA(call-to-action)按鈕,或者其他一些需要完成的渲染,就不會有什麼反應,因為呼叫棧還被之前的網路請求阻塞著。在Ruby等多執行緒語言中,這種情況可以控制,但像Javascript這種單執行緒語言,除非呼叫棧中的函式返回值,否則就一直堵著。瀏覽器沒有任何反應,網頁就會崩潰。這樣我們可沒辦法為終端使用者提供流暢的使用者介面。那我們怎麼辦?
“JS中的併發——一次只做一件事,非同步回撥除外”
最早的解決方案就是用非同步回撥,這意味著我們給某部分程式碼加一個回撥,該回調會在這段程式碼執行完成後執行。我們肯定都遇到過諸如AJAX請求用的$.get()
、setTimeout()
、setInterval()
、Promises
的非同步回撥。Node都是基於非同步函式執行的。所有那些非同步回撥不會像console.log()
等同步函式那樣立刻執行,而是在之後的某個時刻執行,所以不會立刻就推到呼叫棧中去。那它們到底去哪裡了?怎麼控制它們?
如上例,若一個網路請求在Javascript中執行:
1. 請求函式被執行,給`onreadystatechange`事件傳一個匿名函式作為回撥,用來在將來響應就緒的時候執行。
2. “Script call done!”立刻輸出到控制檯。
3. 後續某時刻,響應被返回,回撥被執行,響應體被輸出到控制檯。
複製程式碼
在等待非同步操作完成並解除回撥執行之時,響應的解耦呼叫允許Javascript執行時做別的事。瀏覽器插入進來呼叫了它的API,這是用C++實現的API,用來建立執行緒以控制諸如DOM事件、http請求、setTimeout等非同步事件。
那些web介面不能自己把執行程式碼推入呼叫棧,如果能,那麼該介面會隨機出現在你的程式碼中(執行順序不可控)。上面討論過的訊息回撥佇列說明了這一點。任何web介面在執行完畢後,都會把回撥推入這個佇列。事件迴圈此時就要負責控制佇列中的回撥的執行,並在棧空時把回撥推入棧中。事件迴圈的基本工作就是監聽呼叫棧和任務佇列,當它看到棧空了,就把佇列中第一個任務推入棧。每個訊息或者回調都在上一個任務處理完再開始處理。
while (queue.waitForMessage()) {
queue.processNextMessage();
}
複製程式碼
在web瀏覽器中,一旦某事件發生並綁定了事件監聽器,訊息就立即新增到佇列中。如果沒有監聽器,那就意味著事件丟失了。因此點選一個綁定了點選事件處理器,就會新增一個訊息,其他事件亦如此。對其回撥的呼叫將會是呼叫棧中的初始層,而由於Javascript是單執行緒的,在呼叫棧中所有呼叫都return
之前,後續的訊息的輪詢和處理就暫停了。之後的(同步的)函式呼叫會向呼叫棧中增加新的呼叫層。
在下一部分,我會通過一個動畫來展示上述過程的程式碼執行,深入解釋什麼是不同型別的非同步函式、佇列中誰優先執行,以及諸如零延遲等功能的技巧。