logo

JavaScript Promise - 批量处理 (3/6)

有时候我们可能需要同时监控多个 promise 的处理进度,以确定下一步的行为。JavaScript 提供了几个满足这些需求的方法,本篇主要看一下这些方法之间的区别。

Promise.all() 方法

概念

const p1 = Promise.resolve(2)
const p2 = new Promise((resolve, reject) => resolve(3))
const p3 = new Promise((resolve, reject) => setTimeout(() => resolve(4), 100))

const p4 = Promise.all([p1, p2, p3, 5])
p4.then(v => {
  console.log(Array.isArray(v)) // true
  console.log(v) // [ 2, 3, 4, 5 ]
})

上面的代码说明两点:

如果 Promise.all() 失败,则其结果是失败的那个 promise 的返回值:

// promise-all.mjs
const p1 = Promise.resolve(2)
const p2 = new Promise((resolve, reject) => reject(3))
const p3 = new Promise((resolve, reject) => setTimeout(() => resolve(4), 5000))

const p4 = Promise.all([p1, p2, p3, 5])
p4.catch(v => {
  console.log(Array.isArray(v)) // false
  console.error(v) // 3
})

注意,用 node promise-all.mjs 执行上面的代码,会立刻输出 false 和 3 两个值,然后停顿 5 秒等待 p3 执行完毕。

用例

同时处理多个文件

尤其是在使用服务端 JavaScript 运行时如 Node.js 时,有时候我们需要将多个文件的内容合并处理,这种情况下我们可以并行读取多个文件,等所有文件读取完毕之后再进行下一步操作:

import { readFile } from "node:fs/promises"

function readFiles(filenames) {
  return Promise.all(filenames.map((filename) => readFile(filename, "utf8")))
}

readFiles(["file1.json", "file2.json"])
  .then((contents) => {
    const data = contents.map((c) => JSON.parse(c))
    console.log(data)
  })
  .catch((reason) => console.error(reason.message))
调用多个依赖的 API

另一个常见的例子是我们需要从多个 API 获取数据并聚合结果以供使用。

const API_BASE = "https://jsonplaceholder.typicode.com"

function createError(response) {
  return new Error(
    `Unexpected: ${response.status} ${response.statusText} for ${response.url}`
  )
}

function fetchUserData(userId) {
  const urls = [
    `${API_BASE}/users/${userId}/posts`,
    `${API_BASE}/users/${userId}/albums`,
  ]

  return Promise.all(urls.map((url) => fetch(url)));
}

fetchUserData(1)
  .then((responses) => {
    return Promise.all(
      responses.map((response) => {
        if (response.ok) {
          return response.json()
        } else {
          return Promise.reject(createError(response))
        }
      })
    )
  })
  .then(([posts, albums]) => {
    console.log(posts)
    console.log("\n\n")
    console.log(albums)
  })
  .catch((reason) => console.error(reason.message))
故意增加延迟

一个不常见的例子是使用 Promise.all() 故意增加延迟,这大多发生在客户端而非服务端。假设我们有一个酷炫的 loading 控件,为了防止 API 响应太快来不及展示这个酷炫,我们可以利用 Promise.all() 故意增加一个延迟。

const API_BASE = "https://jsonplaceholder.typicode.com"
const appElement = document.getElementById("app")

function delay(milliseconds) {
  return new Promise((resolve, reject) =>
      // 此处 resolve() 不必有值
    setTimeout(() => resolve(), milliseconds)
  )
}

function fetchUserData(userId) {
  appElement.classList.add("loading")

  const urls = [
    `${API_BASE}/users/${userId}/posts`,
    `${API_BASE}/users/${userId}/albums`,
  ]

  return Promise.all([...urls.map((url) => fetch(url)), delay(1500)]).then(
    (results) => {
        // 移除 delay promise 的结果
      return results.slice(0, results.length - 1)
    }
  )
}

fetchUserData(1).then(
    // ...
).then(
    // ...
).finally(() => appElement.classList.remove("loading")).catch(
    // ...
)

Promise.allSettled() 方法

概念

顾名思义,Promise.allSettled() 等待所有的 promise “all settled”,即等待参数中的所有 promise 都执行完毕,无论成功与否。因此这个方法总是返回一个成功的 promise,值是参数的结果组成的对象数组。

这个对象数组中,成功的对象有两个属性:

失败的对象有两个属性:

const p1 = Promise.resolve(2)
const p2 = Promise.reject(3)
const p3 = new Promise((resolve, reject) => setTimeout(() => resolve(4), 100))

