題目與適用情境
實作 promiseAll(iterable),禁止在實作內呼叫原生 Promise.all。輸入是一個有限的同步可迭代物件,可以包含 Promise、thenable 或一般值;輸出是一個新的 Promise。
所有輸入都成功時,回傳值須依輸入的迭代順序組成陣列,不受各項完成先後影響。任一輸入失敗時,外層 Promise 應以最先發生的失敗原因進入 rejected 狀態。空輸入應得到 []。如果讀取迭代器本身拋出錯誤,外層 Promise 也應失敗。
這題適合前端或 JavaScript 職缺的現場程式題。預設使用原生 Promise 處理 thenable 同化與非同步回呼排程;不要求複製 Promise.all 對子類別建構函式、內部槽位與迭代器關閉的所有規範細節。這個範圍應在動手前講清楚,否則「核心行為」與「完整規範相容」會變成兩道不同難度的題目。
面試官評估重點
好的回答會先列出語意,再開始寫迴圈。五個關鍵訊號是:接受可迭代物件而非只接受陣列;用 Promise.resolve 統一一般值、Promise 與 thenable;以固定索引保留結果順序;另外處理空輸入;讓拋錯與拒絕都能使外層 Promise 失敗。
一般回答常對陣列使用 map,再於每個回呼中 push 結果。這可能通過「全部成功且依序完成」的單一範例,卻同時漏掉 Set、產生器、空陣列與亂序完成。較強的回答會先提出不變量:results[i] 永遠對應第 i 個迭代值;pending 表示尚未成功的輸入數量;只有 pending 降為零時才能成功。
面試官也會觀察候選人是否把「快速失敗」誤解成「取消其他工作」。外層 Promise 一旦失敗便不能再次改變狀態,但已經啟動的輸入仍會繼續執行。取消需要輸入任務主動支援額外協定,聚合 Promise 無法自行完成。
作答前需要釐清的問題
- 輸入只會是陣列,還是任意同步可迭代物件? 若只支援陣列,可以使用索引迴圈;若支援契約中的 iterable,應使用
for...of,並處理取得下一個迭代值時拋錯的情況。 - 一般值與 thenable 是否為合法輸入? 若是,每一項都要經過
Promise.resolve。直接呼叫item.then會在數字、字串或一般物件上失敗。 - 需要規範等級的 polyfill,還是重現面試所需的核心行為? 規範等級實作還涉及
this建構函式、Promise 子類別、內部防重複呼叫紀錄與精確的迭代器關閉規則。本題回傳原生 Promise,並明確不宣稱為完整規範實作。 - 失敗後是否要取消其他任務? 若需要,題目必須補上 AbortSignal 或任務取消介面。目前契約只要求外層快速失敗,其餘任務繼續執行。
- 輸入規模是否有限?
Promise.all會同步走訪輸入;無限迭代器永遠無法完成走訪。本題假設輸入有限,空間複雜度才能以n表示。
這些答案會直接改變迴圈形式、錯誤處理與 API 契約,適合在開始寫程式前用幾十秒確認。
30 秒作答框架
「我會回傳一個新的 Promise,並同步走訪有限的 iterable。每取得一項,就記錄它的迭代索引並增加待完成計數,再用 Promise.resolve 統一處理一般值、thenable 與 Promise。成功回呼把值寫入固定索引,計數減到零時解析結果陣列;失敗回呼直接拒絕外層 Promise。迴圈放在 try...catch 內,讓迭代器拋錯時也能拒絕。空輸入不會觸發成功回呼,因此走訪結束後要另外解析為 []。這個實作保留輸入順序並快速失敗,但不會取消已經啟動的任務。」
分步深入解答
先看一個有問題的基礎方案:逐項 await 後再收集結果。它容易保留順序,卻把原本可以併發等待的工作改成循序等待;前一項尚未完成時,後一項連等待回呼都不會註冊。題目要聚合已取得的輸入,不是建立循序任務佇列。
建議實作只需要一個結果陣列、兩個計數器與一次走訪:
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let pending = 0;
let index = 0;
try {
for (const item of iterable) {
const currentIndex = index;
index += 1;
pending += 1;
Promise.resolve(item).then(
(value) => {
results[currentIndex] = value;
pending -= 1;
if (pending === 0) {
resolve(results);
}
},
reject,
);
}
} catch (error) {
reject(error);
return;
}
if (index === 0) {
resolve([]);
}
});
}currentIndex 在當次迭代中固定。假設三個輸入分別在 30、10、20 毫秒完成,成功回呼的執行順序會是 1、2、0,但寫入位置仍為 1、2、0,最後的陣列依 0、1、2 回傳。若改用 results.push(value),結果便會錯誤地變成完成順序。
Promise.resolve(item) 同時處理兩類邊界。一般值會變成已成功的 Promise;thenable 的 then 會被統一同化,即使它不正確地多次呼叫回呼,原生 Promise 的一次性狀態也會阻止重複結算傳入這段程式。直接檢查 item instanceof Promise 既會漏掉其他執行環境的 Promise,也會漏掉合法 thenable。
空輸入必須明確處理。計數器從零開始,沒有任何成功回呼會替我們呼叫 resolve。走訪後檢查 index === 0,即可讓回傳的 Promise 以空陣列成功;呼叫端註冊的 .then 仍依 Promise 規則非同步執行。
try...catch 負責同步走訪錯誤。例如產生器在第二次 next() 時拋錯,第一項可能已掛上成功回呼,但 catch 會拒絕外層 Promise;後續成功回呼即使執行,也無法改變已拒絕的狀態。某項 Promise 的拒絕也直接傳給同一個 reject。多個輸入都失敗時,第一次實際觸發的拒絕決定外層原因。
走訪與回呼的總工作量為 O(n),結果陣列和每項成功回呼占用 O(n) 空間。單項完成時只做固定索引寫入與計數,額外工作為 O(1)。若產品需要取得所有成功與失敗結果,應選擇類似 allSettled 的契約;若產品要限制併發數,則需要任務工廠與排程器,不能把一組已啟動的 Promise 交給本函式後再宣稱限制了併發。
驗證不能只跑成功路徑。至少要檢查:空 iterable;Set 輸入;一般值、Promise 與 thenable 混合;完成順序與輸入順序相反;最早發生的拒絕;迭代器途中拋錯。每個案例都對應一條實作不變量,比只比較一個範例陣列更能說明正確性。
高品質示範回答
「我先把範圍定為有限的同步 iterable,回傳原生 Promise,不處理 Promise 子類別的完整規範細節。這裡有三個必要語意:結果依迭代順序排列、任一輸入失敗時外層盡快失敗、空輸入成功得到空陣列。
我會在走訪時替每項配置固定索引,並增加 pending。每項先經過 Promise.resolve,讓數字、既有 Promise 與 thenable 走相同路徑。成功後寫回固定位置並遞減計數,最後一項成功時解析整個陣列;拒絕處理器直接使用外層 reject。for...of 放進 try...catch,因為 iterable 在取得下一項時也可能同步拋錯。走訪結束仍沒有任何元素,就立即用 [] 解析。
這個方案的走訪與結算共需 O(n) 工作與 O(n) 空間。外層快速失敗只改變聚合結果,不會停止其他非同步任務。若產品要求取消,我會把 AbortSignal 加進輸入任務契約;若要求收集全部錯誤,我會改採 all-settled 語意,而不會改變這個函式的失敗規則。」
常見錯誤
- 用
push收集成功值 → 陣列順序取決於完成速度,較慢的第一項可能落在最後 → 走訪時保存currentIndex,成功後寫入results[currentIndex]。 - 只寫
item.then(...)→ 一般值沒有then,非原生 thenable 也未被安全同化 → 統一呼叫Promise.resolve(item)。 - 等待計數從輸入長度開始,卻宣稱支援 iterable → Set 與產生器沒有可直接依賴的
length,題目契約被悄悄縮小 → 走訪時動態增加pending。 - 漏掉空輸入 → 沒有回呼會觸發成功,回傳的 Promise 永遠維持 pending → 走訪後用
index === 0解析空陣列。 - 使用
forEach(async ...)再await→forEach不會等待非同步回呼,外層控制流程與錯誤傳遞都不符合預期 → 直接註冊.then處理器並由計數器聚合。 - 只處理輸入 Promise 的拒絕 → iterable 的
next()同步拋錯會逸出走訪,但解釋中沒有涵蓋 → 用try...catch包住整段走訪並拒絕外層 Promise。 - 宣稱快速失敗會取消請求 → Promise 狀態不可逆不代表底層任務已停止 → 明確指出取消需要 AbortSignal 或任務本身的取消協定。
延伸追問與應對
追問一:如何證明結果順序一定正確?
先定義不變量:第 i 個迭代值只擁有索引 i,而且它的成功回呼只會寫入 results[i]。回呼執行順序只能改變寫入時間,不能改變寫入位置。全部 n 項成功後,陣列的 0 到 n - 1 位置分別來自對應輸入,所以結果順序等於迭代順序。可用三個反向延遲的 Promise 驗證,不要依賴相同延遲的範例。
追問二:快速失敗後如何取消其餘網路請求?
目前函式無法做到,因為它只接收 Promise,看不到底層任務的取消入口。可以把輸入改成接收 AbortSignal 的任務工廠,建立共用的 AbortController;某項失敗時先呼叫 controller.abort(),再拒絕外層 Promise。仍要處理已完成、忽略 signal 或取消本身失敗的任務,因此「取消請求」屬於擴充契約,不能暗中加入標準 Promise.all 語意。
追問三:若最多只能同時執行三個任務呢?
輸入必須從「已經啟動的 Promise」改為「尚未啟動的任務函式」。維護下一個任務索引、執行中數量與結果陣列;每完成一項便啟動下一項,確保執行中數量不超過 3。若仍接收 Promise 陣列,建立陣列時任務通常已經開始,排程器無法追回併發控制。這個追問考的是啟動時機,不是把完成計數器改名為併發計數器。
追問四:這個實作和規範等級的 Promise.all 還有什麼差距?
它固定使用原生 Promise,沒有依 this 取得建構函式,也未重現 Promise 子類別、內部槽位、規範中的迭代器關閉步驟與所有異常完成細節。面試時應稱它為「核心行為實作」。若目標是可發布的 polyfill,應逐步對照 ECMAScript 演算法並補齊相容性測試,不能只憑常見範例宣稱規範等價。