前端修仙之路-五、async/await使你的程式碼更簡潔
有時候,我們在編寫JS程式碼的時候很細化使用巢狀回撥函式,但如果有多層巢狀的話,,會使專案的程式碼冗長,複雜和混亂。現在ES8提供了一種用於處理這些操作的新語法,它甚至可以將最複雜的非同步操作轉為簡潔易讀的程式碼。
ajax(非同步JavaScript和XML)
在此之前,為大家講講什麼是ajax?首先是一段簡短的歷史。在1990年代之後,Ajax是非同步JavaScript的第一個重大突破。這種技術允許網站在HTML載入之後提取並非同步重新整理資料,這是一個革命性的想法,因為當時大多數網站都是重新整理整個頁面來獲取並顯示新內容。這項技術(由jQuery中的捆綁輔助函式 "$.ajax" 命名)在整個21世紀都主導者Web開發,而Ajax是當今網站檢索資料的主要技術,但XML很大程度上替代了JSON。
NodeJS
當NodeJS欲2009年首次釋出時,服務端環境的主要重點是允許程式優雅地處理併發。當時,大多數服務端語言都通過同步阻塞來完成I/O操作的。相反,NodeJS利用事件迴圈體系結構,以便開發人員可以分配“回撥”功能,以完成非阻塞非同步操作。這類與Ajax技術相似的方式進行觸發。
Promise
在NodeJS釋出幾年後,NodeJS和瀏覽器環境中出現了一個稱為“Promises”的新標準,它提供了一種強大且標準化的方式來完成非同步操作。Promises仍使用基於回撥的格式,但提供了用於連結和組成非同步操作的一致語法。Promises由流行的開放原始碼庫開創,最終在2015年被作為JavaScript的新特性。
Promises雖然是一個重大的突破,但是它仍然常常是導致冗長且難以閱讀的程式碼塊的原因。
Async/await
Async/await是一種新語法(從.NET和C#借用),使我們將Promises組合起來,就好像它們知識沒有回撥的普通函式一樣。它於2016年被JavaScript新加入的,可用於簡化幾乎現有的JS應用程式。
前面扯了這麼多,也是時候回到正題了,我們先來看下面的一個例子。
我們需要做一個功能,在頁面載入的時候實現查詢三組資料:查詢使用者、查詢朋友、查詢圖片。
先看看正常的流程:
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) }) } }
在上面定義了API的類,裡面寫了三個封裝的查詢介面。現在要依次執行那三個操作。
第一種方法:Promises的巢狀
首先使用Promises巢狀回撥函式來實現:
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 }) }) }) }) }
從上面可以看出,這程式碼塊很簡單,但它有很長、很深的巢狀。
這只是簡單的函式內容,但在真實的程式碼庫中,每個回撥函式可能會很長的程式碼,這就可能會導致程式碼變得龐大難讀懂。處理此類程式碼,在回撥內的回撥中使用回撥,通常稱為“回撥地獄”。
更糟糕的是,沒有錯誤檢查,因此任何回撥都可能作為未處理的resove/reject而失敗。
第二種方法:Promises鏈
先看看程式碼:
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 }) }) }
Promises的一個不錯的功能是,可以通過在每個回撥中返回另一個Promises來連結它們。這樣,我們可以將所有回撥保持在相同的縮排級別。我們還可以使用箭頭函式來簡化回撥函式的宣告。
當然,此變體比第一種易於閱讀,並且有更好的順序感,但是,仍然很冗長且看起來複雜。
第三種方法 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 }) }
現在看起來,好多了。
在Promises之前呼叫“await”關鍵字暫停函式的流程,知道Promises被解決,並將結果分配給等號左側的變數。這樣,我們可以對非同步操作流程進行程式設計,就好像它是普通的同步命令系列一樣。
上面的例子比較簡單實現,下面我們繼續講下一個例子。
現在有一個功能,要獲取一個使用者的朋友的朋友列表。
第一種方法:遞迴Promises迴圈
在正常Promises下按順序獲取使用者的每個朋友的列表。
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) }) }
我們正在建立一個內部函式,該函式已遞迴的方式來獲取Promises,知道列表為空。雖然它具有完整的功能,但這只是對於簡單的任務來說。
第二種方法: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) } }
無需編寫任何遞迴的Promises閉包。只是一個迴圈。
同步操作
逐個列出每個使用者的朋友的朋友有點慢?為什麼不併行進行呢?
我們依然可以使用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) }
並並行執行操作,就要形成執行的Promises陣列,並將其作為引數傳遞給Promise.all()。這將返回一個等待的Promise,一旦所有操作完成,就會返回。
異常處理
在非同步程式設計中一個主要問題我們尚未解決:異常處理。非同步異常處理通常的操作是為每個操作編寫單獨的錯誤處理回撥。將錯誤滲透到呼叫堆疊的頂部可能很複雜,並且通常需要顯式檢查是否在每個回撥的開頭都引發了錯誤。這種方法乏味,冗長且容易出錯。此外,如果未正常捕獲,則在Promise中引發任何異常都將以靜默方式失敗,從而導致程式碼中有“看不見的錯誤”。
方法1: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鏈捕獲
我們可以通過結合使用Promise的捕獲方法來改善一下。
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錯誤一樣處理它們。
方法3:使用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 }) }
如果現在我們需要查詢前10個使用者的所有資料怎麼辦?
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) } }
寫在最後
隨著單頁web應用的興起以及NodeJS的廣泛應用,對於JS開發人員來說,優雅地處理併發比以往任何時候都更為重要。Async/await緩解了數十年來困擾JS程式碼引起錯誤的控制流問題,並且可以保證使任何非同步程式碼塊都明顯更短,更簡單,更不言而喻。藉助主流瀏覽器和NodeJS的近乎使用的支援,希望大家能靈活應用這技術。