使用 Async / Await 來編寫簡明的非同步程式碼

天天天天才櫻木花道發表於2019-03-04
原文連結:https://blog.patricktriest.com/what-is-async-await-why-should-you-care/
複製程式碼

停止書寫回撥函式並愛上ES8

以前,JavaScript專案會逐漸‘失去控制’,其中主要一個原因就是採用傳統的回撥函式處理非同步任務時,一旦業務邏輯比較複雜,我們就難免書寫一些冗長、複雜、巢狀的程式碼塊(回撥地獄),這會嚴重降低程式碼的可讀性與可維護性。現在,JavaScript提供了一種新的語法糖來取代回撥函式,使我們能夠編寫簡明、可讀性高的非同步程式碼。

背景

AJAX

先來回顧一下歷史。在20世紀90年代後期,Ajax是非同步JavaScript的第一個重大突破。這一技術允許網站在載入HTML後獲取並顯示最新的資料,這是一個革命性的想法。在這之前,大多數網站會再次下載整個頁面來顯示更新的內容。這一技術(在jQuery中以ajax的名稱流行)主導了2000-2010的web開發並且Ajax是目前網站用來獲取資料的主要技術,但是XML在很大程度上取代了JSON。

NodeJS

當NodeJS在2009年首次釋出時,伺服器端環境的主要焦點是允許程式優雅地處理併發性。大多數伺服器端語言通過阻塞程式碼來處理I/O操作,直到操作完成為止。相反,NodeJS使用的是事件迴圈機制,這樣開發人員可以在非阻塞非同步操作完成後,呼叫回撥函式來處理邏輯(類似於Ajax的工作方式)。

Promises

幾年後,NodeJS和瀏覽器環境中出現了一種新的標準,稱為”Promise”,Promise提供了一種強大的、標準化的方式來組成非同步操作。Promise仍然使用基於回撥的格式,但為鏈式和組合非同步操作提供了一致的語法。在2015年,由流行的開源庫所倡導的Promise最終被新增為JavaScript的原生特性。
Promise是一個不錯的改進,但它們仍然常常是一些冗長而難以閱讀的程式碼塊的原因。
而現在有了一個解決方案。
Async/Await是一種新的語法(從.net和C#中借用),它允許我們編寫Promise,但它們看起來像是同步程式碼,沒有回撥,可以用來簡化幾乎任何現有的JS應用程式。Async/Await是JavaScript語言的新增的特性,在ES7中被正式新增為JavaScript的原生特性。

示例

我們將通過一些程式碼示例來展示Async/Await的魅力

:執行下面的示例不需要任何庫。Async/Await已經被最新版本的Chrome、FireFox、Safari、Edge完全支援,你可以在你的瀏覽器控制檯裡執行例子。Async/Await需要執行在NodeJS 7.6版本及以上,同時也被Babel、TypeScript轉譯器支援。所以Async/Await可以被用於實際開發之中。

準備

我們會使用一個虛擬的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)
    })
  }
}
複製程式碼

每個示例依次執行如下三個操作: 獲取一個使用者的資訊,獲取該使用者的朋友, 獲取該使用者的照片。在最後,我們會在控制檯中列印這些結果。

方法一 — Nested Promise Callback Functions

使用巢狀的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專案開發的人來說,這個程式碼塊非常熟悉。非常簡單的業務邏輯,但是程式碼卻是冗長、深巢狀,並且以這個結尾…..

     })
    })
  })
}
複製程式碼

在真實的業務場景中,每個回撥函式可能更復雜,程式碼塊會以一堆充滿層次感的})為結尾。“回撥函式裡面巢狀著回撥函式巢狀著回撥函式”,這就是被傳說中的“回撥地獄”(“回撥地獄”的誕生不只是因為程式碼塊的混亂,也源於信任問題。)。
更糟糕的是,我們為了簡化,還沒有做錯誤處理機制,如果加上了reject……細思極恐

方法二 — Promise Chain

讓我們優雅起來

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.prototype.then()和Promise.prototype.catch()返回Promise物件,這就使得我們可以將這些promise連線成一個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 })
}
複製程式碼

優雅多了,呼叫await之前我們會一直等待,直到promise被決議並將值賦值給左邊的變數。通過async/await,我們可以對非同步操作流程進行控制,就好像它是同步程式碼。

注:await必須搭配async一起使用,注意上面的函式,我們將關鍵字async放在了函式的宣告前,這是必需的。稍後,我們會深入討論這個問題

迴圈

Async/Await可以讓以前很多複雜的程式碼變得簡明。舉個例子,如果我們要按序檢索每個使用者的朋友的朋友列表。

方法一 — Recursive Promise Loop

下面是使用傳統的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)
    })
}
複製程式碼

我們建立在promiseLoops中建立了一個函式用於遞迴地去獲取朋友的朋友列表。這個函式體現了函數語言程式設計,但是對於這個簡單的任務而言,這依舊是一個比較複雜的解決方案。

方法二 — Async/Await For-Loop

讓我們嘗試一下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迴圈就能解決我們的問題。

並行

一個一個地去獲取朋友的朋友的列表看起來有點慢,為什麼不併行處理請求呢?我們可以用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.all()會返回一個promise,一旦所有的請求完成就會決議。

錯誤處理

然而,在非同步程式設計中有一個主要的問題還沒解決:錯誤處理。在非同步操作中,我們必須為每個操作編寫單獨的錯誤處理回撥,在呼叫棧的頂部去找出正確的報錯位置可能很複雜,所以我們得在每個回撥開始時就去檢查是否丟擲了錯誤。所以,引入錯誤處理後的回撥函式會比之前複雜度成倍增加,如果沒有主動定位到報錯的位置,這些錯誤甚至會被“吞掉”。
現在,我們給之前的例子添上錯誤處理機制。為了測試錯誤處理機制,我們將在真正獲取到使用者圖片之前使用抽象類裡的api.throwError()方法。

方法一 — Promise Error Callbacks

讓我們看看最壞的情況

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 Chain “Catch” Method

我們可以給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新增一個錯誤處理取代了之前給每個回撥函式新增錯誤處理。但是,這還是有一點複雜並且我們還是需要使用一個特殊的回撥來處理非同步錯誤而不是像對待正常的JavaScript錯誤那樣處理它們。

方法三 — Normal Try/Catch Block

我們可以做得更好

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函式獲取資料。

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,這將使得整個非同步模組更加明顯。
如果我們要獲取前面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)
  }
}
複製程式碼

結論

隨著SPA的興起和NodeJS的廣泛應用,對於JavaScript開發人員來說,優雅地處理併發性比以往任何時候都要重要。Async/Await緩解了許多因為bug引起且已經影響JavaScript很多年的控制流問題,並且使得程式碼更加優雅。如今,主流的瀏覽器和NodeJS都已經支援了這些語法糖,所以現在是使用Async/Await的最好時機。

相關文章