JavaScript >> Javascript 文檔 >  >> JavaScript

了解 JavaScript 中的事件循環、回調、承諾和異步/等待

這篇文章最初是為 DigitalOcean 寫的。

簡介

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

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

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

作為一名 JavaScript 開發人員,您需要知道如何使用異步 Web API 並處理這些操作的響應或錯誤。在本文中,您將了解事件循環、通過回調處理異步行為的原始方式、更新後的 ECMAScript 2015 添加的 Promise 以及使用 async 的現代實踐 /await .

內容

  • 事件循環
  • 回調函數
    • 嵌套回調和末日金字塔
  • 承諾
  • async 的異步函數 /await

事件循環

本節將解釋 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() .

1
2
3

當使用異步 Web API 時,規則變得更加複雜。一個可以測試的內置 API 是 setTimeout ,它設置一個計時器並在指定的時間後執行一個動作。 setTimeout 需要異步,否則整個瀏覽器在等待過程中會一直卡死,導致用戶體驗很差。

添加setTimeoutsecond 模擬異步請求的函數:

// 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 運行這三個函數仍然會導致按順序打印數字。但是因為是異步的,所以有超時的函數會最後打印出來:

1
3
2

將超時設置為 0 秒還是 5 分鐘都沒有區別 — console.log 由異步代碼調用的將在同步頂級函數之後執行。這是因為 JavaScript 宿主環境(在本例中為瀏覽器)使用了一個稱為 事件循環 的概念 處理並發或併行事件。由於 JavaScript 一次只能執行一條語句,它需要通知事件循環何時執行哪條特定語句。事件循環使用 stack 的概念來處理這個問題 和一個隊列 .

堆棧

堆棧 ,或調用堆棧,保存當前正在運行的函數的狀態。如果您不熟悉堆棧的概念,可以將其想像為具有“後進先出”(LIFO) 屬性的數組,這意味著您只能從堆棧末尾添加或刪除項目。 JavaScript 將運行當前的 (或特定環境中的函數調用)在堆棧中,然後將其移除並移動到下一個。

對於僅包含同步代碼的示例,瀏覽器按以下順序處理執行:

  • 添加first() 到堆棧,運行 first() 記錄 1 到控制台,刪除 first() 從堆棧中。
  • 添加second() 到堆棧,運行 second() 記錄 2 到控制台,刪除 second() 從堆棧中。
  • 添加third() 到堆棧,運行 third() 記錄 3 到控制台,刪除 third() 從堆棧中。

setTimout 的第二個例子 看起來像這樣:

  • 添加first() 到堆棧,運行 first() 記錄 1 到控制台,刪除 first() 從堆棧中。
  • 添加second() 到堆棧,運行 second() .
    • 添加setTimeout() 到堆棧,運行 setTimeout() 啟動計時器並將匿名函數添加到 queue 的 Web API , 刪除 setTimeout() 從堆棧中。
  • 移除 second() 從堆棧中。
  • 添加third() 到堆棧,運行 third() 記錄 3 到控制台,刪除 third() 從堆棧中。
  • 事件循環檢查隊列中是否有任何待處理的消息並從 setTimeout() 中找到匿名函數 , 將函數添加到記錄 2 的堆棧中 到控制台,然後將其從堆棧中刪除。

使用 setTimeout ,一個異步 Web API,引入了 queue 的概念 ,本教程接下來會介紹。

隊列

隊列 ,也稱為消息隊列或任務隊列,是函數的等待區。每當調用堆棧為空時,事件循環將從最舊的消息開始檢查隊列中是否有任何等待消息。一旦找到,它就會將其添加到堆棧中,堆棧將執行消息中的函數。

setTimeout 例如,匿名函數在頂層執行的其餘部分之後立即運行,因為計時器設置為 0 秒。重要的是要記住,計時器並不意味著代碼將在 0 中執行 秒或任何指定的時間,但它將在該時間內將匿名函數添加到隊列中。之所以存在這種隊列系統,是因為如果定時器在定時器結束時將匿名函數直接添加到堆棧中,它將中斷當前正在運行的任何函數,這可能會產生意想不到的和不可預知的影響。

現在您知道事件循環如何使用堆棧和隊列來處理代碼的執行順序了。下一個任務是弄清楚如何控制代碼中的執行順序。為此,您將首先了解確保事件循環正確處理異步代碼的原始方法:回調函數。

