非同步是所有網站都必須處理的議題。前端框架興起後,SPA 已是現代快速建構網站的標配,非同步取得資料也成了不可或缺的環節。本章討論 JavaScript 的非同步處理方式。

同步?非同步?#

從中文字面容易誤解這兩個名詞,但在電腦科學的脈絡下:

  • 同步:一件一件事情依序處理
  • 非同步:多件事情並行處理

透過事件迴圈(Event Loop),JavaScript 處理非同步事件相當輕鬆。那麼具體有哪些處理方式?

回呼函式#

最熟悉最常出現的就是回呼(Callback)函式了。例如網頁與使用者進行互動註冊的事件監聽器,就需要接收一個回呼函式;或是其他 Web API 的各種功能如 setTimeoutxhr,也都能透過傳遞回呼函式,並在使用者要求的時機觸發它。

例如看一個 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