😎

從非同步操作到 Event Loop(閱讀筆記)

2023/10/19に公開

前言

基本上這兩個是在面試時很常被拿來當作考題的觀念,有時單純的考 promise 對於異步 (asynchronous) 的操作,有時會在 Event Loop 的考題裡出現關於 promise 的操作,這篇文章會介紹他們的關係,以及它們做了什麼?

既然 Promise 跟 Event Loop 都跟非同步有關,那它們的關係是什麼?

「簡單來說 Promise 用於處理非同步操作的結果,把這些結果給 Event Loop 後,利用 Event Loop 的結構來安排何時執行這些處理程序,以確保 JavaScript 的單線程運行環境能夠有效處理異步操作。」

雖然這一句話總結了這篇文章的重點,但還有好多名詞需要解釋,什麼是非同步操作?Event Loop 依據什麼來安排執行順序?單線程運行環境??......等等。

為什麼要非同步執行任務?

什麼是同步什麼是非同步?

首先要知道 JavaScript 是單執行緒(Single Thread)的程式語言,一行程式碼執行完才會再執行下一行,這個概念稱之為同步 (synchronous)

而因為在 Web 開發和應用程式中,有許多需要等待時間長的操作,如網絡請求、文件讀寫、使用者輸入事件等。如果這些操作是同步的,若有其中一個操作花了很久的時間,這將會導致應用程式在等待操作完成時被阻塞,給用戶帶來不好的體驗。於是就有了非同步(asynchronous) 的概念。

非同步的程式碼或事件,並不會阻礙主線程執行其他程式碼。也就是說如果今天有一個拿取資料的非同步事件,它會在開始的時候在一個分支上執行,主線程可以繼續執行其他程式碼,等非同步事件完成了以後會再通知主線程繼續下一個動作,讓使用者不會感受到因拿取資料的時間而有停滯的感覺。

像下面這個同步的例子,因為程式碼中間那個耗時的操作的關係,會導致在等待期間,使用者都沒辦法做其他的動作。

function syncOperation() {
  console.log("開始操作");

  // 模擬一個耗時的操作,例如延遲 3 秒
  for (let i = 0; i < 3; i++) {
    // 使用 while 迴圈模擬同步等待
    const startTime = new Date().getTime();
    while (new Date().getTime() - startTime < 1000) {
      // 等待 1 秒
    }

    console.log(`${i + 1} 秒過去了`);
  }
  
  console.log("Final 操作");

}

syncOperation();
console.log("操作後的其他代碼");

那如果是下面這樣非同步的例子的話,在 執行到 setTimeout 的時候,就會先透過 Web API 在放到 Task Queue 等待執行,這樣其他部分的程式碼就可以先執行了。

// 非同步操作的示例
function asyncOperation() {
  console.log("開始操作");

  // 模擬一個非同步操作,例如發送網絡請求
  setTimeout(() => {
    console.log("操作完成");
  }, 3000);
}

asyncOperation();
console.log("操作後的其他代碼");

那為什麼 JavaScript 可以做到非同步操作?

前面說 JavaScript 一次僅能做一件任務,那要怎麼做才能讓它做非同步的操作?JavaScript 可以執行非同步操作的原因主要與其運行環境(如瀏覽器或 Node.js)以及語言設計相關。

瀏覽器的 Web API

例如:XMLHttpRequest(XHR)setTimeoutsetInterval 等等。當遇到這些項目時,會先交給 Browser 處理,進而不會阻塞原本的執行緒,藉此讓原本同時間只能進行一項的任務,變成可以進行多項

JavaScript 語言本身支援的非同步操作

例如:回呼函數(Callback Functions)、Promise、async / await 等等,這些機制允許你處理異步任務。

JavaScript 實現非同步(asynchronous)的方式

