面试还在用forEach手写Promise.all()?你写的只是半成品

2,615 阅读6分钟

前言

最近看很多掘友都在说现在面试特喜欢考手写Promise.all、Promise.any等方法,我突然想起来也是最近才注意到的一个Promise.all的细节,相信相当多的人会把这个细节忽略掉,我搜了一下相关文章,果然不出所料,很多人掉坑了,所以我觉得有必要写一篇文章来好好唠唠这个事。

Promise.all

先看MDN上的定义:

Promise.all() 方法接收一个 promise 的 iterable 类型(注:Array,Map,Set都属于ES6的iterable类型)的输入,并且只返回一个Promise实例, 那个输入的所有 promise 的 resolve 回调的结果是一个数组。

这个Promise的 resolve 回调执行是在所有输入的 promise 的 resolve 回调都结束,或者输入的 iterable 里没有 promise 了的时候。它的 reject 回调执行是,只要任何一个输入的 promise 的 reject 回调执行或者输入不合法的 promise 就会立即抛出错误,并且reject的是第一个抛出的错误信息。

我们来一句一句分析

Promise.all() 方法接收一个 promise 的 iterable 类型(注:Array,Map,Set都属于ES6的iterable类型)的输入

Promise.all接受的参数是一个可迭代的对象,换句话说就是具备迭代器(iterator)接口的数据结构,大家如果不了解可迭代对象的话,推荐查看这篇文章彻底搞懂,别忽视迭代器(Iterator),你每天都在与它打交道

我们先用原生的Promise.all看一下,传入非可迭代类型会怎么样:

image.png

可以看到,传入非可迭代类型会报reject,我们先来写这部分

// 给Promise添加静态方法myAll,因为原生的all就是静态方法
Promise.myAll = function (promises) {
  if (! typeof promises[Symbol.iterator] === 'function') { // 检查是否是可迭代类型
    const type = typeof promises;
    // 拼接错误提示字符串,基本数据类型要把值也提示给开发者
    return Promise.reject(`${type} ${type === 'object' ? '' : promises} is not iterable (cannot read property Symbol(Symbol.iterator))`);
  }
};

看下效果

image.png

nice! 符合预期,接下来分析下一句


并且只返回一个Promise实例, 那个输入的所有 promise 的 resolve 回调的结果是一个数组

提炼: 返回的是Promise实例,返回值是数组

// 给Promise添加静态方法myAll,因为原生的all就是静态方法
Promise.myAll = function (promises) {
  if (! typeof promises[Symbol.iterator] === 'function') { // 检查是否是可迭代类型
    const type = typeof promises;
        return Promise.reject(`${type} ${type === 'object' ? '' : promises} is not iterable (cannot read property Symbol(Symbol.iterator))`); // 基本数据类型要把值也提示给开发者
  }
  
  const results = []; // 执行结果数组
  return new Promise((resolve) => { // 返回Promise
    resolve(results);
  });
};


接着再看

这个Promise的 resolve 回调执行是在所有输入的 promise 的 resolve 回调都结束,或者输入的 iterable 里没有 promise 了的时候。

简单说就是要等所有promise都执行成功,可迭代对象里不是promise的数据转成promise后直接resolve,全部执行成功后才能执行Promise.all的resolve回调。

看下原生传入不是promise的数据是啥效果 image.png

不是promise的数据会立即resolve并返回原值,所以我们不能把所有传过来的都当做Promise对象而直接调用.then(),用Promise.resolve()包裹一下之后就可以了,如:

// 这样就可以调用then方法了
Promise.resolve('666').then();
Promise.resolve({a: 3}).then();

我们把上面分析的实现一下:

// 给Promise添加静态方法myAll,因为原生的all就是静态方法
Promise.myAll = function (promises) {
  if (! typeof promises[Symbol.iterator] === 'function') { // 检查是否是可迭代类型
    const type = typeof promises;
        return Promise.reject(`${type} ${type === 'object' ? '' : promises} is not iterable (cannot read property Symbol(Symbol.iterator))`); // 基本数据类型要把值也提示给开发者
  }
  
  let doneCount = 0;  // 执行成功计数器
  const results = []; // 执行结果数组
  return new Promise((resolve) => {
    // Object.entries将promises转换为[ ['0', 'a'], ['1', 'b'], ['2', 'c'] ]这种键值对数组,便于获取索引
    for (const [index, item] of Object.entries(promises)) { // 获取遍历项和索引
      Promise.resolve(item).then((res) => {
        results[index] = res; // index的作用是保证输出结果的顺序与输入保持一致
        doneCount += 1;
        if (doneCount === promises.length) return resolve(results); // 全部成功时resolve
      });
    }
  });
};

