译: JavaScript中的并发Promise

译: JavaScript中的并发Promise

文章翻译至: Effective Promise Concurrency in JavaScript

目录

async function getPageData() {
  const user = await fetchUser()
  const product = await fetchProduct()
}

这段代码是否可以继续优化?使其在原来一半的时间内完成?

在此函数中,我们依次等待user的获取和product的获取。

但其中一个并不依赖于另一个,因此我们不必等待其中一个完成后才发出下一个请求。

相反,我们可以同时触发这两个请求,并同时等待这两个请求。

使用 Promise.all

Promise.all() 静态方法接受一个 Promise 可迭代对象作为输入,并返回一个 Promise。当所有输入的 Promise 都被fulfills时,返回的 Promise 也将被fulfills(即使传入的是一个空的可迭代对象),并返回一个包含所有fulfills值的数组。如果输入的任何 Promise 被rejects,则返回的 Promise 将被rejects,并带有第一个被rejects的原因。

一种方法是利用 Promise.all ,如下所示:

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser(), fetchProduct()
  ])
}

现在,如果我们想象,每个请求都需要 1 秒来完成,而在我们原来的函数中,我们将连续等待两个请求总共 2 秒才能完成我们的函数。在这个新函数中,我们**同时开启了两个请求*,因此我们的函数在 1 秒内完成 —— 所需时间相比之前缩短了一半!

但是……还有一个问题

问题

首先,我们在这里根本没有处理Error。

这时你可能说: “sure,我会把它放在一个大的 try-catch 块中”。

async function getPageData() {
  try {
    const [user, product] = await Promise.all([
      fetchUser(), fetchProduct()
    ])
  } catch (err) {
    // 🚩 this has a big problem...
  }
}

但这实际上有一个重大问题。

假设 fetchUser函数 首先完成但出现了错误并被抛出。这将会触发try catch,然后该函数继续执行。

这里有一个关键问题: 如果 fetchProduct函数也有一个错误,这个错误就不会再触发 catch了。因为函数已经继续执行到catch内,之后getPageData将执行完成。

完整Demo:

async function getPageData() {
  try {
    const [user, product] = await Promise.all([
      fetchUser(), fetchProduct()
    ])

    console.log(user, product); // This code will not be executed. because catch...
  } catch (err) {
    console.log('err is', err); // err is fetchUser Error
  }
}

const fetchUser = (e) => {
  return new Promise((reject) => {
    throw new Error('fetchUser Error')
  })
};

const fetchProduct = (e) => {
  return new Promise((reject) => {
    throw new Error('fetchProduct Error')
  });
};

这将导致一个promise reject未被处理。

如果我们有某种逻辑,它提示用户或者保存错误日志,如下所示:

// ...
} catch (err) {
  handle(err)
}
// ...

function handle(err) {
  alertToUser(err)
  saveToLoggingService(err)
}

我们只会获取到第一个错误。第二个错误将丢失,没有用户反馈,也不会在我们的错误日志中出现。

使用 .catch() 解决

解决上述问题的一种方法是将函数传递给 .catch() ,例如如下所示:

function onReject(err) {
  handle(err)
  return err
}

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser().catch(onReject), // ⬅️
    fetchProduct().catch(onReject) // ⬅️
  ])

  if (user instanceof Error) {
    handle(user) // ✅
  }
  if (product instanceof Error) {
    handle(product) // ✅
  }
}

在这种情况下,如果我们收到error,我们将返回error handle并处理error。如果我们得到的 userproduct 是 Error对象 ,我们可以用 instanceof 检查属于哪个函数。

完整测试Demo:

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser().catch(onReject),
    fetchProduct().catch(onReject)
  ])

  if (user instanceof Error) {
    console.log('user error:', user);
  }
  if (product instanceof Error) {
    console.log('product error:', product); // 打印: product error: Error: fetchProduct Error
  }

  console.log(user); // ✅
}

const fetchUser = (e) => {
  return new Promise((resolve) => {
    resolve('✅')
  })
};

const fetchProduct = (e) => {
  return new Promise(() => {
    throw new Error('fetchProduct Error')
  });
};

const onReject = (err) => {
  // 上报错误
  toReportError(err);

  return err
};

const toReportError = (e) => { };

getPageData();

但是,这里的主要缺点是我们需要在整个代码中始终提供 .catch(onReject) 。这在实际开发中很容易忘记。

实例化后单独 await

附带说明一下,记住我们并不总是需要在创建 Promise 后立即await它,我们可以延后Promise的await。

async function getPageData() {
  // Fire both requests together
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Await together
  const user = await userPromise
  const product = await productPromise

  // Handle individually
  if (user instanceof Error) {
    handle(user)
  }
  if (product instanceof Error) {
    handle(product)
  }
}

完整Demo:

async function getPageData() {
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  const user = await userPromise
  const product = await productPromise

  if (user instanceof Error) {
    console.log('user error:', user);
  }
  if (product instanceof Error) {
    console.log('product error:', product); // 打印: product error: Error: fetchProduct Error
  }

  console.log(user); // 打印: ✅
}

const fetchUser = (e) => {
  return new Promise((resolve) => {
    resolve('✅')
  })
};
const fetchProduct = (e) => {
  return new Promise(() => {
    throw new Error('fetchProduct Error')
  });
};

const onReject = (err) => {
  toReportError(err);

  return err
};

const toReportError = (e) => { };

getPageData();

此外,在这种方式中,如果我们想,我们可以更安全地使用 try/catch 而不会出现之前遇到的问题:

