從非同步操作到 Event Loop(閱讀筆記)
前言
基本上這兩個是在面試時很常被拿來當作考題的觀念,有時單純的考 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)
、setTimeout
、setInterval
等等。當遇到這些項目時,會先交給 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。這個構造函數接受一個函數,該函數包含兩個回呼函數參數:resolve
和 reject
,用於表示操作成功或失敗。
const myPromise = new Promise((resolve, reject) => {
// 非同步操作,根據操作結果呼叫 resolve 或 reject
});
Promise 的狀態
new Promise 除了會有 result (resolve 或是 reject),還有 state ,代表該 Promise 的執行狀態。一個 Promise 一定會處於以下三種狀態的其中一種:
- pending:初始狀態,執行了 executor,但還在等待中。
- fulfilled:表示操作完成,執行 resolve 函式。
- rejected:表示操作失敗,執行 reject 函式。
任何一個 resolved 或 rejected 的 promise 都會被稱為 settled
。
通常在同一個 Promise 中只能處理一個結果,不是成功就是失敗,只要有了結果,其他的 resolve 或是 reject 都會忽略。所以會再利用 .then
或 catch
等方法來處理 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 裡的任務。
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 反應性。
範例
以下以這個程式碼當作例子,也可以稍微當作練習試試看:
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
說明一下流程:
- 印出
Start
- 一開始,這是第一個console.log
語句,所以會立即印出。 - 遇到 setTimeout ,先放到 Macrotask 中。
- 遇到 Promise ,先放到 Microtask 中。
- 印出
End
。 - 再來看看 Microtask,先處理 Promise 的第一個 Microtask,所以它會在
End
之後印出Microtask - Promise callback 1
。 - 再來是Promise的第二個 Microtask,所以印出
Microtask - Promise callback 2
。 - 最後到Macrotask中處理
setTimeout
,所以印出Macrotask (Task) - setTimeout callback
。
參考文章
透過程式範例,熟悉 JS 執行流程的關鍵:Event Loop
[JavaScript] 理解事件循環(Event Loop) 到底是什麼~
Discussion