非同步是所有網站都必須處理的議題。前端框架興起後,SPA 已是現代快速建構網站的標配,非同步取得資料也成了不可或缺的環節。本章討論 JavaScript 的非同步處理方式。
同步?非同步?#
從中文字面容易誤解這兩個名詞,但在電腦科學的脈絡下:
- 同步:一件一件事情依序處理
- 非同步:多件事情並行處理
透過事件迴圈(Event Loop),JavaScript 處理非同步事件相當輕鬆。那麼具體有哪些處理方式?
回呼函式#
最熟悉最常出現的就是回呼(Callback)函式了。例如網頁與使用者進行互動註冊的事件監聽器,就需要接收一個回呼函式;或是其他 Web API 的各種功能如 setTimeout、xhr,也都能透過傳遞回呼函式,並在使用者要求的時機觸發它。
例如看一個 setTimeout 的例子:
// callback
function withCallback() {
console.log("start");
setTimeout(() => {
console.log("callback func");
}, 1000);
console.log("done");
}
withCallback();
// start
// done
// callback func
如事件迴圈一章提到的,setTimeout 執行後,指定時間間隔過後回呼函式會被放到 Queue 末端,等待事件迴圈處理。
也因為前述的機制,開發者設定給
setTimeout的時間間隔,並不會精準的等於從執行到觸發所經過的時間,使用時要特別注意。
回呼函式雖然在開發中十分常見,但有許多難以避免的問題:
- 由於函式需要被傳遞給其他函式,開發者難以掌控其他函式內的處理邏輯。
- 因為回呼函式僅能配合
try ... catch捕捉錯誤,當非同步錯誤發生時難以控制。 - 著名的「回呼地獄(Callback Hell)」。
Promise#
ES6 之後 Promise 橫空出世,拯救身陷地獄的開發者。基本用法:
function withPromise() {
return new Promise((resolve) => {
console.log("promise func");
resolve();
});
}
withPromise()
.then(() => console.log("then 1"))
.then(() => console.log("then 2"));
// promise func
// then 1
// then 2
先前討論 Event Loop 時沒有提到的部分是,在 HTML 5 的 Web API 標準中,Event Loop 新增了微任務序列(micro task queue),而 Promise 正是透過微任務序列來驅動它的;微任務序列的觸發時機,是在 Stack 清空時,JavaScript 引擎會先確認微任務序列有沒有東西,有的話就優先執行,直到清空後,才從 Queue 拿出新任務到 Stack 上。
如上面的範例,當函式回傳一個 Promise 物件時,JavaScript 引擎便會將傳入的函式放到微任務序列中。後續的 .then 語法會回傳一個新的 Promise 物件,參數函式則接收前一個 Promise.resolve 的結果,藉由這樣函式參數傳遞,讓開發者可以管道式的依序處理非同步事件。
如果在範例中加上了 setTimeout,就更能清楚理解微任務與一般任務的差別:
function withPromise() {
return new Promise((resolve) => {
console.log("promise func");
resolve();
});
}
withPromise()
.then(() => console.log("then 1"))
.then(() => setTimeout(() => console.log("setTimeout"), 0))
.then(() => console.log("then 2"));
// promise func
// then 1
// then 2 -> 微任務優先執行
// setTimeout
另外,前述的回呼函式較難處理的非同步錯誤,也可以透過 .catch 語法來捕捉非同步的錯誤:
function withPromise() {
return new Promise((resolve) => {
console.log("promise func");
resolve();
});
}
withPromise()
.then(() => console.log("then 1"))
.then(() => {
throw new Error("error");
})
.then(() => console.log("then 2"))
.catch((err) => console.log("catch:", err));
// promise func
// then 1
// catch: error
// ...error call stack
async/await#
從 ES6 Promise 問世後,非同步的程式碼從回呼地獄,漸漸變成了優雅的函數式管道處理;但在熟悉度不足的開發者手上,就只是從 Callback Hell 變成 Promise Hell 而已。
一年過去,ES7 規範了新的 async/await,雖然只是 Promise 和 Generator Function 尬在一起的語法糖,但透過 async/await 便可以將非同步事件用同步語法來處理,寫起來的風格與 Promise 完全不同:
function wait(time, fn) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("wait:", time);
resolve(fn ? fn() : time);
}, time);
});
}
await wait(500, () => console.log("bar"));
console.log("foo");
// wait: 500
// bar
// foo
這邊透過 Promise 將 setTimeout 包裝成 Promise 物件,再使用 await 關鍵字呼叫它;可以看到結果會是同步執行的先出現 bar,再出現 foo,也就是此段落開頭提及的「將非同步事件寫成同步處理」。
再看一個範例:
async function withAsyncAwait() {
for (let i = 0; i < 5; i++) {
await wait(i * 500, () => console.log(i));
}
}
await withAsyncAwait();
// wait: 0
// 0
// wait: 500
// 1
// wait: 1000
// 2
// wait: 1500
// 3
// wait: 2000
// 4
在使用 async/await 時,由於
await關鍵字只能在 async function 中執行,使用時務必記得要同時使用。
另外,在用迴圈處理非同步事件時,需要注意 ES6 後提供的許多 Array 方法都不支援 async/await 的語法(例如若此處使用 forEach 取代 for,結果會變成同步執行)。
結語#
本章介紹了 JavaScript 處理非同步的三種方式,透過範例說明程式執行順序,並在事件迴圈基礎上加入微任務序列的概念。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10221800