Promise使用过程中常见错误及避坑指南

622 阅读3分钟

JS 写的任何东西都会被执行并且会起作用,所以当我们追求高质量代码时,它应该是快速且无错误的。在使用 Promises时,我们可能很容易地犯一些错误,比如忘记输入代码的某些部分或使用不正确的东西。下面列出了开发过程中常见的 Promise 问题。

1、嵌套Promise

检查下面的代码

loadSomething().then(something => {
  loadAnotherThing().then(another => {
    doSomething(something, another)
  }).catch(e => console.error(e))
}).catch(e => console.error(e))

Promises 一个重要的作用就是为了修复“回调地狱”,上面的例子是用“回调地狱”风格编写的。要正确地重写代码,我们需要理解为什么原始代码是这样写的。在上述情况下,是需要在两个 Promise 的结果可用后做一些事情,因此进行了嵌套。可以使用Promise.all() 重写它:

Promise.all([loadSomething(), loadAnotherThing()])
  .then(([something, another]) => {
    doSomething(something, another)
  })
  .catch(e => console.error(e))

检查错误处理。当正确使用 Promises 时,只需要一个catch() 。

Promise 链还为我们提供了一个finally() 处理程序。它总是被执行,它有利于清理和一些总是被执行的最终任务,无论 Promise 被解决还是被拒绝。如下:

Promise.all([loadSomething(), loadAnotherThing()])
  .then(([something, another]) => {
    doSomething(something, another)
  })
  .catch(e => console.error(e))
  .finally(() => console.log('Promise executed'))

2、断裂的Promise链

Promise 使用方便的主要原因之一是“promise-chaining”(链式结构),一种将 Promise 的结果传递到链中并在链的末端调用catch以在一个地方捕获错误的能力。让我们看看下面的例子:

function anAsyncCall() {
  const promise = doSomethingAsync()

  promise.then(() => {
    somethingElse()
  })

  return promise
}

上面代码的问题是执行somethingElse() 方法时,若该段内发生的错误将会丢失。那是因为我们没有从somethingElse() 方法中返回 Promise。默认情况下,.then() 总是返回一个 Promise,因此,在我们的例子中,返回的 Promise 将不会被使用,并且.then() 返回一个新的 Promise。我们丢失了方法somethingElse() 的执行结果。我们可以将其重写为

function anAsyncCall() {
  return doSomethingAsync()
    .then(somethingElse)
    .catch(e => console.error(e))
}

3、在 Promise 链中混合使用同步和异步代码

这是 Promises 最常见的错误之一。人们倾向于对所有事物使用 Promises 然后将它们链接起来,即使对于异步代码也是如此。在一个地方进行错误处理可能更容易,但是为此使用 Promise 链并不是正确的方法。你只会占用内存并且给垃圾收集器带来更多的工作。检查下面的例子

const fetch = require('node-fetch') // only when running in Node.js

const getUsers = fetch('https://api.github.com/users')
const extractUsersData = users =>
  users.map(({ id, login }) => ({ id, login }))
const getRepos = users => Promise.all(
  users.map(({ login }) => fetch(`https://api.github.com/users/${login}/repos`))
)
const getFullName = repos => repos.map(repo => ({ fullName: repo.full_name }))

const getDataAndFormatIt = () => {
  return getUsers()
    .then(extractUsersData)
    .then(getRepos)
    .then(getFullName)
    .then(repos => console.log(repos))
    .catch(error => console.error(error))
}

getDataAndFormatIt()

在这个例子中,我们在 Promise 链中混合了同步和异步代码。其中两种方法是从 GitHub 获取数据,另外两种只是通过数组映射并提取一些数据,最后一种只是在控制台中记录数据(这三种方法都是同步的)。这是人们常犯的错误,尤其是当您必须获取一些数据,然后为获取的数组中的每个元素获取更多数据时,但这是错误的。通过将getDataAndFormatIt() 方法改成async/await,可以很容易的看出错误在哪里:

const getDataAndFormatIt = async () => {
  try {
    const users = await getUsers()
    const userData = await extractUsersData(users)
    const repos = await getRepos(userData)
    const fullName = await getFullName(repos)
    await logRepos(fullName)
  } catch (error) {
    console.error(error)
  }
}

我们将每个方法都视为异步(这就是 Promise 链示例中会发生的情况)。但是我们不需要每个方法都需要 Promises,只需要两个异步方法,因为其他三个是同步的。通过稍微重写代码,将最终解决内存问题:

const getDataAndFormatIt = async () => {
  try {
    const users = await getUsers()
    const userData = extractUsersData(users)
    const repos = await getRepos(userData)
    const fullName = getFullName(repos)
    logRepos(fullName)
  } catch (error) {
    console.error(error)
  }
}

只有异步的方法会作为 Promises 执行,其他的会作为同步方法执行。我们不再有内存泄漏。所以应该避免链接 同步异步 方法,这会在将来产生很多问题(尤其是当您有大量数据要处理时)。为了简便起见,请选择 async/await,它将帮助您了解 Promise 应该是什么,以及同步方法中应该保留什么。

4、丢失catch

JavaScript 不强制执行错误处理。每当程序员忘记捕获错误时,JavaScript 代码就会引发运行时异常。但是,回调语法使错误处理更加直观。每个回调函数接收两个参数,error 和result。通过编写代码,您将始终看到未使用的error 变量,并且您需要在某个时候处理它。检查下面的代码:

fs.readFile('foo.txt', (error, result) => {
  if (error) {
    return console.error(error)
  }

  console.log(result)
})

由于回调函数签名有一个error,处理它变得更加直观,并且更容易发现缺少的错误处理程序。Promises 很容易忘记捕获错误,因为它.catch() 是可选的,而.then() 我对单个成功处理程序非常满意。这将发出UnhandledPromiseRejectionWarning 在 Node.js 中,可能会导致内存或文件描述符泄漏。Promise 回调中的代码占用一些内存,在 Promise 被解析或拒绝后清理已用内存应该由垃圾收集器完成。但如果我们不正确处理被拒绝的承诺,那可能不会发生。如果我们访问一些 I/O 源或在 Promise 回调中创建变量,将创建一个文件描述符并使用内存。如果没有正确处理 Promise 拒绝,内存将不会被清理,文件描述符也不会被关闭。这样做数百次,您将导致大量内存泄漏,并且其他一些功能可能会失败。为避免进程崩溃和内存泄漏,始终以.catch().

如果您尝试运行下面的代码,它将失败并显示UnhandledPromiseRejectionWarning

const fs = require('fs').promises

fs.stat('non-existing-file.txt')
  .then(stat => console.log(stat))

如果您没有正确处理您的错误,您将泄漏一个文件描述符或进入其他一些拒绝服务的情况。这就是为什么您需要在 Promise 被拒绝时正确清理所有内容。为了正确处理它,我们应该.catch() 在这里添加一条声明:

const fs = require('fs').promises

