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()));
常見誤區:
- fetch 不會因 4xx/5xx reject —— 只有網路錯誤才 reject,狀態碼要自己查
res.ok - 忘了 await
res.json()(它回 Promise) - 不處理超時 —— 使用者面對無限轉圈
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/取消,才懂框架替你做了什麼。
習題#
- 把 3.3 節聊天室的前端改成 async/await + 超時 + 重試(指數退避)。
Promise.all與allSettled各適合什麼場景?給一個混用需求(部分關鍵、部分可失敗)的解法。- 解釋為什麼「在 for 迴圈裡 await」常是效能 bug,何時又必須這樣寫。