题干与适用场景
实现 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 既漏掉跨 realm 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)。 - 等待计数从输入长度开始,却只接受数组 → Set 和生成器没有可直接依赖的
length,题目契约被悄悄缩窄 → 遍历时动态增加pending。 - 遗漏空输入 → 没有回调会触发成功,返回的 Promise 永久 pending → 遍历后用
index === 0解析空数组。 - 用
forEach(async ...)再await→forEach不等待异步回调,外层控制流与错误传播都不符合预期 → 直接注册.then处理器并由计数器聚合。 - 只处理输入 Promise 的拒绝 → iterable 的
next()同步抛错会逃出 executor,论证里却没有说明 → 用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 算法并补齐兼容性测试,不能仅凭常见样例宣布规范等价。