爲什麼說Async/Await讓代碼更簡潔?
作者|Patrick Triest
譯者|張衛濱
來源 | 前端之巔
Async/Await 是 ECMAScript 新引入的語法,能夠極大地簡化異步程序的編寫,本文詳細介紹了 Async/Await 的用法以及與傳統方式的對比,通過樣例體現了 Async/Await 的優勢。
現代的 JavaScript 項目有時候會面臨失控的危險。其中有個主要的原因就是處理異步任務中的混亂,它們會導致冗長、複雜和深度嵌套的代碼塊。JavaScript 現在爲這種操作提供了新的語法,它甚至能夠將最複雜的異步操作轉換成簡潔且具有高度可讀性的代碼。
背 景
AJAX(異步 JavaScript 與 XML)
首先,我們來回顧一下歷史。在 20 世紀 90 年代,在異步 JavaScript 方面,Ajax 是第一個重大突破。這項技術允許 Web 站點在 HTML 加載完之後,拉取和展現新的數據,當時,大多數的 Web 站點爲了進行內容更新,都會再次下載整個頁面,因此這是一個革命性的理念。這項技術(因爲 jQuery 中打包了輔助函數使其得以流行開來)主導了本世紀前十年的 Web 開發,如今,Ajax 是目前 Web 站點用來獲取數據的主要技術,但是 XML 在很大程度上被 JSON 所取代了。
Node.js
當 Node.js 在 2009 年首次發佈時,服務器環境的主要關注點在於允許程序優雅地處理併發。當時,大多數的服務器端語言通過阻塞代碼執行的方式來處理 I/O 操作,直到操作完成爲止。NodeJS 卻採用了事件輪詢的架構,這樣的話,開發人員可以設置“回調(callback)”函數,該函數會在非阻塞的異步操作完成之後被調用,這與 Ajax 語法的工作原理是類似的。
Promise
幾年之後,在 Node.js 和瀏覽器環境中都出現了一個新的標準,名爲“Promise”,它提供了強大且標準的方式來組合異步操作。Promise 依然使用基於回調的格式,但是提供了一致的語法來鏈接(chain)和組合異步操作。Promise 最初是由流行的開源庫所倡導的庫,在 2015 年最終作爲原生特性添加到了 JavaScript 中。
Promise 是一項重要的功能改善,但它們依然經常會產生冗長且難以閱讀的代碼。
現在,我們有了一種解決方案。
Async/await 是一種新的語法(借鑑自.NET and C#),它允許我們在組合 Promise 時,就像正常的同步函數那樣,不需要使用回調。對於 JavaScript 語言來說,這是非常棒的新特性,它是在 JavaScript ES7 中添加進來的,能夠用來極大地簡化已有的 JS 應用程序。
樣例
接下來,我們將會介紹幾個代碼樣例。
這裏並不需要其他的庫。在最新的 Chrome、Firefox、Safari 和 Edge 中, async/await 已經得到了完整的支持,所以你可以在瀏覽器的控制檯中嘗試這些樣例。另外,async/await 能夠用於 Node.js 7.6 及以上的版本,而且 Babel 和 Typescript 轉譯器也支持該語法,所以現在它能夠用到任意的 JavaScript 項目中。
搭建
如果你想要在自己的機器上跟着運行這些代碼的話,那麼將會用到這個虛擬的 API 類。這個類模擬網絡調用,返回 Promise,這個 Promise 將會在調用 200ms 之後以簡單示例數據的方式完成處理。
class Api {
constructor () {
this.user = { id: 1, name: 'test' }
this.friends = [ this.user, this.user, this.user ]
this.photo = 'not a real photo'
}
getUser () {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.user), 200)
})
}
getFriends (userId) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.friends.slice()), 200)
})
}
getPhoto (userId) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.photo), 200)
})
}
throwError () {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Intentional Error')), 200)
})
}
}
每個樣例都會按順序執行三個相同的操作:檢索某個用戶、檢索他們的好友、獲取他們的圖片。最後,我們會將所有的三個結果打印在控制檯上。
第一次嘗試:嵌套 Promise 回調函數
下面的代碼展現了使用嵌套 Promise 回調函數的實現。
function callbackHell () {
const api = new Api()
let user, friends
api.getUser().then(function (returnedUser) {
user = returnedUser
api.getFriends(user.id).then(function (returnedFriends) {
friends = returnedFriends
api.getPhoto(user.id).then(function (photo) {
console.log('callbackHell', { user, friends, photo })
})
})
})
}
看上去,這似乎與我們在 JavaScript 項目中的做法非常類似。要實現非常簡單的功能,結果代碼塊變得非常冗長且具有很深的嵌套,結尾處的代碼甚至變成了這種樣子:
})
})
})
}
在真實的代碼庫中,每個回調函數可能會非常長,這可能會導致龐大且深層交錯的函數。處理這種類型的代碼,在回調中繼續使用回調,就是通常所謂的“回調地獄”。
更糟糕的是,這裏沒有錯誤檢查,所以其中任何一個回調都可能會悄無聲息地發生失敗,表現形式則是未處理的 Promise 拒絕。
第二次嘗試:Promise 鏈
接下來,我們看一下是否能夠做得更好一些。
function promiseChain () {
const api = new Api()
let user, friends
api.getUser()
.then((returnedUser) => {
user = returnedUser
return api.getFriends(user.id)
})
.then((returnedFriends) => {
friends = returnedFriends
return api.getPhoto(user.id)
})
.then((photo) => {
console.log('promiseChain', { user, friends, photo })
})
}
Promise 非常棒的一項特性就是它們能夠鏈接在一起,這是通過在每個回調中返回另一個 Promise 來實現的。通過這種方式,我們能夠保證所有的回調處於相同的嵌套級別。我們在這裏還使用了箭頭函數,簡化了回調函數的聲明。
這個變種形式顯然比前面的更易讀,也更加具有順序性,但看上去依然非常冗長和複雜。
第三次嘗試:Async/Await
在編寫的時候怎樣才能避免出現回調函數呢?這難道是不可能實現的嗎?怎樣使用 7 行代碼完成編寫呢?
async function asyncAwaitIsYourNewBestFriend () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const photo = await api.getPhoto(user.id)
console.log('asyncAwaitIsYourNewBestFriend', { user, friends, photo })
}
這樣就更好了。在返回 Promise 函數調用之前,添加“await”將會暫停函數流,直到 Promise 處於 resolved 狀態爲止,並且會將結果賦值給等號左側的變量。藉助這種方式,我們在編寫異步操作流時,能夠像編寫正常的同步命令序列一樣。
我希望,此時你能像我一樣感到興奮。
注意:“async”要放到函數聲明開始的位置上。這是必須的,它實際上會將整個函數變成一個 Promise,稍後我們將會更深入地對其進行介紹。
LOOPS
使用 async/await 能夠讓很多在此之前非常複雜的操作變得很簡便。例如,如果我們想要順序地獲取某個用戶的好友的好友,那該怎麼實現呢?
第一次嘗試:遞歸 Promise 循環
如下展現瞭如何通過正常的 Promise 按順序獲取每個好友列表:
function promiseLoops () {
const api = new Api()
api.getUser()
.then((user) => {
return api.getFriends(user.id)
})
.then((returnedFriends) => {
const getFriendsOfFriends = (friends) => {
if (friends.length > 0) {
let friend = friends.pop()
return api.getFriends(friend.id)
.then((moreFriends) => {
console.log('promiseLoops', moreFriends)
return getFriendsOfFriends(friends)
})
}
}
return getFriendsOfFriends(returnedFriends)
})
}
我們創建了一個內部函數,該函數會以 Promise 鏈的形式遞歸獲取好友的好友,直至列表爲空爲止。它完全是函數式的,這一點非常好,但對於這樣一個非常簡單的任務來說,這個方案依然非常複雜。
注意:如果希望通過Promise.all()來簡化promiseLoops()函數的話,將會導致明顯不同的函數行爲。本例的意圖是展示順序操作(每次一個),而Promise.all()用於併發(所有操作同時)運行異步操作。Promise.all()與 async/await 組合使用會有很強的威力,我們在下面的章節中將會進行討論。
第二次嘗試:Async/Await For 循環
採用 Async/Await 之後看起來就容易多了:
async function asyncAwaitLoops () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
for (let friend of friends) {
let moreFriends = await api.getFriends(friend.id)
console.log('asyncAwaitLoops', moreFriends)
}
}
此時,我們不需要編寫任何的遞歸 Promise 閉包。只需一個 for-loop 即可,所以 async/await 是能夠幫助我們的好朋友。
並行操作
按照一個接一個的順序獲取每個好友似乎有些慢,爲什麼不用並行的方式來進行操作呢?藉助 async/await 能夠實現這一點嗎?
是的,當然可以。它解決了我們所有的問題。
async function asyncAwaitLoopsParallel () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const friendPromises = friends.map(friend => api.getFriends(friend.id))
const moreFriends = await Promise.all(friendPromises)
console.log('asyncAwaitLoopsParallel', moreFriends)
}
要並行運行操作,首先生成一個要運行的 Promise 的列表,然後將其作爲參數傳遞給Promise.all()。這樣會返回一個 Promise 讓我們去 await 它完成,當所有的操作都結束時,它就會進行 resolve 處理。
錯誤處理
在異步編程中,還有一個主要的問題我們沒有解決,那就是錯誤處理。它是很多代碼庫的軟肋,異步錯誤處理一般要涉及到爲每個操作編寫錯誤處理的回調。將錯誤傳遞到調用堆棧的頂部可能會非常複雜,通常需要在每個回調開始的地方顯式檢查是否有錯誤拋出。這種方式冗長繁瑣並且容易出錯。此外,如果沒有恰當地進行處理,Promise 中拋出的異常將導致悄無聲息地失敗,這會產生代碼庫中錯誤檢查不全面的“不可見的錯誤”。
我們再看一下樣例,爲它們依次添加錯誤處理功能。爲了測試錯誤處理,我們在獲取用戶的照片之前,將會調用一個額外的函數,“api.throwError()”。
第一次嘗試:Promise 錯誤回調
我們首先看一個最糟糕的場景。
function callbackErrorHell () {
const api = new Api()
let user, friends
api.getUser().then(function (returnedUser) {
user = returnedUser
api.getFriends(user.id).then(function (returnedFriends) {
friends = returnedFriends
api.throwError().then(function () {
console.log('Error was not thrown')
api.getPhoto(user.id).then(function (photo) {
console.log('callbackErrorHell', { user, friends, photo })
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}
這是非常恐怖的一種寫法。除了非常冗長和醜陋之外,控制流非常不直觀,因爲它是從輸出接入的,而不像正常的、易讀的代碼庫那樣,從頂部到底部進行編寫。
第二次嘗試:Promise 鏈的“Catch”方法
我們可以使用 Promise 的“catch”方法,對此進行一些改善。
function callbackErrorPromiseChain () {
const api = new Api()
let user, friends
api.getUser()
.then((returnedUser) => {
user = returnedUser
return api.getFriends(user.id)
})
.then((returnedFriends) => {
friends = returnedFriends
return api.throwError()
})
.then(() => {
console.log('Error was not thrown')
return api.getPhoto(user.id)
})
.then((photo) => {
console.log('callbackErrorPromiseChain', { user, friends, photo })
})
.catch((err) => {
console.error(err)
})
}
比起前面的寫法,這當然更好一些了,我們在 Promise 鏈的最後使用了一個 catch 函數,這樣能夠爲所有的操作提供一個錯誤處理器。但是,這還有些複雜,我們還是需要使用特定的回調來處理異步錯誤,而不能像處理正常的 Javascript 錯誤那樣來進行處理。
第三次嘗試:正常的 Try/Catch 代碼塊
我們可以更進一步。
async function aysncAwaitTryCatch () {
try {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
await api.throwError()
console.log('Error was not thrown')
const photo = await api.getPhoto(user.id)
console.log('async/await', { user, friends, photo })
} catch (err) {
console.error(err)
}
}
在這裏,我們將整個操作包裝在了一個正常的 try/catch 代碼塊中。通過這種方式,我們可以按照完全相同的方式,拋出和捕獲同步代碼和異步代碼中的錯誤。這種方式簡單了很多。
組 合
我在前面的內容中曾經提到過,帶有“async”標籤的函數實際上會返回一個 Promise。這樣的話,就允許我們非常容易地組合異步控制流。
例如,我們可以重新配置前面的樣例,讓它返回用戶數據,而不是簡單地打印日誌。我們可以通過調用 async 函數,將其作爲一個 Promise 來獲取數據。
async function getUserInfo () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const photo = await api.getPhoto(user.id)
return { user, friends, photo }
}
function promiseUserInfo () {
getUserInfo().then(({ user, friends, photo }) => {
console.log('promiseUserInfo', { user, friends, photo })
})
}
更好的一點在於,我們可以在接收者函數中使用 async/await 語法,這樣的話,就能形成完全具有優勢、非常簡單的異步編程代碼塊。
async function awaitUserInfo () {
const { user, friends, photo } = await getUserInfo()
console.log('awaitUserInfo', { user, friends, photo })
}
如果我們想要獲取前十個用戶的數據,那又該怎樣處理呢?
async function getLotsOfUserData () {
const users = []
while (users.length < 10) {
users.push(await getUserInfo())
}
console.log('getLotsOfUserData', users)
}
如果想要並行該怎麼辦呢?怎樣添加完備的錯誤處理功能?
async function getLotsOfUserDataFaster () {
try {
const userPromises = Array(10).fill(getUserInfo())
const users = await Promise.all(userPromises)
console.log('getLotsOfUserDataFaster', users)
} catch (err) {
console.error(err)
}
}
結 論
隨着單頁 JavaScript Web 應用的興起和 Node.js 的廣泛採用,對於 JavaScript 開發人員來說,優雅地處理併發變得比以往更加重要。async/await 能夠緩解很多易於引入缺陷的控制流問題,這些問題已經困擾 JavaScript 代碼庫許多年了。同時,它還能確保異步代碼塊更加簡短、更加簡潔、更加清晰。隨着主流瀏覽器和 Node.js 的廣泛支持,現在是一個非常好的時機將其集成到你自己的代碼實踐和項目之中。
原文鏈接
https://blog.patricktriest.com/what-is-async-await-why-should-you-care/