回調函數

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 ,定義一個函數higherOrderFunction 需要一個函數 callback 作為參數,並傳遞 fn 作為對 higherOrderFunction 的回調 .

運行這段代碼會得到以下結果:

Just a function

讓我們回到first , second , 和 third setTimeout 的函數 .這是你目前所擁有的:

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

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

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

任務是獲取third 函數總是延遲執行,直到 second 中的異步操作之後 功能已完成。這就是回調的用武之地。而不是執行 first , second , 和 third 在執行的頂層,您將傳遞 third 作為 second 的參數的函數 . second 函數會在異步動作完成後執行回調。

以下是應用了回調的三個函數:

// 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)

運行此代碼塊後,您將收到以下輸出:

1
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 嵌套在一個更高階的函數中,創建一個越來越深的回調金字塔形狀。運行此代碼將給出以下結果:

1
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 視覺混亂。

運行此代碼將為您提供以下信息:

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

這種處理異步代碼的方式很難遵循。因此,promises 的概念 在 ES6 中引入。這是下一節的重點。

承諾

一個承諾 表示完成一個異步函數。它是一個將來可能返回值的對象。它完成了與回調函數相同的基本目標,但具有許多附加功能和更易讀的語法。作為一名 JavaScript 開發人員,您可能會花費比創建它們更多的時間來消耗 Promise,因為通常是異步 Web API 會返回一個 Promise 供開發人員使用。本教程將向您展示如何做到這兩點。

創建承諾

您可以使用 new Promise 初始化一個承諾 語法,並且您必須使用函數對其進行初始化。傳遞給 Promise 的函數有 resolvereject 參數。 resolvereject 函數分別處理操作的成功和失敗。

寫下下面這行來聲明一個承諾:

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

如果您使用 Web 瀏覽器的控制台檢查處於此狀態的已初始化 Promise,您會發現它有一個 pending 狀態和 undefined 價值:

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

到目前為止,還沒有為 Promise 設置任何內容,所以它將放在 pending 中 永遠的狀態。測試一個 Promise 的第一件事就是通過一個值解決它來實現這個 Promise:

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

現在,在檢查 Promise 時,您會發現它的狀態為 fulfilled , 和一個 value 設置為您傳遞給 resolve 的值 :

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

如本節開頭所述,promise 是一個可以返回值的對象。成功履行後,value 來自 undefined 被數據填充。

一個 Promise 可以有三種可能的狀態:待處理、已完成和已拒絕。

  • 待處理 - 被解決或拒絕之前的初始狀態
  • 已完成 - 操作成功,promise 已解決
  • 拒絕 - 操作失敗,promise 被拒絕

在被執行或被拒絕後,一個promise就被解決了。

現在您已經了解瞭如何創建 Promise,讓我們看看開發人員如何使用這些 Promise。

消費承諾

上一節中的承諾已經實現了一個值,但您還希望能夠訪問該值。 Promise 有一個名為 then 的方法 將在 Promise 達到 resolve 後運行 在代碼中。 then 將 promise 的值作為參數返回。

這就是您返回和記錄 value 的方式 示例承諾:

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

你創建的 Promise 有一個 [[PromiseValue]] We did it! .該值將作為 response 傳遞給匿名函數 :

We did it!

到目前為止,您創建的示例並未涉及異步 Web API——它僅解釋瞭如何創建、解析和使用本機 JavaScript 承諾。使用 setTimeout ,你可以測試出一個異步請求。

以下代碼將異步請求返回的數據模擬為 promise:

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

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

使用 then 語法確保 response 僅當 setTimeout 2000之後操作完成 毫秒。這一切都是在沒有嵌套回調的情況下完成的。

現在兩秒鐘後,它將解析承諾值並登錄到 then

Resolving 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 中的已完成響應 將記錄返回值:

Resolving an asynchronous request! And chaining!

then 可以鏈接,它允許使用承諾看起來比回調更同步,因為它們不需要嵌套。這將允許更易於維護和驗證的更易讀的代碼。

錯誤處理

