了解JavaScript中的事件循环、回调、承诺和异步/等待

来自菜鸟教程
跳转至:导航、​搜索

作者选择了 COVID-19 Relief Fund 作为 Write for DOnations 计划的一部分来接受捐赠。

介绍

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

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

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

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

注意: 本文重点介绍浏览器环境中的客户端 JavaScript。 相同的概念在 Node.js 环境中通常是正确的,但是 Node.js 使用自己的 C++ APIs,而不是浏览器的 Web APIs。 有关 Node.js 中异步编程的更多信息,请查看 如何在 Node.js 中编写异步代码


事件循环

本节将解释 JavaScript 如何使用事件循环处理异步代码。 它将首先运行一个工作中的事件循环的演示,然后解释事件循环的两个元素:堆栈和队列。

不使用任何异步 Web API 的 JavaScript 代码将以同步方式执行——一次一个,顺序执行。 这个示例代码证明了这一点,它调用了三个函数,每个函数都将一个数字打印到 控制台

// Define three example functions
function first() {
  console.log(1)
}

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

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

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

接下来,编写对函数的调用:

// Execute the functions
first()
second()
third()

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

Output1
2
3

当使用异步 Web API 时,规则变得更加复杂。 您可以使用的内置 API 是 setTimeout,它设置一个计时器并在指定的时间后执行一个动作。 setTimeout 需要异步,否则整个浏览器在等待的过程中会一直冻结,导致用户体验不佳。

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

// Define three example functions, but one of them contains asynchronous code
function first() {
  console.log(1)
}

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

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

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

现在像以前一样调用函数:

// Execute the functions
first()
second()
third()

您可能期望将 setTimeout 设置为 0 运行这三个函数仍会导致按顺序打印数字。 但是因为它是异步的,所以最后会打印带有超时的函数:

Output1
3
2

将超时设置为 0 秒还是 5 分钟都没有区别——异步代码调用的 console.log 将在同步顶级函数之后执行。 发生这种情况是因为 JavaScript 宿主环境(在本例中为浏览器)使用称为 事件循环 的概念来处理并发或并行事件。 由于 JavaScript 一次只能执行一条语句,它需要通知事件循环何时执行哪条特定语句。 事件循环使用 stackqueue 的概念来处理这个问题。

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

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

  • 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 记录到控制台,然后将其从堆栈中删除。

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

队列

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

setTimeout 示例中,匿名函数在顶层执行的其余部分之后立即运行,因为计时器设置为 0 秒。 重要的是要记住,计时器并不意味着代码将在 0 秒或任何指定的时间执行,但它会在该时间量内将匿名函数添加到队列中。 这个队列系统之所以存在,是因为如果定时器在定时器结束时将匿名函数直接添加到堆栈中,它将中断当前正在运行的任何函数,这可能会产生意想不到的和不可预测的影响。

注意: 还有另一个队列叫做 job queuemicrotask queue 处理 promises。 像 Promise 这样的微任务的处理优先级高于像 setTimeout 这样的宏任务。


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

回调函数

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

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

这是一个高阶函数和回调的语法代码示例:

// A function
function fn() {
  console.log('Just a function')
}

// A function that takes another function as an argument
function higherOrderFunction(callback) {
  // When you call a function that is passed as an argument, it is referred to as a callback
  callback()
}

// Passing a function
higherOrderFunction(fn)

在此代码中,您定义了一个函数 fn,定义了一个将函数 callback 作为参数的函数 higherOrderFunction,并将 fn 作为回调传递给higherOrderFunction

运行此代码将给出以下信息:

OutputJust a function

让我们回到带有 setTimeoutfirstsecondthird 函数。 这是你到目前为止所拥有的:

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

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

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

任务是让 third 函数始终延迟执行,直到 second 函数中的异步动作完成之后。 这就是回调的用武之地。 将 third 函数作为参数传递给 secondsecond 函数将在异步操作完成后执行回调。

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

// Define three functions
function first() {
  console.log(1)
}

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

    // Execute the callback function
    callback()
  }, 0)
}

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