回呼函數(Callback Functions)

  • 回調函數是在 ES6 promise 出現之前 JavaScript 中最早的非同步處理方式。它們是函數,作為參數傳遞給非同步函數,用於在操作完成時執行。
  • 那這樣做的缺點就是,會造成「callback 地獄」(callback hell),也就是說每個操作都需要等待上一個操作完成才能開始。這導致了多層嵌套的回調函數,難以理解和維護。
function step1(callback) {
  setTimeout(() => {
    console.log("第一步完成");
    callback();
  }, 1000);
}

function step2(callback) {
  setTimeout(() => {
    console.log("第二步完成");
    callback();
  }, 1000);
}

function step3(callback) {
  setTimeout(() => {
    console.log("第三步完成");
    callback();
  }, 1000);
}

function startProcess() {
  step1(() => {
    step2(() => {
      step3(() => {
        console.log("所有步驟完成");
      });
    });
  });
}

startProcess();

Promise

什麼是 Promise?

在 MDN 文件中, Promise 是用來表示一個異步操作的最终完成(或失敗)及其结果值

怎麼使用 Promise

需要透過 new 關鍵字建立一個 Promise。這個構造函數接受一個函數,該函數包含兩個回呼函數參數:resolvereject,用於表示操作成功或失敗。

const myPromise = new Promise((resolve, reject) => {
  // 非同步操作,根據操作結果呼叫 resolve 或 reject
});

Promise 的狀態

new Promise 除了會有 result (resolve 或是 reject),還有 state ,代表該 Promise 的執行狀態。一個 Promise 一定會處於以下三種狀態的其中一種:

  1. pending:初始狀態,執行了 executor,但還在等待中。
  2. fulfilled:表示操作完成,執行 resolve 函式。
  3. rejected:表示操作失敗,執行 reject 函式。

任何一個 resolved 或 rejected 的 promise 都會被稱為 settled

Image.png

通常在同一個 Promise 中只能處理一個結果,不是成功就是失敗,只要有了結果,其他的 resolve 或是 reject 都會忽略。所以會再利用 .thencatch 等方法來處理 Promise 結果,每個 .then().catch() 回調函數的返回值也會是一個新的 Promise。

.then()

  • 第一個要提的是鏈式操作(Chaining),也就是說 Promise 可以被鏈結在一起,依序執行多個非同步操作。並在 .then() 方法中返回另一個 Promise。
myPromise
  .then(res => {
    // 處理成功情況
  })
  .then(res => {
    // 處理另一個成功情況
  });
  • .then() 方法可以接受兩個參數,一個為成功的回調,另一個為失敗的回調。
myPromise.then(
  (res) => {
    console.log(res);
  },
  (reason) => {
    console.log(reason);
  }
);

.catch()錯誤處理

  • 使用 .catch() 方法來處理 Promise 失敗(reject)的情況。.catch() 方法接受一個回呼函數,該函數在操作失敗(例如:網路故障)時被呼叫,捕獲錯誤信息,並輸出錯誤訊息。
myPromise
  .then(result => {
    // 處理成功情況
  })
  .catch(error => {
    // 處理失敗情況
  });

.finally()

  • 如果有加上 .finally(),那 Promise 狀態不論是 fulfilled 還是 rejected 都一定會執行 .finally() 方法。
  • 下面的是關於如何利用 .finally() 來確保清理操作的執行,以便在非同步操作完成後關閉載入器或執行其他必要的清理任務。
// 假設你有一個函數用於模擬非同步操作,例如載入資料
function loadData() {
  // 返回一個 Promise,模擬非同步操作
  return new Promise((resolve, reject) => {
    // 模擬非同步操作,延遲 2 秒
    setTimeout(() => {
      resolve("成功:資料載入完成");
      // 或者在失敗情況下使用 reject("失敗:資料載入失敗");
    }, 2000);
  });
}

// 顯示載入器
function showLoader() {
  console.log("載入中...");
}

// 關閉載入器
function closeLoader() {
  console.log("載入完成,關閉載入器");
}

// 主要處理流程
showLoader(); // 顯示載入器

