沐鳴總代_使用 Async/Await 讓你的代碼更簡潔

寫在文章前

這篇文章翻譯自 ASYNC/AWAIT WILL MAKE YOUR CODE SIMPLER,這是一篇寫於2017年八月的文章,並由某專欄提名為17年十大必讀文章。在掘金上沒找到這篇文章的翻譯(其實沒仔細找),就想試着自己翻譯一下。翻譯的不好的地方,還望大家指出,針對我水平就好不要質疑掘金的水平(上次文章評論耿耿於懷 ̄▽ ̄),謝謝。

[翻譯] Async/Await 使你的代碼更簡潔

或者說,我如何學習不使用回調函數並且愛上ES8

有時,現代JavaScript項目會脫離我們的掌控。其中一個主要的罪魁禍首就是雜亂的處理異步的任務,導致寫出了又長又複雜又深層嵌套的代碼塊。JavaScript現在提供了一個新的處理這些操作的語法,他甚至能把最錯綜複雜的操作轉化成為簡潔而且可讀性高的代碼

背景

AJAX (Asynchronous JavaScript And XML)

首先來進行一點科普。 在90年代末期, Ajax是異步JavaScript的第一個重大突破。 這個技術可以讓網站在html加載之後獲取和展示新的數據。對於當時大部分網站的那種需要重新下載整個個頁面來展示一個部分內容的更新來說,它是革命性的創新。這項技術(在jQuery中通過捆綁成為輔助函數而聞名)在整個21世界主導了web開發,同時ajax在今天也是網站用來檢索數據的主要技術,但xml卻被json大規模的取代

NodeJS

當NodeJS在2009年第一次發布的時候,服務端的一個主要的關注點就是允許程序優雅的處理併發。當時大部分的服務端語言使用阻塞代碼完成的這種方式來處理I/O操作,直到它結束處理I/O操作之後再繼續進行之前的代碼運行。取而代之,NodeJS利用事件循環體系,使用了一種類似ajax語法的工作方式:一旦非阻塞的異步操作完成之後,就可以讓開發者分配的回調函數被觸發。

Promises

幾年之後,一個新的叫做“promises”的標準出現在nodejs和瀏覽器環境中,他提供了一套更強大也更標準化的方式去構建異步操作。promises 仍舊使用基於回調的格式,但是為異步操作的鏈式調用和構建提供了統一的語法。promises,這種由流行的開源庫所創造的標準,最終在2015年被加入了原生JavaScript。

promises雖然是一個重大的改進,但仍舊會在某些情況下產生冗長難讀的代碼。

現在,我們有了一個新的解決方案。

async/await 是一種允許我們像構建沒有回調函數的普通函數一樣構建promises的新語法(從 .net和c#借鑒而來)。 這個是一個極好的JavaScript的增加功能,在去年被加進了JavaScript ES7,它甚至可以用來簡化幾乎所有現存的js應用。

Examples

我們將會舉幾個例子。

這些代碼例子不需要加載任何的三方庫。**Async/await 已經在在最新版本的chrome,Firefox,Safari,和edge 獲得全面支持,所以你可以在瀏覽器的控制台中試着運行這些示例。**此外,async/await 語法可以在Node的7.6版本及其以上運行, Babel 以及TypeScript 也同樣支持async/await 語法。Async和await 如今完全可以在任何JavaScript項目中使用

Setup

如果你想在你的電腦上跟隨我們的腳步探尋async,我們就將會使用這個虛擬的API Class。這個類通過返回promise對象來模擬網絡的調用的過程,並且這些promise對象將會在被調用的200ms之後使用resolve函數將簡單的數據作為參數傳遞出去。

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 rejection 而引發不易察覺的地失敗。

第二個嘗試 – 鏈式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)
    })
}

我們創建了一個內部函數用來通過回調鏈式的promises獲取朋友的朋友,直到列表為空。O__O 我們的確實現了功能,很棒棒,但是我們其實使用了一個十分複雜的方案來解決一個相當簡單的任務。

注意 – 使用promise.all()來嘗試簡化PromiseLoops()函數會導致它表現為一個有着完全不同的功能的函數。這個代碼段的目的是按順序(一個接着一個)運行操作,但Promise.all是同時運行所有異步操作(一次性運行所有)。但是,值得強調的是, Async/await 與Promise.all()結合使用仍舊十分的強大,就像我們下一個小節所展示的那樣。

第二次嘗試- Async/Await的for循環

這個可能就十分的簡單了。

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循環。看到了吧,這就是你的人生益友-Async/Await

PARALLEL OPERATIONS(并行操作)

逐個獲取每個朋友列表似乎有點慢,為什麼不採取并行執行呢?我們可以使用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, 這個promise對象一旦所有的操作都完成了就將會變成resolved狀態。

Error handling (錯誤處理)

然而,這篇文章到目前為止還沒有說到那個異步編程的重要問題:錯誤處理。 很多代碼庫的災難源頭就在於異步的錯誤處理通常涉及到為每個操作寫單獨的錯誤處理的回調。因為將錯誤放到調用堆棧的頂部會很複雜,並且通常需要在每個回調的開始明確檢查是否有錯誤拋出。這種方法是十分繁瑣冗長而且容易出錯的。況且,在一個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捕獲方法

我們可以通過使用一種promise-catch組合(先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錯誤一樣處理異步錯誤。

第三個嘗試-正常的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 塊中。這樣的話,我們就可以使用同樣的方式從同步代碼和一步代碼中拋出並捕獲錯誤。顯然,簡單的多;)

Composition(組合)

我在之前提到說,任何帶上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)
  }
}

Conclusion(結論)

隨着單頁JavaScript web程序的興起和對NodeJS的廣泛採用,如何優雅的處理併發對於JavaScript開發人員來說比任何以往的時候都顯得更為重要。Async/Await緩解了許多因為控制流問題而導致bug遍地的這個困擾着JavaScript代碼庫數十年的問題,並且幾乎可以保證讓任何異步代碼塊變的更精鍊,更簡單,更自信。而且近期async/await 已經在幾乎所有的主流瀏覽器以及nodejs上面獲得全面支持,因此現在正是將這些技術集成到自己的代碼實踐以及項目中的最好時機。