我们有时候希望通过监听多个 Promise 的进展来确定下一步的行动。 JavaScript 提供了几种方法来监听多个 Promise,并以略微不同的方式对它们做出响应。
3.1 Promise.all()
Promise.all()
方法可以接受一个 iterable (例如数组)作为参数,该参数包含所需要监听的 Promise, 并返回一个 Promise。当且仅当该 iterable 对象中的每个 Promise 都被履行(fulfilled)时,它才会返回一个处于履行状态(fulfilled)的 Promise。如下所示:
1 | let promise1 = Promise.resolve(42); |
这里的每个被履行的 promise 都有相对应的一个值, 对 promise.all()
的调用创建了 promise4。 当 promise1, promise2 和 promise3 都被履行时, promise4 才会被履行。传递给 promise4 的履行处理器的是一个包含 42,43,44 的数组, 这些值是按照传递给 promise.all()
的 promise 的顺序存储的。这样一来, 我们就可以将 promise 的结果与履行它们的 promise 相匹配。
如果传递给 promise.all()
的某个 promise 被拒绝, 那么返回的 promise 会被立刻拒绝,而不会等待其他的 promise 执行完成:
1 | let promise1 = Promise.resolve(42); |
在这个例子中,第二个 promise 被拒绝,其值为 43。 promise4 的拒绝处理器被立即调用, 而不是等待第一个 Promise 或第三个 Promise 执行完成。 (它们仍然会执行完成,只不过 Promise4 不会等待执行结果。) _拒绝处理器总是接收单一的值,而不是接收数组_。 这个值是被拒绝的 Promise 的值。在本例中,我们传递给拒绝处理器的值为 43,以表明拒绝来自 promise2。
⚠️ iterable 参数中的任何非 Promise 值都会被传递给
Promise.resolve()
并被转换为 Promise。
3.1.1 何时使用 Promise.all() 方法
Promise.all()
适用于任何需要等待多个 Promise 履行的情况,这其中任何一个 Promise 被拒绝都会导致整个操作失败,下面介绍 Promise.all()
的一些常见用例
- 同时处理多个文件
当使用如 Node.js 或 Deno 这样的服务器端 JavaScript 运行环境时,我们可能需要读取多个文件以处理其中的数据。在这种情况下,最高效的做法是并行读取文件,并等待它们全部被读取后再继续处理数据。下面是一个在 Node.js 中读取文件的例子:
1 | import { readFile } from 'node:fs/promises'; |
这个例子使用 Node.js 基于 Promise 的文件系统 API 来并行读取多个文件。readFiles()
函数接受一个由待读取文件名组成的数组作为参数, 然后将每个文件名映射到 入readFile()
函数创建的 Promise 对象。文件以文本形式读取。(这一点从第二个参数 utf8
可知)。其读取结果在履行处理器中作为fileContents
数组可用,其中包含每个文件中的文本。在这里,文件内容被解析为 JSON 格式并存储在 data 数组中, 然后再被传递给处理数据的函数。这是处理多个文件的常用方法, 因为如果任何一个文件不能被读取或解析, 那么整个操作就不能正确完成,应该及时停止。
- 调用多个相互依赖的 Web 服务 API
Promise.all()
的另一个常见用例是调用多个相互依赖的 Web 服务 API, 这在 REST API 中尤为常见,因为与实体相关的每一种数据类型都可能有自己的端点。考虑这样一个场景:每个用户都拥有博客文章和相册,我们需要在用户的个人资料中展示这些信息。示例代码如下:
1 | const API_BASE = 'https://jsonplaceholder.typicode.com'; |
在以上代码中,我们为每个用户提供端点 /posts
和 /albums
。fetchUserData()
函数接受一个用户 ID 并生成要调用的 URL。接着 URL 被映射到每个fetch()
调用所返回的 Promise。当收到响应后,另一个Promise.all()
调用将每个响应映射到另一个 Promise。这个 Promise 要么是 JSON 正文(如果响应状态码介于 200 和 299 之间),要么是处于拒绝状态的 Promise,这将中断整个操作并调用拒绝处理器。在最后的履行处理器中, posts 和 albums 的数据准备就绪,等待处理。
- 人为引入延迟
有时我们想延迟一些事情的发生,与服务器端相比,这种情况更可能发生在浏览器端。比如我们有时需要在用户操作和响应之间引入延迟。具体的说,我们可能想在从服务器获取数据期间显示一个加载指示器。但如果响应太快,那么用户可能看不到加载动画,因而无法判断屏幕上的数据是否为最新数据。在这种情况下,我们可以人为引入延迟如下所示:
1 | const API_BASE = 'https://jsonplaceholder.typicode.com'; |
这段代码在前一个例子的基础上,为每一个 fetch()
调用引入了延迟。delay()
函数返回一个 promise。该 promise 会在指定的毫秒数过去后履行,实现方法是使用 setTimeout()
函数,并传递一个调用 resolve()
的回调函数。请注意,这种情况下,无需向 resolve()
传递任何值,因为没有相关数据。
⚠️ 我们也可以直接将 resolve 作为第一个参数传递给
setTimeout()
。但是某些 javascript 运行环境会向超时回调函数传递参数。为了在各种运行环境中实现最佳的兼容性,最好从另一个函数内部调用resolve()
。
fetchUserData()
函数发起对指定用户 ID 的 Web 服务请求。 和之前的例子一样,promise.all()
被用来同时监控多个 fetch 请求。但在这个例子中,传递给 promise.all()
的 promise 数组还包含对 delay()
的调用。当返回的 promise 被履行时,履行处理器会收到包含所有结果的数组。其中最后一个元素是 undefined。在最终结果从 fetchUserData()
返回之前移除最后一个元素,这样调用 fetchUserData()
的代码就根本不需要知道 delay()
被调用了。CSS 类 loading 被添加给 DOM 中的应用元素,以表明数据正在被检索, 随后在数据就绪时,被解决处理器删除。
以上便是 promise.all()
的一些常见用例。如果一个 promise 被拒绝了,但是我们还想继续操作下去,又该怎么办呢?在这种情况下,promise.allSettled()
方法是更好的选择。
3.2 Promise.allSettled() 方法
Promise.allSettled()
方法在 Promise.all()
方法的基础上做了细微的改变。该方法会等待 iterable 对象中所有的 Promise 都被解决,不管它们是被履行还是被拒绝。 Promise.allSettled()
返回的值总是一个处于履行状态的 Promise,它带有一个结果对象数组。
对于处于履行状态的 Promise 而言,其结果对象有两个属性:
status
:该属性总是被设置为fulfilled
(履行)。value
:这是该 Promise 被履行的值。
对于处于拒绝状态的 Promise 而言,其结果对象也有两个属性:
status
:该属性总是被设置为rejected
(拒绝)。reason
:这是该 Promise 被拒绝的值。
我们可以使用返回的对象数组来确定每个 Promise 的结果:
1 | let promise1 = Promise.resolve(42); |
尽管第二个 Promise 处于拒绝状态,但是对于 Promise.allSettled()
的调用仍然会返回一个处于履行状态的 Promise。
3.1.1 何时使用 Promise.allSettled() 方法
Promise.allSettled()
方法的许多用例和 Promise.all()
相同。不过它最适合用于忽略操作被拒绝、以不同方式处理拒绝或允许部分成功的情况。下面是一些常见的用例:
- 分别处理多个文件
在讨论 Promise.all()
时,我们看到了处理多个文件的例子。这些文件相互依赖,只有当所有文件都处理成功后,整个操作才能成功。在另一些情况下,我们分别处理多个文件。如果一个文件处理失败,那么我们无需停止整个操作,而是继续处理其他文件,同时记录处理失败的操作,以便之后重试.下面是一个在 node.js 中处理多个文件的例子:
1 | import { readFile, writeFile } from 'node:fs/promises'; |
这段代码读取一系列文件,颠倒文件中的文本顺序,然后将这些文本写回原始文件。当然,你可以用其他任何操作来代替 transformText()
, transformFiles()
函数接收一个由文件名组成的数组,读取文件的内容,转换文本,并将转换后的文本写回文件。链式 promise 体现了这个过程中的每一步。拒绝处理器,为拒绝理由添加了属性 filename, 以便事后更容易解释结果。当对所有文件的操作都完成后,我们对 results 进行过滤,找出转换失败的任务,然后将失败结果输出到控制台。在生产环境中,最好把失败结果送入一个监控系统或一个队列中,以便再次尝试转换。
- 调用多个相互独立的 Web 服务 API
并且我们希望所有的请求都能成功。如果无需所有的请求都成功,那么我们可以使用 Promise.allSettled()
。 回顾之前的例子,如果可以显示用户资料页面,即使有些数据丢失也没关系,那么我们就可以通过使用 Promise.allSettled()
来避免向用户显示错误消息。示例代码如下所示:
1 | const API_BASE = 'https://jsonplaceholder.typicode.com'; |
这个例子还调用了第三个端点,即:${API_BASE}/users/${userId}/extras
, 因为这个端点并不存在,所以代码将返回 404。一旦所有请求都完成,履行处理器就会将每个结果映射到对应的 value 属性,这样做确保任何处于拒绝状态的 promise 都会被映射到 undefined,并且处于履行状态的 promise 被映射到从 fetch()
返回的响应对象。
首先,因为 response 可能是 undefined,所以我们需要在检查 OK 属性之前确定 response 是真值,然后读取每个有效响应的 JSON 主体,最后由旅行处理器读取数据。因为不能保证每个请求的数据都存在,所以我们需要在处理数据之前检查每个值是否存在。
- 等候动画播放完成
在一个网页中,元素可以同时以多种方式实现动画化。比如我们可以使一个元素从网页的底部移动到顶部,同时改变该元素的宽度和高度,使其逐渐进入视野。在这种情况下,最好在对元素或网页进行下一步修改之前,等候所有的动画播放完成。代码如下:
1 | function waitForAnimations(element) { |
在这个例子中,我们既不关心是否有任何动画播放失败,也不关心是否在动画播放过程中收到任何值。因此,Promise.allSettled()
比Promise.all()
更合适。getAnimations()
方法返回一个动画对象数组,其中每个对象都有一个包含 promise 的属性 finished。当动画播放完成时,这个 promise 就会进入解决状态,通过将每个 promise 传入 Promise.allSettled()
, 我们将在所有动画都播放完成时得到通知。因为 Promise.allSettled()
永远不会返回处于拒绝状态的 promise,所以我们可以直接附加一个履行处理器,而不用担心漏掉任何未捕获的拒绝错误。
3.3 Promise.any() 方法
Promise.any()
方法接收一个包含多个 promise 的 iterable 对象,并在传入的任何 promise 被履行时返回一个处于履行状态的 promise。一旦其中一个 promise 被履行,该操作就会提前完成,(这一点与Promise.all()
相反,在Promise.all()
中,一旦有一个 promise 被拒绝,操作就会提前完成。)下面是一个例子:
1 | let promise1 = Promise.reject(43); |
在这个例子中,尽管第一个 promise 被拒绝了,但对 Promise.any()
调用还是成功了。因为第二个 promise 被成功履行,第三个 promise 的结果则被忽略。
如果传递给 Promise.any()
的所有 promise 都被拒绝,那么 Promise.any()
将返回一个处于拒绝状态的 promise, 并且拒绝的理由是 AggregateError, AggregateError 是一个 错误,它代表了存储在属性 errors 中的多个错误。举例如下:
1 | let promise1 = Promise.reject(43); |
在这里 Promise.any()
收到的 promise 都没有被履行,因此返回的 promise 以 AggregateError 为由被拒绝。可以检查属性 errors,它是一个数组,可用来检索每个 promise 的拒绝值。
3.3.1 何时使用 Promise.any() 方法
Promise.any()
方法最适合这样的情况。我们希望传入的任何一个 promise 被成功履行即可,而不关心有多少其他 promise 被拒绝,除非他们都被拒绝。下面介绍一些常见用例:
- 执行对冲请求
对冲请求是指客户端向多台服务器发出请求,并接受第一个回复的响应。这在客户端需要最小化延迟,且有服务器资源,专门用于管理额外负载和重复响应的情况下,很有用。来看一个例子:
1 | const HOSTS = ['api1.example-url', 'api2.example-url']; |
每个对冲请求都会调用一个主机数组 hedgedFetch()
函数。根据这些主机名,创建一个 fetch()
请求数组,并将该数组传递给 Promise.any()
, 即使实际上有多个请求,但在用户看来也只有一个请求。这样一来,用户只用使用一个履行处理器和一个拒绝处理器即可处理操作结果。就算有一个请求失败了,用户也不会知道,只有当所有请求都失败时,javascript 才会调用拒绝处理器。
- 在 service worker 中使用最快速的响应
使用 service worker 的网页通常可以选择是从网络还是从缓存中加载数据。在某些情况下,网络请求可能比从缓存中加载更快。因此我们可能想使用 Promise.any()
来选择更快的响应。下面这段代码说明了 service worker 是如何工作的:
1 | self.addEventListener('fetch', (event) => { |
使用 fetch 事件监听器,我们可以监听网络请求并拦截响应。 这个关于 service work 的例子使用 fetch 事件监听器从缓存(使用caches.match()
)和网络中(使用 fetch()
)读取数据,对caches.match()
的调用总是返回一个处于履行状态的 promise。其结果要么是匹配的响应对象,要么是 undefined(如果请求不再缓存中)。event.respondWith()
方法,需要传递一个 promise, 因此,这个事件处理器传递了 Promise.any()
的结果。
Promise.any()
接收两个 promise, 一个是获取的响应,它带有拒绝处理器的,并且该拒绝处理器默认返回来自缓存的响应,另一个是缓存的响应本身。通过这种方式,如果缓存中存在满足要求的数据或者获取网络请求被拒绝,则 javascript 都将返回来自缓存的响应。然后履行处理器需要确保收到的响应是有效的。(请注意,如果缓存首先响应但未命中,则 response 可能为 undefined)。拒绝处理器不执行任何操作,因为在这种情况下,没有备选方案,由于获取的响应和缓存的响应都被拒绝,因此 javascript 会悄悄的忽略错误,以允许浏览器采取默认行为。
虽然 Promise.any()
在第一个成功履行的 promise 出现后就会提前完成,但我们也可以根据第一个已解决(可以是履行也可以是拒绝)的 promise来提前完成整个操作。而不管结果如何,对于这种情况,我们可以使用 Promise.race()
。
3.4 Promise.race() 方法
在监控多个 promise 时,Promise.race()
方法与 Promise.any()
方法略有不同,这个方法也接受一个包含多个 promise 的 iterable 对象并返回一个 promise。但返回的 promise 在第一个 promise 确定状态 就立即确定其状态。Promise.race()
不像 Promise.all()
那样等待所有 promise 都被履行,也不像 Promise.any()
那样在第一个 promise 被履行时即提前完成,而是一旦数组中的任何 promised 状态确定(不管成功还是失败都叫做状态已确定),就会返回一个 promise。来看一个例子:
1 | let promise1 = Promise.resolve(42); |
在这段代码中,第一个 promise 处于履行状态,其他 promise 则对应之后要执行的任务。随后 promise4 的履行处理器的调用,其值为 42,而其他 promise 被忽略,传递递给 Promise.race()
的 promise, 互相竞争,看哪个先确定状态,如果首先确定状态的 promise 是履行状态,那么返回的 promise 也将处于履行状态,如果首先确定状态的 promise 是拒绝状态,那么返回的 promise 也将处于拒绝状态。以拒绝状态为例,我们来看一下代码:
1 | let promise1 = new Promise((resolve, reject) => { |
在这段代码中,promise1 和 promise3 都使用 setTimeout()
来延迟履行时间,结果是 promise4 被拒绝。因为 promise2 在 promise1 或 promise3 被解决之前就被拒绝了。尽管 promise1 和 promise3 最终都成功履行,但他们仍被忽略。因履行状态在 promise2 被拒绝之后才确定。
3.4.1 何时使用 Promise.race() 方法
如果我们希望能够提前完成涉及多个 promise 的操作,那么 Promise.race()
方法是最佳选择。在使用 Promise.any()
时,我们希望其中一个 promise 成功,并且仅在所有 promise 都失败时才给予关注。与此不同,在使用Promise.race()
时,即使一个 promise 失败,只要他在任何其他 promise 成功之前失败,我们也希望得知这个情况。下面介绍 Promise.race()
的常见用例:
- 为操作设置超时
虽然 fetch()
函数有很多有用的功能,但它无法为一个给定的请求管理超时(timeout),一个请求会处于挂起状态,直到该请求以某种方式完成。我们可以通过使用 Promise.race()
来轻松的创建一个封装方法,为任何请求添加超时:
1 | function timeout(millseconds) { |
timeout()
函数和我们之前创建的 delay()
函数类似, 只不过它在延迟后调用 reject()
, (delay 函数则是调用 resolve()
)。在这种情况下,延迟表示发生了错误。因为我们想在某请求所花的时间超预期时被告知。 fetchWithTimeout()
函数随后在一个数组中调用 fetch()
和 timeout()
并将其传递给了 Promise.race()
。如果对 fetch()
的调用所花的时间超过了 timeout()
所规定的时间,返回的 promise 就会被拒绝,以便我们进行适当的处理。
‼️ 请记住,即使
fetchWithTimeout()
拒绝了一个超过指定时间的请求,该请求也不会被取消,它将继续在后台等待响应,即使该响应应该被忽略。
3.5 总结
如果想同时监控和响应多个 promise, 那么我们可以采用 javascript 提供的多种方法,每种方法略有不同,但都让我们能够并行的运行多个 promise,并将它们作为一个整体来响应。
Promise.all(): 当且仅当所有 promise 都被履行时,返回的 promise 才会处于履行状态。若任何一个 promise 被拒绝,则返回的 promise 就处于拒绝状态的。
Promise.allSettled(): 返回的 Promise 总是处于履行状态,并且带有一个结果对象数组。
Promise.any(): 一旦有一个 promise 被履行,返回的 promise 就处于履行状态。而当所有的 promise 都被拒绝时,返回的 promise 就处于拒绝状态。
Promise.race(): 返回的 promise 总是处于第一个被确定状态的 promise 的状态。如果首先确定状态的 promise 是进入履行状态,那么返回的 promise 也将处于旅行状态。如果首先确定状态的 promise 是进入拒绝状态,那么返回的 promise 也将处于拒绝状态。