面试官让我手写 Promise.all / Promise.race / Promise.allSettled,我直接水灵灵地写出来了

21 阅读7分钟

刷算法题、准备面试的同学肯定都见过这类题,看似简单,其实里面有很多细节值得深挖。今天把这三个方法掰开了揉碎了讲,顺便手把手带你实现一遍。


前言

Promise 的三个静态方法 allraceallSettled 可以说是前端面试的"常客"了,特别是手写实现题,出现的频率贼高。

说实话,我第一次被问到 Promise.all 手写的时候,愣是写了个七七八八,但边界情况完全没考虑。回来一复盘,发现自己只掌握了"会用",没掌握"为什么这么设计"。

所以今天这篇文章,我不光带你写,还要帮你理解为什么这么写,以及有哪些坑需要避开


先搞清楚这三个方法是干啥的

在说实现之前,先把这三个方法的语义理清楚,免得写的时候脑子一团浆糊。

Promise.all:全部成功才成功,一个失败就失败

const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3]).then(result => {
    console.log(result); // [1, 2, 3]
});

简单说:等所有 Promise 都 resolve 了,才算成功。任何一个 reject 了,直接失败。

这个在什么场景用呢?比如你有个页面要同时请求用户信息、权限列表、配置数据,都拿到了才渲染——Promise.all 就派上用场了。

Promise.race:谁先settle,谁就赢

const p1 = new Promise(resolve => setTimeout(() => resolve(1), 100));
const p2 = new Promise(resolve => setTimeout(() => resolve(2), 50));

Promise.race([p1, p2]).then(result => {
    console.log(result); // 2 —— p2 更快
});

不管结果是成功还是失败,谁先有结果,谁就作为最终结果。

这个可以用来做超时控制:Promise.race([fetchData(), timeout()]),请求超时自动失败。

Promise.allSettled:不管成功失败,都要完整的结果

const p1 = Promise.resolve(1);
const p2 = Promise.reject('error');
const p3 = Promise.resolve(3);

Promise.allSettled([p1, p2, p3]).then(results => {
    console.log(results);
    // [
    //   { status: 'fulfilled', value: 1 },
    //   { status: 'rejected', reason: 'error' },
    //   { status: 'fulfilled', value: 3 }
    // ]
});

不管成功还是失败,所有 Promise 的结果都要收集起来。 任何一个失败不会导致其他结果丢失。

这个在需要"尽最大努力"收集结果的场景特别有用,比如批量提交表单,提交了多少、失败了多少都要知道。


开始手写:准备工作

在写这三个方法之前,先搞个辅助函数:

// 把任意值转成 Promise
// 如果已经是 Promise 就直接返回,如果不是就包装成 resolved Promise
const toPromise = (value) => Promise.resolve(value);

这个函数很关键,因为我们后面要处理的参数不一定是 Promise,可能是普通值。


一、手写 Promise.all

1.1 核心思路

Promise.all 的逻辑其实很清晰:

  1. 接收一个 Promise 数组
  2. 等所有 Promise 都 resolve 了,resolve 一个包含所有结果的数组
  3. 任何一个 reject 了,直接 reject

1.2 逐步实现

第一步:参数校验

function myPromiseAll(promises) {
    // 首先判断是不是数组,不是的话直接报错
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }
    // ... 后续逻辑
}

这个校验其实很少考到,但写上总比不写强,显得你考虑得周全。

第二步:处理空数组

return new Promise((resolve, reject) => {
    const len = promises.length;
    
    // 空数组直接返回空数组,这个边界情况很容易忽略
    if (len === 0) {
        return resolve([]);
    }
    
    // ... 后续逻辑
});

你可能觉得空数组有什么好处理的?但如果不做这个判断,后面的逻辑会有问题——空数组的时候 completedCount 永远是 0,resolve 永远不会被调用。

第三步:收集结果

    const results = new Array(len);  // 预分配结果数组,保持顺序
    let completedCount = 0;

    promises.forEach((item, index) => {
        toPromise(item)  // 先转成 Promise
            .then((value) => {
                results[index] = value;  // 按原顺序存储结果
                completedCount++;
                if (completedCount === len) {
                    resolve(results);  // 全部完成才 resolve
                }
            })
            .catch(reject);  // 任何一个失败就 reject
    });

几个关键点:

  • index 而不是 push,是为了保持结果顺序——Promise.race 用不着这个,但 Promise.all 必须保证顺序
  • .catch(reject) 而不是第二个参数,是因为 reject 在这儿的语义是一致的

1.3 完整代码

function myPromiseAll(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve, reject) => {
        const len = promises.length;
        if (len === 0) {
            return resolve([]);
        }

        const results = new Array(len);
        let completedCount = 0;

        promises.forEach((item, index) => {
            toPromise(item)
                .then((value) => {
                    results[index] = value;
                    completedCount++;
                    if (completedCount === len) {
                        resolve(results);
                    }
                })
                .catch(reject);
        });
    });
}

1.4 测试一把

const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = new Promise((_, reject) => setTimeout(() => reject('error'), 100));

myPromiseAll([p1, p2]).then(console.log);              // [1, 2] 
myPromiseAll([p1, p2, p3]).catch(console.error);      // 'error' 
myPromiseAll([]).then(console.log);                   // [] 
myPromiseAll([1, 2, 3]).then(console.log);            // [1, 2, 3]  普通值也能处理

二、手写 Promise.race

2.1 核心思路

Promise.race 就简单多了:

  1. 接收一个 Promise 数组
  2. 哪个 Promise 先 settle(resolve 或 reject),就以那个结果为最终结果

2.2 实现

说实话,这个比 Promise.all 简单太多了:

function myPromiseRace(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve, reject) => {
        promises.forEach((item) => {
            // 任何一个 settle 了,就 resolve 或 reject
            toPromise(item).then(resolve, reject);
        });
    });
}

注意这里 .then(resolve, reject) 的写法——第一个参数是 resolve 的回调,第二个是 reject 的回调。因为我们希望不管成功还是失败,只要先到就行

2.3 空数组的情况

myPromiseRace([]).then(console.log);  // 这行代码永远不会执行

空数组调用 race 会怎样?实际上它永远不会 settle,原生的 Promise 也是这样。所以我们不需要额外处理空数组,让浏览器自行决定就好。

2.4 完整代码

function myPromiseRace(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve, reject) => {
        promises.forEach((item) => {
            toPromise(item).then(resolve, reject);
        });
    });
}

2.5 常见面试题:超时控制

function withTimeout(promise, timeout) {
    return Promise.race([
        promise,
        new Promise((_, reject) => 
            setTimeout(() => reject(new Error('timeout')), timeout)
        )
    ]);
}

// 使用
withTimeout(fetch('/api/data'), 3000)
    .then(data => console.log(data))
    .catch(err => console.error(err.message));  // 可能是 "timeout"

三、手写 Promise.allSettled

3.1 核心思路

Promise.allSettled 的特点是不抛弃任何一个 Promise

  1. 接收一个 Promise 数组
  2. 等待所有 Promise 都 settle 了(不管成功还是失败)
  3. 返回一个数组,每个元素都标注了状态和结果/原因

3.2 实现

这个比 Promise.all 多了一步:不管成功失败,都要记录下来。

function myPromiseAllSettled(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve) => {
        const len = promises.length;
        if (len === 0) {
            return resolve([]);
        }

        const results = new Array(len);
        let completedCount = 0;

        promises.forEach((item, index) => {
            toPromise(item)
                .then(
                    (value) => {
                        results[index] = { status: 'fulfilled', value };
                    },
                    (reason) => {
                        results[index] = { status: 'rejected', reason };
                    }
                )
                .finally(() => {
                    completedCount++;
                    if (completedCount === len) {
                        resolve(results);
                    }
                });
        });
    });
}

关键点:

  • .then() 的两个参数分别处理成功和失败,而不是 .catch()
  • .finally() 来计数,因为不管是成功还是失败,都要算"完成了"

3.3 测试一把

const p1 = Promise.resolve(1);
const p2 = Promise.reject('oops');
const p3 = new Promise(resolve => setTimeout(() => resolve(3), 50));

myPromiseAllSettled([p1, p2, p3]).then(results => {
    console.log(results);
    // [
    //   { status: 'fulfilled', value: 1 },
    //   { status: 'rejected', reason: 'oops' },
    //   { status: 'fulfilled', value: 3 }
    // ]
});

3.4 实际应用场景

批量提交表单,想知道每个表单项的提交结果:

async function submitAllForms(forms) {
    const promises = forms.map(form => submitForm(form));
    const results = await Promise.allSettled(promises);
    
    const success = results.filter(r => r.status === 'fulfilled').length;
    const failed = results.filter(r => r.status === 'rejected').length;
    
    console.log(`成功: ${success}, 失败: ${failed}`);
    return results;
}

四、三者对比总结

方法成功条件失败条件返回值结构
Promise.all全部 resolve任何一个 reject普通数组 [val1, val2]
Promise.race任何一个先 settle任何一个先 settle单个值 val
Promise.allSettled全部 settle不会失败(总会 resolve)对象数组 [{status, value/reason}]

一句话记忆

  • all:一个都不能少,一个都不能输
  • race:谁快谁赢,不管输赢
  • allSettled:不管成功失败,我全都要

五、完整代码汇总

// 辅助函数:将任意值转换为 Promise
const toPromise = (value) => Promise.resolve(value);

// 1. Promise.all
function myPromiseAll(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve, reject) => {
        const len = promises.length;
        if (len === 0) {
            return resolve([]);
        }

        const results = new Array(len);
        let completedCount = 0;

        promises.forEach((item, index) => {
            toPromise(item)
                .then((value) => {
                    results[index] = value;
                    completedCount++;
                    if (completedCount === len) {
                        resolve(results);
                    }
                })
                .catch(reject);
        });
    });
}

// 2. Promise.race
function myPromiseRace(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve, reject) => {
        promises.forEach((item) => {
            toPromise(item).then(resolve, reject);
        });
    });
}

// 3. Promise.allSettled
function myPromiseAllSettled(promises) {
    if (!Array.isArray(promises)) {
        return Promise.reject(new TypeError('Argument is not iterable'));
    }

    return new Promise((resolve) => {
        const len = promises.length;
        if (len === 0) {
            return resolve([]);
        }

        const results = new Array(len);
        let completedCount = 0;

        promises.forEach((item, index) => {
            toPromise(item)
                .then(
                    (value) => {
                        results[index] = { status: 'fulfilled', value };
                    },
                    (reason) => {
                        results[index] = { status: 'rejected', reason };
                    }
                )
                .finally(() => {
                    completedCount++;
                    if (completedCount === len) {
                        resolve(results);
                    }
                });
        });
    });
}

写在最后

手写 Promise 静态方法其实不算难,关键是搞清楚语义边界情况

  1. 参数校验——是不是数组
  2. 空数组处理——要不要特殊处理
  3. 顺序保持——Promise.all 需要用 index 而不是 push
  4. 失败策略——all 一个失败全失败,race 谁先谁赢,allSettled 永不失败

面试的时候能写出来这些细节,面试官肯定会对你刮目相看。

觉得有帮助的话,点个赞呗 有问题评论区见~