forEach async 為什麼沒等完?用 for...of 或 Promise.all 接住工作
把儲存動作放進 items.forEach(async item => { await save(item); }),下一行卻已顯示「完成」,甚至先跳頁。原因是 forEach 不會等待 callback 回傳的 Promise,外層的 await 也不會幫它收集工作。需要依序完成,用 for...of;工作彼此獨立、可以同時啟動,再用 map 建立 Promise 陣列交給 Promise.all。
先決定順序與失敗策略,再選語法。若第二筆失敗時第三筆不能開始,並行啟動就不符合需求,事後加 catch 也無法撤回已經送出的操作。
用一個閘門證明 forEach 已經回傳
MDN 的 forEach 說明明確指出它不等 Promise,回傳值為 undefined。ECMAScript 演算法也沒有收集 callback 結果的步驟。
以下範例把工作停在可手動放行的 Promise,避免用「延遲幾秒」猜測順序。將每個 JavaScript 區塊分別存成 .mjs,用 node 檔名.mjs 執行;正常結果是沒有 assertion 錯誤。
import assert from 'node:assert/strict';
function deferred() { let resolve; const promise = new Promise((ok) => { resolve = ok; }); return { promise, resolve };}
const gate = deferred();const finished = deferred();const completed = [];const returned = await [1, 2].forEach(async (id) => { await gate.promise; completed.push(id); if (completed.length === 2) finished.resolve();});
assert.equal(returned, undefined);assert.deepEqual(completed, []); // 外層 await 已結束,工作仍被擋住
gate.resolve();await finished.promise; // 測試另外建立完成訊號,forEach 本身沒有提供assert.deepEqual(completed, [1, 2]);這個重現刻意只使用成功工作。若 async callback 內拋錯,會變成該 callback 的 Promise rejection;包住 await items.forEach(...) 的外層 try/catch 接不到這個被忽略的 Promise。改成能回傳整批完成狀態的流程,才能讓呼叫端等待並處理失敗。
後一筆依賴前一筆,就用 for…of
例如逐筆套用有順序的更新,或第一個錯誤就應停止後續操作,可直接在迴圈內等待。下面的 helper 接受一般、無空洞的陣列;save 必須回傳代表工作完成的 Promise,或在同步完成後回傳值。
import assert from 'node:assert/strict';
async function saveInOrder(items, save) { const results = []; for (const item of items) { results.push(await save(item)); } return results;}
const calls = [];const failure = new Error('第二筆失敗');await assert.rejects( saveInOrder([1, 2, 3], async (id) => { calls.push(id); if (id === 2) throw failure; return `saved:${id}`; }), (error) => error === failure,);assert.deepEqual(calls, [1, 2]); // 第三筆根本沒有啟動assert.deepEqual( await saveInOrder([1, 2], async (id) => `saved:${id}`), ['saved:1', 'saved:2'],);assert.deepEqual(await saveInOrder([], async (id) => id), []);這裡的「停止」只表示不啟動下一筆,不會撤銷已成功的第一筆。若整批必須全部成功或全部撤回,要在資料庫交易或業務補償流程處理。save 若只是啟動背景工作便立刻回傳,外面的 await 仍不知道背景何時結束。
可以並行才改成 map,確認結果與完成順序不同
map 保留 callback 回傳值,因此能把 Promise 交給聚合器。範例使用 async wrapper,即使 save 同步拋錯,也會成為可等待的 rejection。
import assert from 'node:assert/strict';
async function saveTogether(items, save) { return Promise.all(items.map(async (item) => save(item)));}
function deferred() { let resolve; const promise = new Promise((ok) => { resolve = ok; }); return { promise, resolve };}
const gates = new Map([[1, deferred()], [2, deferred()]]);const completed = [];const batch = saveTogether([1, 2], async (id) => { await gates.get(id).promise; completed.push(id); return `saved:${id}`;});
gates.get(2).resolve();await Promise.resolve(); // 讓第二筆先通過自己的 awaitassert.deepEqual(completed, [2]);gates.get(1).resolve();assert.deepEqual(await batch, ['saved:1', 'saved:2']);assert.deepEqual(completed, [2, 1]);
const failure = new Error('同步失敗');await assert.rejects( saveTogether([1], () => { throw failure; }), (error) => error === failure,);assert.deepEqual(await saveTogether([], async (id) => id), []);Promise.all的結果依輸入順序排列,不能據此宣稱 API 也依序執行。上例讓第二筆先完成,但回傳陣列仍將第一筆放在前面。若改寫成 items.map(async item => { save(item); }) 卻忘了 return 或 await,又會遺失真正工作的完成訊號。
這個 helper 沒有並行上限。若陣列來自大量待處理資料,不應直接把整份清單全部啟動;可接著使用 allSettled 與 p-limit 的部分失敗及並行限制方法。如果要求保留成功結果,選 allSettled;如果後續只能在全部成功時進行,選 all 並處理 rejection。
Promise.all 第一個失敗就回報,其他工作仍可能執行。關閉共用連線或移除暫存檔前,要另外管理生命週期,詳見 Promise.all 失敗後等工作結束再清理。本文的三個範例驗證的是 JavaScript 完成順序與錯誤傳遞,沒有測試真實 API 的取消、交易或 UI 跳頁行為。
參考資料: