理解JavaScript中的事件循环、回调、Promise和异步/等待

作者选择了COVID-19救助基金作为Write for Donations计划的捐赠对象。

介绍

在互联网早期,网站通常由HTML页面中的静态数据组成。但是现在,随着Web应用程序变得更加互动和动态,执行像发起外部网络请求以检索API数据这样的密集操作变得越来越必要。为了处理这些操作,开发人员必须使用JavaScript的异步编程技术。

由于JavaScript是一种单线程编程语言,具有同步执行模型,按顺序处理一个操作,因此它一次只能处理一个语句。然而,像从API请求数据这样的操作可能需要不确定的时间,这取决于请求的数据大小、网络连接速度和其他因素。如果API调用以同步方式执行,浏览器将无法处理任何用户输入,如滚动或点击按钮,直到该操作完成。这就是所谓的阻塞。

为了防止阻塞行为,浏览器环境有许多Web API,JavaScript可以访问它们是异步的,这意味着它们可以与其他操作并行运行,而不是顺序运行。这很有用,因为它允许用户在异步操作被处理时继续正常使用浏览器。

作为JavaScript开发人员,您需要知道如何处理异步Web API的响应或错误。在本文中,您将了解事件循环,通过回调处理异步行为的原始方式,ECMAScript 2015引入的promise,以及使用async/await的现代实践。

注意:本文侧重于浏览器环境中的客户端JavaScript。相同的概念通常也适用于Node.js环境,但是Node.js使用其自己的C++ API而不是浏览器的Web API。有关Node.js中异步编程的更多信息,请参阅如何在Node.js中编写异步代码

事件循环

这部分将解释JavaScript如何使用事件循环处理异步代码。首先,它将通过演示事件循环的工作原理,然后解释事件循环的两个元素:堆栈和队列。

不使用任何异步Web API的JavaScript代码将以同步方式执行——依次执行,顺序执行。通过调用每个打印数字到控制台的三个函数来演示这一点:

// 定义三个示例函数
function first() {
  console.log(1)
}

function second() {
  console.log(2)
}

function third() {
  console.log(3)
}

在这段代码中,您定义了三个使用console.log()打印数字的函数。

接下来,调用这些函数:

// 执行这些函数
first()
second()
third()

输出将基于调用函数的顺序——first()second(),然后third()

Output
1 2 3

当使用异步Web API时,规则变得更加复杂。一个内置的API,您可以使用setTimeout进行测试,它设置一个计时器,并在指定的时间后执行操作。setTimeout需要是异步的,否则整个浏览器在等待期间将保持冻结状态,这会导致用户体验较差。

setTimeout添加到second函数中以模拟异步请求:

// 定义三个示例函数,但其中一个包含异步代码
function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

setTimeout 接受两个参数:它将异步运行的函数以及调用该函数之前等待的时间。在这段代码中,您将 console.log 包装在一个匿名函数中,并将其传递给 setTimeout,然后将该函数设置为在 0 毫秒后运行。

现在调用函数,就像之前做的那样:

// 执行这些函数
first()
second()
third()

您可能期望将 setTimeout 设置为 0,这样运行这三个函数仍然会按顺序打印数字。但是由于它是异步的,具有超时的函数将会最后打印:

Output
1 3 2

无论您将超时设置为零秒还是五分钟,都不会有任何区别——由异步代码调用的 console.log 将在同步顶级函数之后执行。这是因为 JavaScript 宿主环境,这里是浏览器,使用了称为 事件循环 的概念来处理并发或并行事件。由于 JavaScript 只能一次执行一条语句,因此它需要事件循环来告知何时执行哪个特定语句。事件循环使用 堆栈队列 的概念来处理这个问题。

堆栈

堆栈,或称为调用堆栈,保存了当前正在运行的函数的状态。如果您对堆栈的概念不熟悉,您可以将其想象成具有“后进先出”(LIFO)属性的数组,这意味着您只能在堆栈的末尾添加或删除项目。JavaScript 将在堆栈中运行当前的(或特定环境中的函数调用),然后将其移除并继续下一个。

对于仅包含同步代码的示例,浏览器按照以下顺序处理执行:

  • first() 添加到堆栈,运行 first(),将 1 记录到控制台,从堆栈中移除 first()
  • second() 添加到堆栈,运行 second(),将 2 记录到控制台,从堆栈中移除 second()
  • third() 添加到堆栈,运行 third(),将 3 记录到控制台,从堆栈中移除 third()

