在不使用 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());
这很简洁! 但很长一段时间,我只是接受了这个解决方案,并将这段代码复制到我的应用程序中,因为它“有效”。 这篇文章是我尝试理解两件事
- 为什么这种方法有效?
- 为什么我们不能使用其他
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()
的幕后,我们不会等待回调完成,然后再转到下一项。 它完全是同步的。 这些其他方法也是如此
Array.prototype.map()
Array.prototype.forEach()
Array.prototype.filter()
Array.prototype.some()
Array.prototype.every()
但是 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
方法在幕后的工作原理。 我是否遗漏了什么? 有什么错误吗? 请告诉我!
这可能会产生误导。 按顺序意味着您将获得
Loop...
,然后在继续下一个 Promise 之前获得Resolve
。 但这与普通的Promise.all(...)
没有区别。嗨,Rong —
我理解你的困惑,但实际上这篇文章讨论的内容与
Promise.all()
的作用之间存在显着差异。Promise.all()
旨在在一系列 Promise 全部解析后执行某些操作,无论它们解析的顺序如何(它们都可能并行解析,并且Promise.all()
会得到满足)。 这篇文章更多地是关于使这些 Promise 全部按顺序解析,一次一个,绝不并行。 就我所知,使用Promise.all()
之类的东西无法做到这一点。 希望这说得通,并且我正确理解了你的评论。我从未遇到过 reduce() 比简单循环更简洁的情况。 在您的示例中,
let promise = Promise.resolve();
for (const nextID of userIDs) {
promise = promise.then(() => methodThatReturnsAPromise(nextID));
}
更容易阅读和理解。 更糟糕的是,当人们使用 reduce() 来修改对象,并且 reducer 函数只返回其第一个参数时。 为了可能避免临时变量,它极大地损害了可读性。
这些都是一些很好的观点。
for...in
方法绝对更易读; 我很好奇reduce()
的吸引力是否超出了节省变量声明。说到这里,探讨为什么 THAT 方法适用于按顺序解析将非常有趣。
for...in
循环并不总是同步的?或者,如果您能使用 async/await,它就是
完美! 切中要害。 阅读愉快。
在 Promise.all 内部执行映射。 如果映射内部执行的函数使用 async/await,则它将在下一次迭代之前完成(按顺序,而不是并行)。 额外的好处是 Promise.all 将所有 Promise 值解析为数组。 我正在从我的 iPad 发布此内容,否则我会提供代码示例。
为什么不直接使用 rxjs?
我得说,这是一种不太常见的处理 Promise 的方法,在网上你找不到太多类似的例子,探索新方法总是好的,所以,感谢你写这篇文章,Alex!
不太理解为什么会有这么多“讨厌”的评论,毕竟编程中没有一种放之四海而皆准的处理方法——合适的工具用于合适的场景,具体情况具体分析。
如果需要处理多个 Promise,我通常会像一些人写的那样使用
Promise.all
,我从未遇到过需要这种方法的场景(尽管了解它很好)。如今,借助 HTTP/2,浏览器可以轻松处理多个并发请求,如果你有大量的请求,那么用户肯定需要等待很长时间才能依次解析每个请求。
此外,如果我们需要按顺序执行 Promise,比如有一个工厂方法 ping 某个服务器以构建全局配置对象,这样的代码会不会有点耦合度太高了?
你能举一个这种方法在现实世界中的应用场景吗?
我完全不建议使用这种方法。1. 为什么我应该在异步代码中使用同步方法,如果不使用 Promise 会更好。2. 容易造成混淆,并可能引入隐藏的问题。我遇到过一个情况,有人为了解决代码缓慢的问题,使用了 reduce 按照顺序解析 Promise(有趣的是,Promise 回调函数是一个普通的函数,带有一个 1 毫秒的 setTimeout)。这段代码不仅难以调试,而且隐藏了真正的缓慢原因,而缓慢的原因在其他地方。
如果真的有需要使用这种方法的现实场景,那么请重新思考你的代码,你可能做错了什么。
我的建议是,通常情况下避免使用这种作弊式的反模式。
随你便,兄弟!这篇文章根本没有规定任何东西。它只是想解释一下,
reduce()
方法为什么可以用来按顺序解析 Promise,因为这种方法很常见。我对这种循环越来越熟悉,老实说我不确定我是否会自己使用它。我想这在很大程度上取决于具体的应用场景。好吧,一旦你需要进行网络请求,你就有了异步代码……所以你没有选择。如果你需要按顺序执行某些操作(例如,保证某些内容按顺序写入数据库),那么你需要它表现得像同步一样。我可以举更多例子,但这里的主要内容是,这对于某些情况来说是一个有效的解决方案。
@Mike 网络请求最终可以以同步模式执行(同步 AJAX),但这并不是用例,因为这会冻结用户界面。
你声称的第二个陈述是一个错误的用例,因为你永远无法保证前端上的顺序操作(我总是可以通过自定义 js 作弊)。无论前端保证什么,所有验证都应该在后端进行。
如果你给我更多例子,我可以告诉你它们是对还是错。数据库示例是一个不好的用例。
好的,再举个例子。你在使用 Node.js,并且有一个数据库作为服务(比如 Firebase)。你使用官方的 Firebase Node 库与数据库交互,该库返回 Promise。你需要按顺序执行操作。
@Mike 再次强调,数据库示例是不正确的,客户端和服务器是谁并不重要。在你的例子中:简单地发送数组中元素的索引,并将其存储为顺序号,不需要按顺序运行。
即使你按顺序发送它们,Firebase 上的顺序是什么?Firebase 上的随机字符串 ID?
如果 Promise 失败会怎样?
如果你真的需要顺序,那么发送元素的数组索引,并并行解决 Promise。
我可以向你证明,这种方法的每个用例都可以用更简单、更好的方法完成。
有没有人注意到最后一个 Promise 没有被解析?所以,如果我们有 3 个用户 ID,我们只会处理前两个。
干杯!
我同意 Alex 的观点,这只是一个有趣的用例,但在实际应用中非常非常罕见。实际上,我无法想到一个将其作为最佳替代方案的用例。
我喜欢你的文章!所以我想问一下,我是否可以将你的文章翻译成中文,然后发布在我的博客上。当然,我会添加你的文章链接,并在博客开头说明我的文章是从你的文章翻译过来的。
你好!当然,你可以自由使用网站上的任何内容:https://css-tricks.org.cn/license/