了解 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
需要異步,否則整個瀏覽器在等待過程中會一直卡死,導致用戶體驗很差。
添加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
運行這三個函數仍然會導致按順序打印數字。但是因為是異步的,所以有超時的函數會最後打印出來:
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)
}
現在,執行 first
和 second
,然後通過 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 的函數有 resolve
和 reject
參數。 resolve
和 reject
函數分別處理操作的成功和失敗。
寫下下面這行來聲明一個承諾:
// 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)
})
}
設置代碼,以便如果 onSuccess
是 true
,超時將滿足一些數據。如果 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 中編碼系列。