现在,执行 firstsecond,然后将 third 作为参数传递给 second

first()
second(third)

运行此代码块后,您将收到以下输出:

Output1
2
3

首先将打印 1,然后在计时器完成后(在本例中为 0 秒,但您可以将其更改为任意数量),它将打印 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 都嵌套在一个高阶函数中,创建了一个越来越深的回调金字塔形状。 运行此代码将给出以下结果:

Output1
2
3

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

这是一个更现实的“末日金字塔”的可运行示例,您可以使用它:

// Example asynchronous function
function asynchronousRequest(args, callback) {
  // Throw an error if no arguments are passed
  if (!args) {
    return callback(new Error('Whoa! Something went wrong.'))
  } else {
    return setTimeout(
      // Just adding in a random number so it seems like the contrived asynchronous function
      // returned different data
      () => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
      500,
    )
  }
}

// Nested asynchronous requests
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)
      })
    })
  })
}

// Execute 
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 中引入了 promises 的概念。 这是下一节的重点。

承诺

promise 表示异步函数的完成。 它是一个将来可能返回值的对象。 它完成了与回调函数相同的基本目标,但具有许多附加功能和更易读的语法。 作为一名 JavaScript 开发人员,您可能会花费比创建它们更多的时间来消耗 Promise,因为通常是异步 Web API 会返回一个 Promise 供开发人员使用。 本教程将向您展示如何做到这两点。

创建一个承诺

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

编写以下行来声明一个承诺:

// Initialize a promise
const promise = new Promise((resolve, reject) => {})

如果您使用 Web 浏览器的控制台检查处于此状态的已初始化 Promise,您会发现它具有 pending 状态和 undefined 值:

Output__proto__: Promise
[[PromiseStatus]]: "pending"
[[PromiseValue]]: undefined

到目前为止,还没有为 promise 设置任何内容,因此它将永远处于 pending 状态。 测试一个 Promise 的第一件事是通过用一个值解决它来实现这个 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 变为填充数据。

一个 Promise 可以有三种可能的状态:未决、已完成和已拒绝。

  • Pending - 被解决或拒绝之前的初始状态
  • Fulfilled - 运行成功,promise已经解决
  • Rejected - 操作失败,promise 被拒绝

在被履行或被拒绝后,一个承诺被解决。

现在您已经了解了如何创建 Promise,让我们看看开发人员如何使用这些 Promise。

消费承诺

上一节中的承诺已经实现了一个值,但您还希望能够访问该值。 Promise 有一个称为 then 的方法,该方法将在 Promise 到达代码中的 resolve 后运行。 then 将返回 promise 的值作为参数。

这是您返回并记录示例承诺的 value 的方式:

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

你创建的 Promise 的 PromiseValueWe did it!。 该值将作为 response 传递给匿名函数:

OutputWe did it!

到目前为止,您创建的示例并未涉及异步 Web API——它仅解释了如何创建、解析和使用本机 JavaScript 承诺。 使用 setTimeout,您可以测试异步请求。

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

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

// Log the result
promise.then((response) => {
  console.log(response)
})

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

现在两秒钟后,它将解析承诺值并登录到 then

OutputResolving an asynchronous request!

Promise 也可以链接起来,将数据传递给多个异步操作。 如果在 then 中返回一个值,则可以添加另一个 then 来满足前一个 then 的返回值:

// Chain a promise
promise
  .then((firstResponse) => {
    // Return a new value for the next then
    return firstResponse + ' And chaining!'
  })
  .then((secondResponse) => {
    console.log(secondResponse)
  })

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

OutputResolving an asynchronous request! And chaining!

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

错误处理

到目前为止,您只处理了一个成功的 resolve 承诺,它将承诺置于 fulfilled 状态。 但是,对于异步请求,您还必须经常处理错误——如果 API 已关闭,或者发送了格式错误或未经授权的请求。 一个承诺应该能够处理这两种情况。 在本节中,您将创建一个函数来测试创建和使用 Promise 的成功和错误情况。

这个 getUsers 函数将一个标志传递给一个承诺,并返回承诺:

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Handle resolve and reject in the asynchronous API
    }, 1000)
  })
}

