为什么使用 reduce() 按顺序解析 Promise 有效

Avatar of Alex MacArthur
Alex MacArthur

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费积分!

在不使用 Promise 对象的情况下编写异步 JavaScript 就像闭着眼睛烘焙蛋糕一样。 虽然可以做到,但过程会很混乱,而且你很可能会烫伤自己。

我不会说它 必须 这样,但你懂我的意思。它确实很好。不过,有时它需要一点帮助来解决一些独特的挑战,**比如当你尝试按顺序解析一堆 Promise 时,一个接一个地解析。** 例如,当您通过 AJAX 进行某种批处理时,这种技巧会派上用场。 您希望服务器处理一堆东西,但不是一次性全部处理,因此您会随着时间推移逐步处理。

排除那些可以简化此任务的软件包(例如 Caolan McMahon 的 async 库),最常建议的按顺序解析 Promise 的解决方案是使用 Array.prototype.reduce()。 您可能听说过 这个方法。 获取一系列事物,并将它们简化为单个值,如下所示

let result = [1,2,5].reduce((accumulator, item) => {
  return accumulator + item;
}, 0); // <-- Our initial value.

console.log(result); // 8

但是,当出于我们的目的使用 reduce() 时,设置看起来更像这样

let userIDs = [1,2,3];

userIDs.reduce( (previousPromise, nextID) => {
  return previousPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

或者,以更现代的格式

let userIDs = [1,2,3];

userIDs.reduce( async (previousPromise, nextID) => {
  await previousPromise;
  return methodThatReturnsAPromise(nextID);
}, Promise.resolve());

这很简洁! 但很长一段时间,我只是接受了这个解决方案,并将这段代码复制到我的应用程序中,因为它“有效”。 这篇文章是我尝试理解两件事

  1. 为什么这种方法有效?
  2. 为什么我们不能使用其他 Array 方法来做同样的事情?

为什么这种方法有效?

请记住,reduce() 的主要目的是将一堆东西“减少”成一个东西,它通过在循环运行时将结果存储在 accumulator 中来实现。 但 accumulator 不必是数字。 循环可以返回任何它想要的东西(例如 Promise),并在每次迭代中循环使用该值。 值得注意的是,无论 accumulator 的值是什么,循环本身永远不会改变其行为——包括其执行速度。 它只是尽可能快地继续遍历集合,就像线程允许的那样。

理解这一点非常重要,因为它可能与您认为在此循环期间发生的事情背道而驰(至少对我来说是这样)。 **当我们使用它来按顺序解析 Promise 时,reduce() 循环实际上并没有减速。** 它完全是同步的,以其正常的速度尽可能快地执行,一如既往。

查看以下代码片段,并注意循环的进度完全不受回调中返回的 Promise 的影响。

function methodThatReturnsAPromise(nextID) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {

      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);

      resolve();
    }, 1000);
  });
}

[1,2,3].reduce( (accumulatorPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);

  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

在我们的控制台中

"Loop! 11:28:06"
"Loop! 11:28:06"
"Loop! 11:28:06"
"Resolve! 11:28:07"
"Resolve! 11:28:08"
"Resolve! 11:28:09"

Promise 按我们期望的那样按顺序解析,但循环本身快速、稳定且同步。 在查看 MDN 的 reduce() polyfill 后,这很有道理。 while() 循环一遍又一遍地触发 callback() 与异步无关,这就是幕后发生的事情

while (k < len) {
  if (k in o) {
    value = callback(value, o[k], k, o);
  }
  k++;
}

考虑到所有这些,真正的魔力发生在这里

return previousPromise.then(() => {
  return methodThatReturnsAPromise(nextID)
});

每次我们的回调触发时,我们都会返回一个解析为 另一个 Promise 的 Promise。 虽然 reduce() 不会等待任何解析发生,**但它提供的优势是能够在每次运行后将某些内容传递回同一个回调中**,这是 reduce() 独有的功能。 结果,我们能够构建一个 Promise 链,这些 Promise 解析为更多 Promise,使一切都变得井井有条

new Promise( (resolve, reject) => {
  // Promise #1
  
  resolve();
}).then( (result) => { 
  // Promise #2
  
  return result;
}).then( (result) => { 
  // Promise #3
  
  return result;
}); // ... and so on!

所有这些也应该揭示了为什么我们不能在每次迭代中只返回一个 新的 Promise。 因为循环是同步的,所以每个 Promise 都会立即触发,而不是等待之前创建的 Promise。

[1,2,3].reduce( (previousPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);
  
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);
      resolve(nextID);
    }, 1000);
  });
}, Promise.resolve());

在我们的控制台中

"Loop! 11:31:20"
"Loop! 11:31:20"
"Loop! 11:31:20"
"Resolve! 11:31:21"
"Resolve! 11:31:21"
"Resolve! 11:31:21"

是否可以在完成 所有 处理之前执行其他操作? 是的。 reduce() 的同步特性并不意味着您不能在每个项目完全处理后举办派对。 看

function methodThatReturnsAPromise(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Processing ${id}`);
      resolve(id);
    }, 1000);
  });
}

let result = [1,2,3].reduce( (accumulatorPromise, nextID) => {
  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

result.then(e => {
  console.log("Resolution is complete! Let's party.")
});

由于我们在回调中返回的只是链接的 Promise,因此循环结束后我们得到的也只有 Promise:一个 Promise。 之后,我们可以根据需要处理它,即使在 reduce() 完成其过程很久之后。

为什么其他 Array 方法不起作用?

请记住,在 reduce() 的幕后,我们不会等待回调完成,然后再转到下一项。 它完全是同步的。 这些其他方法也是如此

但是 reduce() 是特殊的。

我们发现 reduce() 对我们有效的原因是我们能够将某些内容直接返回到我们的同一个回调中(即 Promise),然后我们可以通过让它解析为另一个 Promise 来构建它。 但是,对于所有这些其他方法,我们都无法将 来自 回调的返回值作为参数传递给我们的回调。 相反,这些回调参数都是预先确定的,这使得我们无法利用它们来实现诸如按顺序解析 Promise 之类的事情。

[1,2,3].map((item, [index, array]) => [value]);
[1,2,3].filter((item, [index, array]) => [boolean]);
[1,2,3].some((item, [index, array]) => [boolean]);
[1,2,3].every((item, [index, array]) => [boolean]);

希望这对您有所帮助!

至少,我希望这有助于阐明为什么 reduce() 能够以这种方式独一无二地处理 Promise,并让您更好地了解常见 Array 方法在幕后的工作原理。 我是否遗漏了什么? 有什么错误吗? 请告诉我!