1103 字
6 分鐘

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(); // 讓第二筆先通過自己的 await
assert.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 跳頁行為。

參考資料:

MDN:Array.prototype.forEach()

ECMAScript:Array.prototype.forEach

MDN:Promise.all()

forEach async 為什麼沒等完?用 for...of 或 Promise.all 接住工作
https://laplusda.com/posts/javascript-foreach-async-await/
作者
Zero
發佈於
2026-10-22
許可協議
CC BY-NC-SA 4.0