fs.stat('non-existing-file.txt')
  .then(stat => console.log(stat))
  .catch(error => console.error(error)

用一个.catch() 语句,当错误发生时,我们应该在控制台中记录它:

{ [Error: ENOENT: no such file or directory, stat 'non-existing-file.txt']
  errno: -2,
  code: 'ENOENT',
  syscall: 'stat',
  path: 'non-existing-file.txt' }

5、忘记返回一个Promise

在下面的代码中,我们忘记在成功处理程序中返回 Promise getUserData()

getUser()
  .then(user => {
    getUserData(user)
  })
  .then(userData => {
    // userData is not defined
  })
  .catch(e => console.error(e))

结果,userData 它是未定义的。此外,此类代码可能会导致unhandledRejection错误。正确的代码应该是这样的:

getUser()
  .then(user => {
    return getUserData(user)
  })
  .then(userData => {
    // userData is defined
  })
  .catch(e => console.error(e))

6、Promise中使用同步代码

Promises 旨在帮助您管理异步代码。因此,使用 Promises 进行同步处理没有任何优势。根据 JavaScript 文档:“Promise 对象用于延迟和异步计算。Promise 表示尚未完成但预计在未来完成的操作。” 如果我们将同步操作包装在 Promise 中,会发生什么情况,如下所示

const syncPromise = new Promise((resolve, reject) => {

传递给 Promise 的函数将立即被调用,但解决方案将被安排在微任务队列中,就像任何其他异步任务一样。它只会在没有特殊原因的情况下阻塞事件循环。在上面的例子中,我们创建了一个额外的上下文,我们没有使用它。这将使我们的代码变慢并消耗额外的资源,没有任何好处。此外,由于我们的函数是 Promise,JavaScript 引擎将跳过旨在减少函数调用开销的最重要的代码优化之一——自动函数内联

7、混合Promise 和 Async/Await

const mainMethod = () => {
  return new Promise(async function (resolve, reject) {
    try {
      const data1 = await someMethod()
      const data2 = await someOtherMethod()

      someCallbackMethod(data1, data2, (err, finalData) => {
        if (err) {
          return reject(err)
        }

        resolve(finalData)
      })
    } catch (e) {
      reject(e)
    }
  })
}

这段代码又长又复杂,它使用了 Promises、Async/Await 和回调。我们在 Promise 中有一个异步函数,这是一个不好的地方,也不是一件好事。因为这是不可预期的,可能会导致许多隐藏的错误。它将分配额外的 Promise 对象,这些对象将不必要地浪费内存,并且您的垃圾收集器将花费更多时间清理它。

如果你想在 Promise 内部使用异步行为,你应该在外部方法中解决它们,或者使用 Async/Await 来链接内部的方法。我们可以这样重构代码:

const somePromiseMethod = (data1, data2) => {
  return new Promise((resolve, reject) => {
    someCallbackMethod(data1, data2, (err, finalData) => {
      if (err) {
        return reject(err)
      }

      resolve(finalData)
    })
  })
}
async function mainMethod() {
  try {
    const data1 = await someMethod()
    const data2 = await someOtherMethod()
    return somePromiseMethod(data1, data2)
  } catch (e) {
    console.error(e)
  }
}

8、在Async函数中返回Promise

async function method() {
  return new Promise((resolve, reject) => { ... })
}

这是不必要的,一个返回 Promise 的函数不需要关键字async ,相反,当函数是 async 时,你不需要return new Promise在里面写。仅当您要在函数中使用await时才使用async 关键字,并且该函数将在调用时返回一个 Promise。

function method() {
  return new Promise((resolve, reject) => { ... })
}

或者

async function method() {
  const data = await someAsyncMethod()
  return data
}

9、将回调定义为异步函数

人们经常在意想不到的地方使用异步函数,例如回调。在下面的示例中,我们使用异步函数作为来自服务器的事件的回调:

server.on('connection', async stream => {
  const user = await saveInDatabase()

  console.log(`Someone with ID ${user.id} connected`)
})

这是一个反模式。无法在 EventEmitter 回调中等待结果,结果将丢失。此外,抛出的任何错误(例如无法连接到数据库)都不会得到处理,相反,您将得到unhandledRejection,并且没有办法处理它。

使用非 Promise Node.js API

Promises 是 ECMAScript 中的一种新实现,并非所有 Node.js API 都以这种方式工作。这就是为什么我们有一个 Promisify 方法可以帮助我们生成一个函数,该函数从一个与回调一起工作的函数返回一个 Promise:

const util = require('util')

const sleep = util.promisify(setTimeout)

sleep(1000)
  .then(() => console.log('This was executed after one second'))

结论

编程语言中的每一个新特性都会让人兴奋但又急于尝试。但是没有人愿意花时间去理解功能;我们只是想使用它。这就是问题所在,Promises 也是如此。

如果您在使用 Promises 时遇到更大的问题并且需要一些分析方面的帮助,我们建议您使用Node Clinic – 一组可以帮助您诊断 Node.js 应用程序性能问题的工具。

参考链接