看下效果。

image.png

完美!

TIPS:这里有的同学可能有疑问了,为啥没用forEach实现,我看网上很多手写的Promise都是用forEach实现的啊,用forEach是不是也可以啊?

严格来说,不行!我们最开始有提到,Promise.all接受的是可迭代对象为入参,即Promise.all(iterable),而forEach并不能遍历所有可迭代对象。

// 创建一个可迭代对象
const iterator = {
    i: 0,
    next() { // 实现next()接口
        if (this.i > 10) return {value: undefined, done: true };
            return { value: this.i++, done: false };// this.i++ 将迭代器指向下一个元素
    },
    
    // 实现对象的迭代器接口,然后它就可以被遍历了
    [Symbol.iterator]() {
        return this; // 因为这个对象本身就是迭代器对象,所以可以用自身作为自己的迭代器
    }
}

// 上面这部分如果不理解的话建议看我的另一篇文章《别忽视ES6迭代器(Iterator),你每天都在与它打交道》,花5分钟搞懂它

// 先用forof遍历一下,输出正常
for (const item of iterator) {
    console.log(item); // 0,1,2,...,10
}
// 再用forEach遍历一下,报错
iterator.forEach((item) => {
    console.log(item) // Error: iterator.forEach is not a function
})

不妨用forEach写出来试一下

image.png

而原生的all方法是可以正常执行并返回数据的

image.png

所以,你用forEach实现的实际上是个半成品forof可以遍历所有可迭代对象,所以用for of更合适。

但是,变通一下,我们还是可以使用forEach的,但是很多同学实际上都忽略了这一点

// 如果要使用forEach可以与扩展运算符,或Array.from方法配合,将可迭代对象转换为数组,forEach可以迭代数组 
Promise.myAll = function (promises) {
  if (! typeof promises[Symbol.iterator] === 'function') { // 检查是否是可迭代类型
    const type = typeof promises;
        return Promise.reject(`${type} ${type === 'object' ? '' : promises} is not iterable (cannot read property Symbol(Symbol.iterator))`); // 基本数据类型要把值也提示给开发者
  }
  
  const arr = [];
  let count = 0;
  return new Promise((resolve, reject) => {
    // 或者 const promsArr = [...promises];
    Array.from(promises).forEach((item, i) => {
      Promise.resolve(item).then((res) => {
        arr[i] = res;
        count += 1;
        if (count === promises.length) resolve(arr);
      }, reject);
    });
  });

ok,了解了使用forEach的坑之后我们接着分析,现在还差最后一哆嗦

它的 reject 回调执行是,只要任何一个输入的 promise 的 reject 回调执行或者输入不合法的 promise 就会立即抛出错误,并且reject的是第一个抛出的错误信息。

任意一个promise发生错误,就会抛出错误,并且reject的是第一个抛出的错误信息,先看下原生咋处理错误的

image.png

来吧,开搞

// 给Promise添加静态方法myAll,因为原生的all就是静态方法
Promise.myAll = function (promises) {
  if (! typeof promises[Symbol.iterator] === 'function') { // 检查是否是可迭代类型
    const type = typeof promises;
        return Promise.reject(`${type} ${type === 'object' ? '' : promises} is not iterable (cannot read property Symbol(Symbol.iterator))`); // 基本数据类型要把值也提示给开发者
  }
  
  let doneCount = 0;  // 执行成功计数器
  const results = []; // 执行结果数组
  return new Promise((resolve, reject) => {
    for (const [index, item] of Object.entries(promises)) {
      Promise.resolve(item).then((res) => {
        results[index] = res; // index的作用是保证输出结果的顺序与输入保持一致
        doneCount += 1;
        if (doneCount === promises.length) return resolve(results); // 全部成功时resolve
      }, reject); // 别的地方不变,在这里加上reject,错误时直接调用外层Promise的reject函数,或者你先catch再返回也可以
    }
  });
};

本次改动很简单,在then的第二个参数直接调用reject,看下效果:

image.png

与原生一致,完美!至此,Promise.all我们就实现完啦,顺着我的思路来,相信你已经完全掌握了,新技能get!

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与发家分享更多干货