const p4 = Promise.allSettled([p1, p2, p3])
p4.then((results) => {
  console.log(Array.isArray(results)) // true

  console.log(results[0].status) // fulfilled
  console.log(results[0].value) // 2

  console.log(results[1].status) // rejected
  console.log(results[1].reason) // 3

  console.log(results[2].status) // fulfilled
  console.log(results[2].value) // 4
})

p4 总是一个成功的 promise,所以调用 then() 方法。

用例

Promise.allSettled() 最适合允许部分 promise 失败的情况。比如前端有一些 CSS 动画,我们希望等待所有的动画完成,但并不在乎是否有一两个失败:

function waitForAnimations(element) {
  return Promise.allSettled(
      // `getAnimations()` 方法返回一个 animation 对象的数组,每个对象都包含一个
      // `finished` 属性,值为 promise
    element.getAnimations().map((animation) => animation.finished)
  )
}

const toasterElement = document.getElementById("toaster")
waitForAnimations(toasterElement).then(() => console.log("Toaster is done"))

参考:Building a toast component

Promise.any() 方法

概念

Promise.any()Promise.all() 相反:后者是有任意一个 promise 失败就立即返回;前者是有任意一个 promise 成功就立即返回。

但如果参数中的所有 promise 都失败了,将返回一个带有 AggregateError 的 promise,参数中的 promise 的结果都保存在返回值的 errors 属性中。

const p1 = Promise.reject(2)
const p2 = new Promise((resolve, reject) => reject(3))
const p3 = new Promise((resolve, reject) => {
  setTimeout(() => reject(4), 100)
})

Promise.any([p1, p2, p3]).catch((reason) => {
  // 运行时报错
  console.error(reason.message) // All promises were rejected

  // 各个 promise 的报错返回值
  console.error(reason.errors[0]) // 2
  console.error(reason.errors[1]) // 3
  console.error(reason.errors[2]) // 4
})

用例

执行对冲请求 (hedged requests)

对冲请求即客户端同时向多个服务端发起请求,接受首先返回的响应。在客户端需要尽可能低的延迟,且有服务器资源专门用于管理额外负载和重复响应的情况下,这很有用。

const HOSTS = ["api1.example.com", "api2.example.com"]

function hedgedFetch(endpoint) {
  return Promise.any(HOSTS.map((host) => fetch(`https://${host}${endpoint}`)))
}

hedgedFetch("/transactions").then().catch()
在 Service Worker 中使用最快的响应

使用 Service Worker 的 Web 页面通常可以选择从哪里加载数据:从网络还是从缓存中。在某些情况下,网络请求可能比从缓存加载更快,因此可以使用 Promise.any() 来选择更快的响应。

self.addEventListener("fetch", (event) => {
  const cachedResponse = caches.match(event.request)
  const fetchedResponse = fetch(event.request.url)

  event.respondWith(
    Promise.any([fetchResponse.catch(() => cachedResponse), cachedResponse])
      .then((response) => response ?? fetchedResponse)
      .catch(() => {})
  )
})

Promise.race() 方法

概念

Promise.race() 接受一组 promise 作为参数,如果有任意一个 promise 执行完毕,无论成功与否,Promise.race() 都使用其结果返回;如果首先返回的 promise 是失败状态,Promise.race() 也是失败状态;如果首先返回的 promise 是成功状态,Promise.race() 也是成功状态。

let p1 = Promise.resolve(2)
let p2 = new Promise((resolve, reject) => resolve(3))
let p3 = new Promise((resolve, reject) => setTimeout(() => resolve(4), 100))

// p1 在创建时即为成功状态,不难理解它第一个返回
Promise.race([p1, p2, p3]).then(v => console.log(v)) // 2

p1 = new Promise((resolve, reject) => setTimeout(() => resolve(10), 100))
p2 = new Promise((resolve, reject) => reject(20))
p3 = new Promise((resolve, reject) => setTimeout(() => resolve(10), 50))

Promise.race([p1, p2, p3]).catch(v => console.error(v)) // 20

用例

比如 fetch() 方法的请求没有超时,它会挂起,直到以某种方式完成。我们可以使用 Promise.race()fetch() 设置超时。

function timeout(milliseconds) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error("Request timeout.")), milliseconds)
  })
}

function fetchWithTimeout(...args) {
  return Promise.race([fetch(...args), timeout(1000)])
}

const API_URL = "https://jsonplaceholder.typicode.com/users"

fetchWithTimeout(API_URL)
  .then((response) => response.json())
  .then((users) => console.log(users))
  .catch((reason) => console.error(reason.message))

总结

有一点需要注意的是:这些方法中的 promise 都会执行完毕,即使方法已经返回结果,未执行完毕的 promise 也会继续执行,但其结果大概也许真的是被丢弃了。