具有 setTimout 的第二个示例如下:

  • first() 添加到堆栈,运行 first(),将 1 记录到控制台,从堆栈中移除 first()
  • second() 添加到堆栈,运行 second()
    • setTimeout() 添加到堆栈,运行 setTimeout() Web API,启动定时器并将匿名函数添加到队列,从堆栈中移除 setTimeout()
  • 从堆栈中移除second()
  • third()添加到堆栈,运行third(),将3记录到控制台,然后从堆栈中删除third()
  • 事件循环检查队列以查找任何挂起的消息,并从setTimeout()找到匿名函数,将该函数添加到堆栈中,该函数将2记录到控制台,然后将其从堆栈中删除。

使用setTimeout,一个异步的Web API,引入了队列的概念,本教程将在接下来介绍。

队列

队列,也称为消息队列或任务队列,是函数的等待区域。每当调用堆栈为空时,事件循环将检查队列以查找任何等待的消息,从最旧的消息开始。一旦找到消息,它将被添加到堆栈中,堆栈将执行消息中的函数。

setTimeout示例中,匿名函数会立即在其余的顶级执行之后运行,因为定时器被设置为0秒。重要的是要记住,定时器并不意味着代码会在确切的0秒或指定的时间执行,而是在那段时间内将匿名函数添加到队列中。这个队列系统存在是因为如果定时器在定时器结束时直接将匿名函数添加到堆栈中,它会中断当前正在运行的任何函数,这可能会导致意想不到和不可预测的效果。

注意: 还有另一个队列叫做作业队列微任务队列,它处理promise。像promise这样的微任务比像setTimeout这样的宏任务具有更高的优先级。

现在您知道事件循环如何使用堆栈和队列来处理代码的执行顺序。下一个任务是弄清楚如何控制代码的执行顺序。为此,您首先会学习确保异步代码通过事件循环正确处理的原始方法:回调函数。

回调函数

setTimeout示例中,具有超时的函数在主顶层执行上下文中的所有内容之后运行。但是,如果您想要确保其中一个函数(例如third函数)在超时之后运行,那么您必须使用异步编码方法。这里的超时可以表示包含数据的异步 API 调用。您希望使用来自 API 调用的数据,但必须确保数据先返回。

处理这个问题的原始解决方案是使用回调函数。回调函数没有特殊的语法;它们只是作为参数传递给另一个函数的函数。将另一个函数作为参数的函数称为高阶函数。根据这个定义,如果将函数作为参数传递,任何函数都可以成为回调函数。回调函数本质上不是异步的,但可以用于异步目的。

以下是高阶函数和回调的语法代码示例:

// 一个函数
function fn() {
  console.log('Just a function')
}

// 一个接受另一个函数作为参数的函数
function higherOrderFunction(callback) {
  // 当您调用作为参数传递的函数时,它被称为回调
  callback()
}

// 传递一个函数
higherOrderFunction(fn)

在这段代码中,您定义了一个名为fn的函数,定义了一个名为higherOrderFunction的函数,该函数接受一个名为callback的函数作为参数,并将fn作为回调传递给higherOrderFunction

运行此代码将会得到以下结果:

Output
Just a function

让我们回到第一个第二个第三个函数,并使用setTimeout。到目前为止,你所拥有的是:

function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

任务是要让第三个函数总是在第二个函数中的异步操作完成后延迟执行。这就是回调函数的用处。你将不再在执行的顶层执行第一个第二个第三个,而是将第三个函数作为参数传递给第二个第二个函数将在异步操作完成后执行回调函数。

下面是应用回调的三个函数:

// 定义三个函数
function first() {
  console.log(1)
}

function second(callback) {
  setTimeout(() => {
    console.log(2)

    // 执行回调函数
    callback()
  }, 0)
}

function third() {
  console.log(3)
}

现在,执行第一个第二个,然后将第三个作为参数传递给第二个

first()
second(third)

运行此代码块后,你将得到以下输出:

Output
1 2 3

首先会打印1,在计时器完成后(在本例中为零秒,但你可以将其更改为任意时间),会打印2然后3。通过将函数作为回调函数传递,你成功地延迟了函数的执行,直到异步 Web API (setTimeout) 完成。

重点是回调函数并不是异步的,setTimeout是负责处理异步任务的异步Web API。回调函数只是允许你在异步任务完成时获得通知,并处理任务的成功或失败。

现在你已经学会了如何使用回调函数来处理异步任务,下一节将解释嵌套太多回调函数和创建“金字塔般的地狱”的问题。

嵌套回调函数和金字塔般的地狱