设置代码,如果 onSuccesstrue,超时将满足一些数据。 如果 false,该函数将拒绝并出现错误:

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Handle resolve and reject in the asynchronous API
      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 作为参数的失败回调。

运行 getUser 命令,将 onSuccess 设置为 false,对成功案例使用 then 方法,对错误使用 catch 方法:

// Run the getUsers function with the false flag to trigger an error
getUsers(false)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })

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

OutputFailed to fetch data!

如果您切换标志和 resolve 代替,catch 将被忽略,数据将改为返回:

// Run the getUsers function with the true flag to resolve successfully
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 对象的处理程序方法:

方法 描述
then() 处理 resolve。 返回一个 promise,并异步调用 onFulfilled 函数
catch() 处理 reject。 返回一个 promise,并异步调用 onRejected 函数
finally() 当 promise 完成时调用。 返回一个 promise,并异步调用 onFinally 函数

对于以前从未在异步环境中工作过的新开发人员和经验丰富的程序员来说,Promise 可能会令人困惑。 然而,如前所述,使用 Promise 比创建 Promise 更常见。 通常,浏览器的 Web API 或第三方库将提供 Promise,您只需使用它即可。

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

将 Fetch API 与 Promises 一起使用

Fetch API 是返回承诺的最有用和最常用的 Web API 之一,它允许您通过网络进行异步资源请求。 fetch 是一个由两部分组成的过程,因此需要链接 then。 此示例演示了使用 GitHub API 获取用户数据,同时还处理任何潜在错误:

// Fetch a user from the 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 传递给将数据记录到控制台的第二个 thencatch 语句将任何错误记录到控制台。

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

Outputlogin: "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 更容易。

async/await 的异步函数

async 函数 允许您以看起来同步的方式处理异步代码。 async 函数在底层仍然使用 Promise,但具有更传统的 JavaScript 语法。 在本节中,您将尝试这种语法的示例。

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

// Create an async function
async function getUser() {
  return {}
}

尽管此函数尚未处理任何异步操作,但它的行为与传统函数不同。 如果你执行这个函数,你会发现它返回一个带有 PromiseStatusPromiseValue 而不是返回值的承诺。

通过记录对 getUser 函数的调用来试试这个:

console.log(getUser())

这将给出以下内容:

Output__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: Object

这意味着您可以像处理 promise 一样使用 then 处理 async 函数。 试试下面的代码:

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

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

运行此程序时,您将收到以下信息:

Output{}

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

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

// Handle fetch with async/await
async function getUser() {
  const response = await fetch('https://api.github.com/users/octocat')
  const data = await response.json()

  console.log(data)
}

// Execute async function
getUser()

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

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

Outputlogin: "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


最后,由于您在异步函数中处理已履行的承诺,因此您还可以在函数中处理错误。 您将使用 try/catch 模式来处理异常,而不是将 catch 方法与 then 一起使用。

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

// Handling success and errors with async/await
async function getUser() {
  try {
    // Handle success in try
    const response = await fetch('https://api.github.com/users/octocat')
    const data = await response.json()

    console.log(data)
  } catch (error) {
    // Handle error in catch
    console.error(error)
  }
}

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

现代异步 JavaScript 代码最常使用 async/await 语法处理,但了解 Promise 的工作原理很重要,尤其是当 Promise 具有无法处理的附加功能时使用 async/await,就像将 Promise 与 Promise.all() 结合使用。

笔记: async/await can be reproduced by using 生成器与承诺相结合 to add more flexibility to your code. To learn more, check out our 了解 JavaScript 中的生成器 tutorial.


结论

由于 Web API 通常异步提供数据,因此学习如何处理异步操作的结果是 JavaScript 开发人员必不可少的部分。 在本文中,您了解了主机环境如何使用事件循环来处理具有 stackqueue 的代码执行顺序。 您还尝试了处理异步事件成功或失败的三种方法的示例,包括回调、承诺和 async/await 语法。 最后,您使用了 Fetch Web API 来处理异步操作。

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