請說明瀏覽器中的事件循環 (Event Loop)

2022年10月21日

💎 加入 E+ 成長計畫 與超過 300+ 位軟體工程師一同在社群中成長,並且獲得更多的軟體工程學習資源

事件循環 (Event loop) 絕對是一定要準備的面試經典題之一、衍伸題型也相當多元,讀者一定要對此概念有一定程度的掌握。在本篇文章中,我們將整理事件循環 (Event loop) 的基本觀念,如果想練習事件循環的模擬考題,可以前往《最常見的事件循環 (Event Loop) 面試題目彙整》一文。

同步 (synchronous)異步 (asynchronous)

在討論事件循環前,我們需要先了解同步與異步的概念。JavaScript 是單執行緒的程式語言,一行程式碼執行完才會再執行下一行,這個概念稱之為同步 (synchronous)。但這樣其實會遇到一個問題,試想一個情境:假設有一網站需要去伺服器端拿取資料,但需要等十秒之後才會拿到,此外等待途中網站無法執行任何動作,這對於使用者來說,會認為畫面定格十秒鐘、就像當機一樣,絕對是很糟糕的使用者體驗,於是就有了異步 (asynchronous) 的概念。

異步的程式碼或事件,並不會阻礙主線程執行其他程式碼,以上面網站向伺服器拿取資料為例,拿取資料當作是一個異步事件,異步事件會在完成之後再通知主線程,而在這之中,主線程可以繼續執行其他程式碼、使用者互動也不受異步事件的阻擋。而瀏覽器或其他的執行環境 (例如 Node.js) 之所以能夠實踐異步,正是因為有事件循環 (Event loop) 的機制。透過事件循環機制,能有效解決 JavaScript 單執行緒的問題,讓耗時的操作不會阻塞主線成。

事件循環 (Event loop) 的組成 - 執行和任務隊列

事件循環不存在 JavaScript 本身,而是由 JavaScript 的執行環境 (瀏覽器或 Node.js) 來實現的,其中包含幾個概念:

  • 堆 (Heap):堆是一種數據結構,拿來儲存物件
  • 棧 (Stack):採用後進先出的規則,當函式執行時,會被添加到棧的頂部,當執行完成時,就會從頂部移出,直到棧被清空
  • 隊列 (Queue):也是一種數據結構,特性是先進先出 (FIFO)。在 JavaScript 的執行環境中,等待處理的任務會被放在隊列(Queue) 裡面,等待棧 (Stack) 被清空時,會從隊列(Queue)中拿取第一個任務進行處理
  • 事件循環 (Event loop):事件循環會不斷地去查看棧(Stack) 是否空出,如果空出就會把隊列 (Queue)中等待的任務放進棧(Stack)中執行
Event Loop 的堆(Heap)、棧(Stack)和隊列(Queue)
事件循環的堆(Heap)、棧(Stack)和隊列(Queue)

事件循環 (Event loop)

整個事件循環大概可以分為幾個步驟

  1. 所有任務都會在主線程上執行,形成一個執行棧
  2. 如果遇到異步任務,例如:setTimeout,執行環境會調用相關的 API (例如在瀏覽器上會調用 Web API),等待此異步任務的結果之後,再被放置到任務隊列中
  3. 一旦執行棧的所有同步任務完成之後,就會讀取任務隊列,並將任務隊列第一個,加到執行棧中運行
  4. 只要執行棧空了之後,就會讀取任務隊列,不斷重複這個步驟,直到所有任務完成,這個流程就是**事件循環 (Event loop) **

宏任務 (Macro Task) 與微任務 (Micro Task)

除了事件循環的流程以外,面對這個面試題,宏任務 (Macro Task) 與微任務 (Micro Task) 也是必提的概念。JavaScript 中的異步任務又分成宏任務 (Macro Task) 和微任務 (Micro Task),這兩者的執行順序是不同的。如果不分清楚這兩種類別的任務,很可能程式執行出的順序會跟預期的不同。

舉例來說,下面這段程式碼,印出的順序會是什麼呢?

console.log(1);

setTimeout(function () {
  console.log(2);
}, 0);

Promise.resolve()
  .then(function () {
    console.log(3);
  })
  .then(function () {
    console.log(4);
  });

假如只單純區分同步與異步,可能會回答 1234;但是正確答案應該是 1342。為什麼是 1342? setTimeout 不是設定 0 毫秒,這樣為什麼會是 Promise 裡面的東西先執行呢? 原因是 Promise 會進到微任務列隊,而 setTimeout 會是在宏任務列隊。在一次事件循環中,宏任務一次只提取一個,所以 console.log(1) 後,會先去看微任務列隊,不斷提取到執行棧中直到微任務列隊為空,因此這邊會先執行 Promise ,然後才是setTimeout

常見的宏任務與微任務如下:

  • 宏任務:script(整體程式碼)、setTimeoutsetInterval、I/O、事件、postMessageMessageChannelsetImmediate (Node.js)
  • 微任務:Promise.thenMutaionObserverprocess.nextTick (Node.js)。

執行順序如下:

  • 執行一次宏任務 (最開始會是整個 srcipt 所以上面的例子會先執行 console.log(1)
  • 執行過程中如果遇到宏任務,就放進宏任務列隊
  • 執行過程中如果遇到微任務,就放進微任務列隊
  • 當執行棧空了,先檢查微任務列隊,如果有微任務,就依序執行直到微任務列隊為空
  • 接著進行瀏覽器的渲染,渲然完後開始下一個宏任務 (回到最開始的步驟)

延伸題:requestAnimationFramerequestIdleCallback

在事件循環的面試題中,也會問到 requestAnimationFramerequestIdleCallback 在事件循環中的發生時機點。requestAnimationFrame 發生的順序會是在下次頁面重繪之前操作 (style calculation、layout、paint 這些渲染步驟前),因為瀏覽器在每次事件循環中,不一定會重新繪製頁面;因此 requestAnimationFrame 執行時機點其觸發時間點跟任務列隊關係比較小,而是跟頁面重繪關係比較大。

requestIdleCallback 則是在瀏覽器渲染後,如果有閒餘時間時則會觸發。

常考的事件循環判讀題目

除了上面那一題基礎的判讀題外,在許多前端面試中,會考更進階的事件循環判讀題。通常考法會是給一段程式碼,然後要你說出正確的順序。如果想針對這類題目多做練習,歡迎前往《最常見的事件循環 (Event Loop) 面試題目彙整》一文。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們