回调函数是确保一个函数延迟执行直到另一个函数完成并返回数据的有效方法。然而,由于回调函数的嵌套性质,如果你有很多相互依赖的连续异步请求,代码可能会变得混乱。这是JavaScript开发人员早期的一个大问题,因此包含嵌套回调的代码通常被称为“金字塔般的地狱”或“回调地狱”。

以下是嵌套回调的演示:

function pyramidOfDoom() {
  setTimeout(() => {
    console.log(1)
    setTimeout(() => {
      console.log(2)
      setTimeout(() => {
        console.log(3)
      }, 500)
    }, 2000)
  }, 1000)
}

在这段代码中,每个新的setTimeout都嵌套在一个高阶函数中,创建了一个越来越深的回调的金字塔形状。运行此代码会得到以下结果:

Output
1 2 3

在实践中,使用真实世界的异步代码,情况可能会变得更加复杂。您很可能需要在异步代码中进行错误处理,然后将一些数据从每个响应传递到下一个请求。使用回调来做这件事会使您的代码难以阅读和维护。

以下是一个更加现实的“回调地狱”的可运行示例,您可以尝试运行一下:

// 示例异步函数
function asynchronousRequest(args, callback) {
  // 如果未传递任何参数,则抛出错误
  if (!args) {
    return callback(new Error('Whoa! Something went wrong.'))
  } else {
    return setTimeout(
      // 只是随机添加一个数字,以使得这个人为的异步函数
      // 返回不同的数据
      () => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
      500,
    )
  }
}

// 嵌套的异步请求
function callbackHell() {
  asynchronousRequest('First', function first(error, response) {
    if (error) {
      console.log(error)
      return
    }
    console.log(response.body)
    asynchronousRequest('Second', function second(error, response) {
      if (error) {
        console.log(error)
        return
      }
      console.log(response.body)
      asynchronousRequest(null, function third(error, response) {
        if (error) {
          console.log(error)
          return
        }
        console.log(response.body)
      })
    })
  })
}

// 执行
callbackHell()

在这段代码中,您必须使每个函数考虑到可能的response和可能的error,使函数callbackHell在视觉上令人困惑。

运行此代码将会得到以下结果:

Output
First 9 Second 3 Error: Whoa! Something went wrong. at asynchronousRequest (<anonymous>:4:21) at second (<anonymous>:29:7) at <anonymous>:9:13

处理异步代码的这种方式很难理解。因此,ES6 引入了Promise的概念。这是下一节的重点。

Promise

A promise represents the completion of an asynchronous function. It is an object that might return a value in the future. It accomplishes the same basic goal as a callback function, but with many additional features and a more readable syntax. As a JavaScript developer, you will likely spend more time consuming promises than creating them, as it is usually asynchronous Web APIs that return a promise for the developer to consume. This tutorial will show you how to do both.

创建一个Promise

您可以使用new Promise语法初始化一个promise,您必须用一个函数来初始化它。传递给promise的函数具有resolvereject参数。resolvereject函数分别处理操作的成功和失败。

编写以下代码以声明一个promise:

// 初始化一个promise
const promise = new Promise((resolve, reject) => {})

如果您使用浏览器的控制台检查初始化的promise,您会发现它处于pending状态并且值为undefined

Output
__proto__: Promise [[PromiseStatus]]: "pending" [[PromiseValue]]: undefined

到目前为止,还没有为promise设置任何内容,因此它将永远处于pending状态。您可以做的第一件事是通过使用一个值来解决它来实现promise:

const promise = new Promise((resolve, reject) => {
  resolve('We did it!')
})

现在,检查promise,您会发现它的状态为fulfilled,并且value设置为您传递给resolve的值:

Output
__proto__: Promise [[PromiseStatus]]: "fulfilled" [[PromiseValue]]: "We did it!"

正如本节开头所述,promise是一个可能返回值的对象。在成功实现之后,valueundefined变为具有数据的值。

A promise can have three possible states: pending, fulfilled, and rejected.

  • 等待中 – 在被解决或被拒绝之前的初始状态
  • 已实现 – 操作成功,承诺已解决
  • 已拒绝 – 操作失败,承诺已拒绝

在实现或拒绝后,承诺就会被解决。

现在你已经了解了如何创建承诺,让我们看看开发人员如何消耗这些承诺。

消耗承诺

上一节的承诺已经实现了一个值,但你也想能够访问这个值。承诺有一个叫做 then 的方法,在代码中当一个承诺达到 resolve 时会运行。 then 会将承诺的值作为参数返回。

这就是你如何返回并记录示例承诺的 value

promise.then((response) => {
  console.log(response)
})

你创建的承诺有一个 [[PromiseValue]]我们做到了!。这个值会作为 response 传递给匿名函数:

Output
We did it!

到目前为止,你创建的示例没有涉及到异步 Web API——它只是解释了如何创建、解决和消耗一个原生 JavaScript 承诺。使用 setTimeout,你可以测试异步请求。

以下代码模拟了作为承诺返回的异步请求的数据:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('Resolving an asynchronous request!'), 2000)
})

// 记录结果
promise.then((response) => {
  console.log(response)
})

使用then语法确保response只有在setTimeout操作完成后的2000毫秒后才会被记录。所有这些都可以在不嵌套回调的情况下完成。

现在,两秒后,它将解决承诺值,并且它将在then中被记录:

Output
Resolving an asynchronous request!

承诺还可以链接以将数据传递给多个异步操作。如果在then中返回了一个值,那么可以添加另一个then,它将以前一个then的返回值来实现:

// 链接一个承诺
promise
  .then((firstResponse) => {
    // 为下一个then返回一个新值
    return firstResponse + ' And chaining!'
  })
  .then((secondResponse) => {
    console.log(secondResponse)
  })

第二个then中的已完成响应将记录返回值:

Output
Resolving an asynchronous request! And chaining!

由于then可以被链接,它使得承诺的消耗看起来比回调更同步,因为它们不需要嵌套。这将使得代码更具可读性,更易于维护和验证。

错误处理

到目前为止,您只处理了具有成功resolve的promise,这将promise置于fulfilled状态。但是,在处理异步请求时,通常还需要处理错误 – 如果API关闭,或者发送了格式错误或未经授权的请求。一个promise应该能够处理这两种情况。在本节中,您将创建一个函数来测试创建和消费promise的成功和错误情况。

这个getUsers函数将向promise传递一个标志,并返回promise:

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 处理异步API中的resolve和reject
    }, 1000)
  })
}

设置代码,使得如果onSuccesstrue,超时将会使用一些数据来实现。如果是false,函数将以错误的形式拒绝:

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 处理异步API中的resolve和reject
      if (onSuccess) {
        resolve([
          {id: 1, name: 'Jerry'},
          {id: 2, name: 'Elaine'},
          {id: 3, name: 'George'},
        ])
      } else {
        reject('Failed to fetch data!')
      }
    }, 1000)
  })
}

对于成功的结果,您返回代表示例用户数据的JavaScript对象

为了处理错误,您将使用catch实例方法。这将为您提供一个带有error参数的失败回调。

使用then方法处理成功情况,使用catch方法处理错误情况,运行getUser命令,并将onSuccess设置为false

// 使用false标志运行getUsers函数以触发错误
getUsers(false)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })

由于触发了错误,then将被跳过,而catch将处理错误:

Output
Failed to fetch data!

如果您切换标志并改用resolvecatch将被忽略,数据将返回:

// 使用 true 标志运行 getUsers 函数以成功解析
getUsers(true)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })

这将产生用户数据:

Output
(3) [{…}, {…}, {…}] 0: {id: 1, name: "Jerry"} 1: {id: 2, name: "Elaine"} 3: {id: 3, name: "George"}

供参考,下面是Promise对象上的处理程序方法表:

Method Description
then() Handles a resolve. Returns a promise, and calls onFulfilled function asynchronously
catch() Handles a reject. Returns a promise, and calls onRejected function asynchronously
finally() Called when a promise is settled. Returns a promise, and calls onFinally function asynchronously

对于新手开发人员和以前从未在异步环境中工作过的经验丰富的程序员来说,Promises可能会令人困惑。但是正如前面提到的,消耗promises比创建它们更常见。通常,浏览器的Web API或第三方库将提供promise,您只需要使用它。

在最后的promise部分,本教程将引用一个常见的Web API返回promises的用例:Fetch API

使用Fetch API与Promises

最有用且经常使用的 Web API 之一是 Fetch API,它返回一个 Promise,允许您在网络上进行异步资源请求。`fetch` 是一个两部分的过程,因此需要链接 `then`。以下示例演示了如何使用 GitHub API 获取用户数据,并处理可能的错误:

// 从 GitHub API 获取用户
fetch('https://api.github.com/users/octocat')
  .then((response) => {
    return response.json()
  })
  .then((data) => {
    console.log(data)
  })
  .catch((error) => {
    console.error(error)
  })

`fetch` 请求发送到 `https://api.github.com/users/octocat` URL,异步等待响应。第一个 `then` 将响应传递给匿名函数,该函数将响应格式化为 JSON 数据,然后将 JSON 传递给第二个 `then`,将数据记录到控制台。`catch` 语句记录任何错误到控制台。