loadData()
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error(error);
  })
  .finally(() => {
    closeLoader(); // 在 finally 中關閉載入器
  });

async / await

  • async / await 是 ES2017 引入的語法糖,建立在 Promise 之上,可以代替 Promise 鏈式調用,使非同步程式碼看起來更像同步代碼,提高可讀性。
  • 首先會利用 async 關鍵字將函式標記為非同步函式,非同步函式就是指返回值為 Promise 物件的函式。
  • 在非同步函式中我們可以調用其他的異步函式,不過不是使用 .then(),而是使用 await 語法, await 會等待 Promise 返回的解決或拒絕結果。
async function fetchDataWithAsync() {
  try {
    const data = await fetchDataWithPromise();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

fetchDataWithAsync();

Promise.all()

Promise.all() 方法接收一個包含多個 Promise 的可迭代對象(如數組或可迭代對象),並返回一個新的 Promise。這個新 Promise 將在**所有輸入的 Promise 都成功(resolved)**後,成功解決,並返回一個包含所有 Promise 結果的數組;如果其中任何一個 Promise 失敗(rejected),新 Promise 將立即失敗並返回第一個失敗的 Promise 的錯誤。

const promise1 = Promise.resolve(1);
const promise2 = new Promise((resolve, reject) => setTimeout(resolve, 2000, 'Hello'));
const promise3 = new Promise((resolve, reject) => setTimeout(resolve, 1000, 'World'));

Promise.all([promise1, promise2, promise3])
  .then(results => {
    console.log(results); // [1, 'Hello', 'World']
  })
  .catch(error => {
    console.error(error); // 這個不會被執行
  });

Promise.race()

Promise.race() 方法同樣接收一個包含多個 Promise 的可迭代對象,但它返回一個新的 Promise,該 Promise 將在輸入的 Promise 中的任何一個成功解決後,立即解決(成功或失敗)。換句話說,它將返回第一個完成的 Promise 的結果或錯誤。

const promise1 = new Promise((resolve, reject) => setTimeout(resolve, 1000, 'Promise 1'));
const promise2 = new Promise((resolve, reject) => setTimeout(resolve, 500, 'Promise 2'));

Promise.race([promise1, promise2])
  .then(result => {
    console.log(result); // 'Promise 2',因為 promise2 比 promise1 更快完成
  })
  .catch(error => {
    console.error(error); // 這個不會被執行
  });

Promise.all() & Promise.race() 小結

Promise.all() 用於等待所有 Promise 都完成後處理它們的結果,而 Promise.race() 用於等待第一個 Promise 完成後處理它的結果。這兩個方法在處理多個非同步操作時非常有用。

Event Loop

在前面的 「那為什麼 JavaScript 可以做到非同步操作?」的段落中有說到 JavaScript 本身並不支援 Event Loop ,要搭配「執行環境」(瀏覽器、Node.js)才能達成。所以說瀏覽器中 JavaScript 的執行流程和 Node.js 中的流程都是基於 Event Loop

Event Loop 到底做了什麼事呢?可以說它藉由控制 Call Stack 以及 Callback Queue 間的非同步操作,來達成循環的**「機制」**。那 Call Stack 以及 Callback Queue 又是什麼呢?

Call Stack(調用堆疊)

Call Stack 是 JavaScript 運行時(runtime)中的一個組件,它用於追蹤正在執行的函數調用。它按照後進先出(Last-In-First-Out)的規則,即就是最新的函數被添加(呼叫)到 Stack 的頂端,而當函數執行完成時,它將從 Stack 中彈出,以讓下一個函數執行。這確保了 JavaScript 單線程環境下程式碼的執行順序。

Callback Queue(回調佇列)

Callback Queue,也被稱為事件佇列,是用於存儲 Callback Functions 的佇列。這些 Callback Functions 通常是與非同步操作和事件處理相關的,例如計時器(setTimeout、setInterval)、事件處理器、Promise 的 .then() 回調等。當非同步操作完成或事件被觸發時,相關的回調函數被添加到 Callback Queue 中,等待被執行。

以下這張圖可以很明顯的看到,程式碼會一行一行的進入Call Stack ,而當遇到需要 Web API 處理的指令時,就會透過 Web API 處理,處理後再送到 Callback Queue,等 Call Stack 處理完後,會再處理 Callback Queue 裡的任務。

Image.png

Web API

在上面的圖中還有一個 Web API,Web APIs 並非只有協助耗時較久的任務,還有其他許多任務,像是:

  • API 允許 JavaScript 與瀏覽器的 DOM 進行操作
  • 通過 XMLHttpRequest 和 Fetch 發送 HTTP 請求
  • 使用 setTimeout 處理定期的或延遲的程式碼執行
  • 附加事件監聽器處理各種事件(例如:onClick

Heap

在圖中的 Heap 不是 Event Loop 本身的一部分,而是 JavaScript 運行環境(例如瀏覽器或Node.js)中的一個元件,它負責處理動態分配 JavaScript 程式運行期間所需的記憶體,特別是用於管理和存儲物件和變數的記憶體。

Event Loop: Task(Macrotask) 與 Microtask

Event Loop 中,要處理的任務其實還有分兩種,也就是Task(Macrotask) 宏任務Microtask 微任務

Task(Macrotask) 宏任務

  • Task 是代表較大的非同步操作任務。這些操作通常包括
    • I/O操作
    • 計時器回調(setTimeout、setInterval)等
  • Task 通常放置在Callback Queue 中,並由 Event Loop 按照它們的順序執行,並且一次只處理一個。

Microtask 微任務

  • Microtask 代表較小的任務,通常與Promise有關。
  • Microtask 會被放置在一個不同於 Macrotask 的佇列中,稱為Microtask Queue。
  • Event Loop 會優先處理Microtasks,然後才處理Macrotasks。

Task(Macrotask) 宏任務Microtask 微任務Event Loop 的關係

Event Loop 在處理非同步操作時具有優先順序,首先處理Microtasks,然後才處理Macrotasks。當 Event Loop 運行時,它會檢查Microtask Queue是否為空,如果不是,就會一次處理一個Microtask,直到Microtask Queue為空,然後才處理下一個Macrotask。這種處理順序確保了 Promise 等微任務能夠在主線程程式碼中的 Macrotask 執行之前被快速處理。這有助於避免阻塞瀏覽器,提高了 JavaScript 反應性。

Image.png

範例

以下以這個程式碼當作例子,也可以稍微當作練習試試看:

console.log('Start');

// Macrotask (Task) - 使用setTimeout
setTimeout(function() {
  console.log('Macrotask (Task) - setTimeout callback');
}, 0);

// Microtask - 使用Promise
Promise.resolve().then(function() {
  console.log('Microtask - Promise callback 1');
}).then(function() {
  console.log('Microtask - Promise callback 2');
});

console.log('End');

答案是

Start
End
Microtask - Promise callback 1
Microtask - Promise callback 2
Macrotask (Task) - setTimeout callback

說明一下流程:

  1. 印出 Start - 一開始,這是第一個 console.log 語句,所以會立即印出。
  2. 遇到 setTimeout ,先放到 Macrotask 中。
  3. 遇到 Promise ,先放到 Microtask 中。
  4. 印出 End
  5. 再來看看 Microtask,先處理 Promise 的第一個 Microtask,所以它會在 End 之後印出 Microtask - Promise callback 1
  6. 再來是Promise的第二個 Microtask,所以印出 Microtask - Promise callback 2
  7. 最後到Macrotask中處理 setTimeout,所以印出 Macrotask (Task) - setTimeout callback

參考文章

透過程式範例,熟悉 JS 執行流程的關鍵:Event Loop

[JavaScript] 理解事件循環(Event Loop) 到底是什麼~

[JavaScript] 非同步(Asynchronous)觀念再強化

Promise 是什麼?有什麼用途?

前端面試必考題: Promise

Discussion