async function getPageData() {
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Try/catch each
  try {
    const user = await userPromise
  } catch (err) {
    handle(err)
  }
  try {
    const product = await productPromise
  } catch (err) {
    handle(err)
  }
}

在这三个版本中,我个人喜欢 Promise.all 版本,因为“一起await这两个函数”感觉更常用。但话虽如此,我认为这取决于个人喜好。

Promise.allSettled

Promise.allSettled() 静态方法将一个 Promise 可迭代对象作为输e。当所有输入的 Promise 都已fulfills或reject时(包入,并返回一个单独的 Promis括传入空的可迭代对象时),返回的 Promise 将被fulfills,并带有描述每个 Promise 结果的对象数组。

使用 ·Promise.allSettled`,我们不是直接返回 user 和 product,而是得到一个包含每个 Promise 结果的值或错误的结果对象。

async function getPageData() {
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])
}

结果对象有 3 个属性:

  • status - 两个值,"fulfilled" 或 "rejected"。
    • value - 仅当 status 为 "fulfilled" 时才出现。promise的值。
    • reason - 仅当 status 为 "rejected" 时才出现。promise被拒绝的原因。

因此,我们现在可以读取每个promise的状态,并单独处理每个错误,而不会丢失任何关键信息:

async function getPageData() {
  // Fire and await together
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  // Process user
  if (userResult.status === 'rejected') {
    const err = userResult.reason
    handle(err)
  } else {
    const user = userResult.value
  }

  // Process product
  if (productResult.status === 'rejected') {
    const err = productResult.reason
    handle(err)
  } else {
    const product = productResult.value
  }
}

在抽象一下:

async function getPageData() {
  const results = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  // Nicer on the eyes
  const [user, product] = handleResults(results)
}

我们可以实现一个简单的 handleResults 函数,如下所示:

// Generic function to throw if any errors occured, or return the responses
// if no errors happened
function handleResults(results) {
  const errors = results
    .filter(result => result.status === 'rejected')
    .map(result => result.reason)

  if (errors.length) {
    // Aggregate all errors into one
    throw new AggregateError(errors)
  }

  return results.map(result => result.value)
}

我们可以在这里使用一个巧妙的技巧,即 AggergateError 类,来抛出一个可能包含多个内部的错误。这样,当捕获时,我们会通过 AggregateError上的 .errors 属性获得包含所有详细信息的单个错误,其中包含所有错误:

async function getPageData() {
  const results = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  try {
    const [user, product] = handleResults(results)
  } catch (err) {
    for (const error of err.errors) {
      handle(error)
    }
  }
}

Promise.race

Promise.race() 静态方法接受一个 promise 可迭代对象作为输入,并返回一个 Promise。这个返回的 promise 状态随第一个 promise 的状态改变而改变。

例如,我们可以实现一个简单的超时,如下所示:

// Race to see which Promise completes first
const racePromise = Promise.race([
  doSomethignSlow(),
  new Promise((resolve, reject) =>
    // Time out after 5 seconds
    setTimeout(() => reject(new Error('Timeout')), 5000)
  )
])

try {
  const result = await racePromise
} catch (err) {
  // Timed out!
}

注意:这并不总是理想的做法,因为一般来说,如果出现超时,应尽可能取消未完成的待办任务。

例如,如果 doSomethingSlow()获取了数据,我们通常希望使用 AbortController 在超时时中止获取数据,而不是仅仅reject promise。

但这只是一个演示基本概念的示例

另外,像往常一样,最好也处理所有承诺的所有错误:

const racePromise = Promise.race([
  doSomethignSlow().catch(onReject), // ✅

  // ...
])

Promise.any

Promise.any() 静态方法将一个 Promise 可迭代对象作为输入,并返回一个 Promise。当输入的任何一个 Promise fulfills时,这个返回的 Promise 将会fulfills,并返回第一个fulfills的值。如果所有输入的 Promise 都被reject(包括传递了空的可迭代对象)时,它会以一个包含reject原因的数组。

例如,在无法预测某个数据哪个获取更快的情况下,这可能很有用:

const anyPromise = Promise.any([
  getSomethingFromPlaceA(), getSomethingFromPlaceB()
])

try {
  const winner = await anyPromise
} catch (err) {
  // Darn, both failed
}

与上面类似,这里的理想解决方案是一旦较快的请求完成,就中止较慢的请求。但同样,这些只是为了演示基础知识而设计的简单示例。

处理未经处理的reject:

const anyPromise = Promise.any([
  getSomethingFromPlaceA().catch(onReject), // ✅
  getSomethingFromPlaceB().catch(onReject) // ✅
])

三点注意事项...

  • Don’t get carried way

并发很好,但过度的并行会导致网络、磁盘性能或其他问题。避免这样的疯狂:

// ❌ please don't
const results = await Promise.allSettled([
  fetchUser(),
  fetchProduct(),
  getAnotherThing(),
  andAnotherThing(),
  andYetAnotherThing(),
  andMoreThings(),
  areYouEvenStillReading(),
  thisIsExcessive(),
  justPleaseDont()
])
  • 我们是在同时等待,而不是同时执行

为了避免混淆,我想指出的是,重要的是要意识到,当我们在这里谈论并发时,我们指的并发是等待promise,而不是并发执行代码。

JavaScript 一直以来都是一种单线程语言,所以我们不要忘记这一点。

  • 不要过早优化

有时,顺序代码更容易推理和管理。

// It sure is simple and easy to read, isn't it
async function getPageData() {
  const user = await fetchUser()
  const product = await fetchProduct()
}