到目前為止,您只處理了一個成功的 resolve 承諾 ,它將承諾放在 fulfilled 狀態。但是,對於異步請求,您還必須經常處理錯誤——如果 API 已關閉,或者發送了格式錯誤或未經授權的請求。一個承諾應該能夠處理這兩種情況。在本節中,您將創建一個函數來測試創建和使用 Promise 的成功和錯誤情況。

這個getUsers 函數將一個標誌傳遞給一個promise,並返回這個promise。

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 將處理錯誤:

Failed 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)
  })

這將產生用戶數據:

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

作為參考,這裡有一個表,其中包含 Promise 上的處理程序方法 對象:

方法 說明
then() 處理一個 resolve .返回一個承諾,並調用 onFulfilled 異步函數
catch() 處理一個 reject .返回一個承諾,並調用 onRejected 異步函數
finally() 當一個承諾被解決時調用。返回一個承諾,並調用 onFinally 異步函數

對於以前從未在異步環境中工作過的新開發人員和經驗豐富的程序員來說,Promise 可能會令人困惑。然而,如前所述,使用 Promise 比創建 Promise 更常見。通常,瀏覽器的 Web API 或第三方庫會提供 Promise,您只需使用它即可。

在最後一個 Promise 部分,本教程將引用一個返回 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 傳遞給第二個 then 將數據記錄到控制台。 catch 語句將任何錯誤記錄到控制台。

運行此代碼將產生以下結果:

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 函數 函數前的關鍵字:

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

儘管此函數尚未處理任何異步操作,但它的行為與傳統函數不同。如果你執行這個函數,你會發現它返回了一個帶有 [[PromiseStatus]] 的 Promise 和 [[PromiseValue]] 而不是返回值。

通過記錄對 getUser 的調用來試試這個 功能:

console.log(getUser())

這將給出以下內容:

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

這意味著您可以處理 async then 的函數 以同樣的方式你可以處理一個承諾。試試下面的代碼:

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

getUser 的調用 將返回值傳遞給將值記錄到控制台的匿名函數。

運行此程序時,您將收到以下信息:

{}

一個 async 函數可以使用 await 處理其中調用的承諾 操作員。 await 可以在 async 中使用 函數,並會等到 promise 完成後再執行指定的代碼。

有了這些知識,您可以使用 async 重寫上一節中的 Fetch 請求 /await 如下:

// 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 的輸出 :

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

最後,由於您在異步函數中處理已履行的承諾,因此您還可以在函數中處理錯誤。而不是使用 catch then 的方法 ,您將使用 try /catch 處理異常的模式。

添加以下突出顯示的代碼:

// 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() 結合 .

結論

由於 Web API 通常異步提供數據,因此學習如何處理異步操作的結果是 JavaScript 開發人員必不可少的部分。在本文中,您了解了宿主環境如何使用事件循環通過 stack 處理代碼的執行順序 和隊列 .您還嘗試了處理異步事件成功或失敗的三種方法的示例,包括回調、承諾和 async /await 句法。最後,您使用了 Fetch Web API 來處理異步操作。

有關瀏覽器如何處理並行事件的更多信息,請閱讀 Mozilla 開發者網絡上的並發模型和事件循環。如果您想了解有關 JavaScript 的更多信息,請返回我們的如何在 JavaScript 中編碼系列。


Tutorial JavaScript 教程
  1. 通過捕獲個人資料視圖來展示您在 Github 自述文件中的存在

  2. 使用 React 的通知時間線

  3. 在 JavaScript 中通過測量圓的面積來計算 PI

  4. 什麼是正確的 JSON 日期格式?

  5. 如何使用 Node.js 構建像 bitly 或 shorturl 這樣的 URL Shortener

  6. Angular 13 + NestJS 8 入門項目

  7. Bootstrap 5:在 Button 中對齊圖標和文本

  1. 使用 jQuery 彈出表單

  2. 我的 React Router 類型安全解決方案

  3. 使用 React 測試庫測試選擇選項

  4. 如果另一個不存在,則角度創建元素

  5. JavaScript 中的箭頭函數與常規函數

  6. 我寫了一個控制台工具來同時編輯多個語言文件

  7. 在 JS 中解析自定義日期

  1. 概念:內聚的非耦合前端代碼架構

  2. 模態不會在所有按鈕上打開

  3. React 包以創建預訂表格

  4. 為什麼我喜歡 CoffeeScript!(以及為什麼要使用它)