运行此代码将产生以下结果:

Output
login: "octocat", id: 583231, avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4" blog: "https://github.blog" company: "@github" followers: 3203 ...

这是从 `https://api.github.com/users/octocat` 请求的数据,以 JSON 格式呈现。

本教程的这一部分展示了 promise 在处理异步代码方面的许多改进。但是,虽然使用 `then` 处理异步操作比回调金字塔更容易理解,但一些开发人员仍然更喜欢同步格式编写异步代码。为了满足这一需求,ECMAScript 2016(ES7)引入了 `async` 函数和 `await` 关键字,以使处理 promise 更加容易。ECMAScript 2016(ES7)

使用 async/await 的异步函数

使用 async 函数可以以类似同步的方式处理异步代码。async 函数在内部仍然使用 promises,但具有更传统的 JavaScript 语法。在本节中,您将尝试使用此语法的示例。

您可以通过在函数之前添加 async 关键字来创建一个 async 函数:

// 创建一个 async 函数
async function getUser() {
  return {}
}

虽然此函数尚未处理任何异步操作,但其行为与传统函数不同。如果执行该函数,您会发现它返回一个带有 [[PromiseStatus]][[PromiseValue]] 的 promise,而不是返回值。

尝试通过记录对 getUser 函数的调用来测试这一点:

console.log(getUser())

这将产生以下结果:

Output
__proto__: Promise [[PromiseStatus]]: "fulfilled" [[PromiseValue]]: Object

这意味着您可以使用 then 处理 async 函数,方式与处理 promise 相同。尝试使用以下代码:

getUser().then((response) => console.log(response))

此对 getUser 的调用将返回值传递给匿名函数,该函数将值记录到控制台。

运行此程序时,您将收到以下结果:

Output
{}

async 函数可以使用 await 运算符处理其中调用的 Promise。 await 可以在 async 函数内部使用,并且会在承诺完成之前等待执行指定的代码。

有了这个知识,您可以使用 async/await 重写上一节中的 Fetch 请求如下:

// 使用 async/await 处理 fetch
async function getUser() {
  const response = await fetch('https://api.github.com/users/octocat')
  const data = await response.json()

  console.log(data)
}

// 执行 async 函数
getUser()

这里的 await 运算符确保在请求填充数据之前不会记录 data

现在最终的 data 可以在 getUser 函数内处理,而无需使用 then。 这是记录 data 的输出:

Output
login: "octocat", id: 583231, avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4" blog: "https://github.blog" company: "@github" followers: 3203 ...

注意: 在许多环境中,使用 async 是必要的来使用 await —— 然而,一些新版本的浏览器和 Node 允许使用顶层 await,这允许您绕过创建包装 await 的异步函数。

最后,由于您在异步函数中处理了已完成的 Promise,您还可以在函数内处理错误。 而不是使用 thencatch 方法,您将使用 try/catch 模式来处理异常。

添加以下突出显示的代码:

// 使用 async/await 处理成功和错误
async function getUser() {
  try {
    // 在 try 中处理成功
    const response = await fetch('https://api.github.com/users/octocat')
    const data = await response.json()

    console.log(data)
  } catch (error) {
    // 在 catch 中处理错误
    console.error(error)
  }
}

如果程序收到错误,则现在将跳转到 catch 块并将该错误记录到控制台。

现代异步 JavaScript 代码通常使用 async/await 语法处理,但了解 Promise 的工作原理至关重要,特别是因为 Promise 能够实现一些 async/await 无法处理的附加功能,比如将 promises 与 Promise.all() 结合使用。

注意:使用 生成器结合 promises 可以为代码增加更多灵活性,从而复制 async/await。欲了解更多信息,请参阅我们的 JavaScript 中理解生成器 教程。

结论

由于 Web API 经常以异步方式提供数据,学习如何处理异步操作的结果是成为 JavaScript 开发人员的重要部分。在本文中,您了解了宿主环境如何使用事件循环来处理代码的执行顺序,包括 堆栈队列。您还尝试了三种处理异步事件成功或失败的方式,包括回调、Promise 和 async/await 语法的示例。最后,您使用了 Fetch Web API 来处理异步操作。

要了解有关浏览器如何处理并行事件的更多信息,请阅读 Mozilla 开发者网络上的 并发模型和事件循环。如果您想要了解更多关于 JavaScript 的知识,请返回我们的 JavaScript 编程指南 系列。

Source:
https://www.digitalocean.com/community/tutorials/understanding-the-event-loop-callbacks-promises-and-async-await-in-javascript