理解 JavaScript 中的事件循环、回调、Promises 和 Async/Await

作者選擇了COVID-19救助基金作為為捐贈而寫計畫的捐贈對象。

介紹

在互聯網的早期,網站通常由靜態數據組成,存在於一個HTML頁面中。但是現在隨著Web應用程序變得更加互動和動態,進行像是發出外部網絡請求以檢索API數據等密集操作變得越來越必要。要在JavaScript中處理這些操作,開發人員必須使用異步編程技術。

由於JavaScript是一種單線程編程語言,具有同步執行模型,它一次只處理一個操作。然而,像從API請求數據這樣的操作可能需要不確定的時間,取決於請求的數據大小、網絡連接的速度和其他因素。如果API調用是同步進行的,則瀏覽器將無法處理任何用戶輸入,例如滾動或點擊按鈕,直到該操作完成。這就是阻塞

為了防止阻塞行為,瀏覽器環境有許多 Web API 可供 JavaScript 存取,這些 API 是非同步的,意味著它們可以與其他操作並行運行,而不是按照順序。這很有用,因為它允許用戶在非同步操作處理時繼續正常使用瀏覽器。

作為 JavaScript 開發人員,您需要知道如何使用非同步 Web API 並處理這些操作的回應或錯誤。在本文中,您將了解事件循環、通過回調處理非同步行為的原始方法、ECMAScript 2015 新增的承諾,以及使用 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()

具有setTimeout的第二個示例如下:

  • 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 中引入了「promises」的概念。這是下一節的重點。

承諾

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]]We did it!。這個值將作為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語法可確保在setTimeout操作完成後的2000毫秒後才將response記錄下來。所有這些都是在不嵌套回調函數的情況下完成的。

現在,兩秒後,它將解析承諾值,並且它將在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的承诺,这会使承诺进入fulfilled状态。但是,经常在异步请求中您还需要处理错误——如果 API 崩溃、发送了格式错误或未授权的请求。承诺应该能够处理这两种情况。在本节中,您将创建一个函数来测试创建和消耗承诺的成功和错误案例。

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

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为参数的失败回调。

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

// 使用 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 的工作更加輕鬆。

使用async/await的異步函數

一個async函數允許你以看似同步的方式處理異步程式碼。async函數在底層仍然使用Promise,但具有更傳統的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運算子來處理其中的一個承諾。在async函數內部可以使用await,它將等待一個承諾完成後再執行指定的程式碼。

有了這個知識,你可以使用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 ...

注意:在許多環境中,使用await必須使用async,但是一些新版本的瀏覽器和Node允許使用頂級await,這允許您跳過創建async函數來包裝await

最後,由於您正在處理異步函數內的完成承諾,您還可以在函數內部處理錯誤。不再使用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 來處理,比如將 promise 與 Promise.all() 結合。

注意:通過將 生成器與 promises 結合 可以實現 async/await,從而為代碼添加更多的靈活性。欲了解更多,請查看我們的 JavaScript 中生成器的理解 教程。

結論

因為 Web API 常常以非同步方式提供數據,學習如何處理非同步操作的結果是成為 JavaScript 開發者的重要部分。在這篇文章中,你學到了主機環境如何使用事件循環來處理代碼的執行順序,包括 堆疊佇列。你還嘗試了三種處理非同步事件成功或失敗的方式,包括使用回調函數、Promises 和 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