async/await 是如何讓程式碼更加簡潔的?

程式碼灣發表於2018-08-17

我是如何放棄編寫回撥函式並愛上JavaScript ES8的

現代的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的廣泛支援,現在是一個非常好的時機將其整合到你自己的程式碼實踐和專案之中。

相關文章