跳轉到

6.3 非同步:Promise 與 async-await#

為什麼 JS 的非同步是必修#

單執行緒(6.1)+ 網路 I/O 動輒數百 ms:同步等待 = 整個頁面凍結。JS 的答案是把 I/O 全部非同步化,於是「時間上的等待」成了語言的核心課題。

非同步的三代寫法#

第一代:回呼(callback)與回呼地獄#

getUser(id, (user) => {
  getOrders(user.token, (orders) => {
    getOrderDetail(orders[0].id, (detail) => {
      render(detail);            // 巢狀越來越深…
    }, onErr);
  }, onErr);
}, onErr);

問題:錯誤處理重複、無法組合(並行?競賽?)、控制流反轉(inversion of control:把後續交給你呼叫的函數)。

第二代:Promise(ES6/2015)#

getUser(id)
  .then((user) => getOrders(user.token))
  .then((orders) => getOrderDetail(orders[0].id))
  .then(render)
  .catch(onErr);          // 錯誤一路向下傳,統一接住

Promise 的理論貢獻:把「未來的值」變成現在一等公民的值 —— 可以傳遞、組合、鏈接。三種狀態:

\[ \text{pending} \to \begin{cases} \text{fulfilled(有值)} \\ \text{rejected(有原因)} \end{cases} \quad \text{狀態一經確定,永不改變} \]

組合子:並行等待變成表達式:

Promise.all([a, b, c])       // 全成功才成功,一失敗即拒絕
Promise.allSettled([a, b])   // 永遠成功,回傳每個的成敗
Promise.race([fetch(x), timeout(3000)])  // 競賽:第一個完成的
Promise.any([a, b])          // 第一個成功的

(對照前書 2.3:Rust 的 try_join_all ≈ Promise.all —— 語言不同,組合數學相同。)

第三代:async/await(ES2017)#

async function load() {
  try {
    const user = await getUser(id);            // 看起來像同步!
    const orders = await getOrders(user.token);
    render(await getOrderDetail(orders[0].id));
  } catch (err) {
    showError(err);                            // try/catch 回來了
  }
}

async/await 是 Promise 的語法糖:await 之處,函數暫停並讓出執行緒(前書 2.3 Rust async 的「poll/Pending」是同一狀態機的不同表面)。心智模型回歸直覺,錯誤處理回歸 try/catch。

並行的正確寫法:await 的位置決定一切#

// ❌ 序列:300ms + 400ms = 700ms
const user = await getUser(id);
const config = await getConfig();

// ✅ 並行:max(300, 400) = 400ms
const [user, config] = await Promise.all([getUser(id), getConfig()]);

經驗法則:互不依賴的 await 永遠合併成 Promise.all。這一條就能把許多頁面的載入時間砍半。

fetch:Web 的 I/O 標準介面#

async function api(path, opts = {}) {
  const res = await fetch(`/api${path}`, {
    headers: { 'Content-Type': 'application/json', ...auth() },
    ...opts,
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();   // 記得:json() 也是 Promise
}

// 超時(fetch 沒有內建 timeout,用 AbortController)
async function withTimeout(ms, fn) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), ms);
  try {
    return await fn(ctrl.signal);
  } finally {
    clearTimeout(t);
  }
}

const data = await withTimeout(5000, (signal) =>
  fetch('/api/slow', { signal }).then((r) => r.json()));

常見誤區:

  1. fetch 不會因 4xx/5xx reject —— 只有網路錯誤才 reject,狀態碼要自己查 res.ok
  2. 忘了 await res.json()(它回 Promise)
  3. 不處理超時 —— 使用者面對無限轉圈

AJAX 的歷史定位:2005 年的名字#

XMLHttpRequest(1999 微軟為 Outlook Web Access 發明)讓「不換頁更新畫面」成真;Jesse James Garrett 2005 年把它命名為 Ajax —— Gmail、Google Maps 據此崛起,SPA 時代開啟。fetch(2015)是它的現代繼承者(Promise 化),XHR 至今仍在舊代碼中出沒。

與 react-query 的接軌(前書 4.2)#

// 手寫方案要處理:快取、去重、競態、重試、失效…
// react-query 把這些變成宣告式配置:
useQuery({
  queryKey: ['todos'],
  queryFn: () => api('/todos'),
  staleTime: 30_000,      // 30 秒內視為新鮮,不重抓
  retry: 2,               // 失敗自動重試(指數退避)
});

本節的 Promise 功課是 react-query 的地基:先懂 all/await/取消,才懂框架替你做了什麼。

習題#

  1. 把 3.3 節聊天室的前端改成 async/await + 超時 + 重試(指數退避)。
  2. Promise.all 與 allSettled 各適合什麼場景?給一個混用需求(部分關鍵、部分可失敗)的解法。
  3. 解釋為什麼「在 for 迴圈裡 await」常是效能 bug,何時又必須這樣寫。