如何更好的編寫async函式

Jiasm發表於2019-03-03

2018年已經到了5月份,node4.x版本也已經停止了維護
我司的某個服務也已經切到了8.x,目前正在做koa2.x的遷移
將之前的generator全部替換為async
但是,在替換的過程中,發現一些濫用async導致的時間上的浪費
所以來談一下,如何優化async程式碼,更充分的利用非同步事件流 杜絕濫用async

首先,你需要了解Promise

Promise是使用async/await的基礎,所以你一定要先了解Promise是做什麼的
Promise是幫助解決回撥地獄的一個好東西,能夠讓非同步流程變得更清晰。
一個簡單的Error-first-callback轉換為Promise的例子:

const fs = require(`fs`)

function readFile (fileName) {
  return new Promise((resolve, reject) => {
    fs.readFile(fileName, (err, data) => {
      if (err) reject(err)

      resolve(data)
    })
  })
}

readFile(`test.log`).then(data => {
  console.log(`get data`)
}, err => {
  console.error(err)
})
複製程式碼

我們呼叫函式返回一個Promise的例項,在例項化的過程中進行檔案的讀取,當檔案讀取的回撥觸發式,進行Promise狀態的變更,resolved或者rejected
狀態的變更我們使用then來監聽,第一個回撥為resolve的處理,第二個回撥為reject的處理。

async與Promise的關係

async函式相當於一個簡寫的返回Promise例項的函式,效果如下:

function getNumber () {
  return new Promise((resolve, reject) => {
    resolve(1)
  })
}
// =>
async function getNumber () {
  return 1
}
複製程式碼

兩者在使用上方式上完全一樣,都可以在呼叫getNumber函式後使用then進行監聽返回值。
以及與async對應的await語法的使用方式:

getNumber().then(data => {
  // got data
})
// =>
let data = await getNumber()
複製程式碼

await的執行會獲取表示式後邊的Promise執行結果,相當於我們呼叫then獲取回撥結果一樣。
P.S. 在async/await支援度還不是很高的時候,大家都會選擇使用generator/yield結合著一些類似於co的庫來實現類似的效果

async函式程式碼執行是同步的,結果返回是非同步的

async函式總是會返回一個Promise的例項 這點兒很重要
所以說呼叫一個async函式時,可以理解為裡邊的程式碼都是處於new Promise中,所以是同步執行的
而最後return的操作,則相當於在Promise中呼叫resolve

async function getNumber () {
  console.log(`call getNumber()`)

  return 1
}

getNumber().then(_ => console.log(`resolved`))
console.log(`done`)

// 輸出順序:
// call getNumber()
// done
// resolved
複製程式碼

Promise內部的Promise會被消化

也就是說,如果我們有如下的程式碼:

function getNumber () {
  return new Promise(resolve => {
    resolve(Promise.resolve(1))
  })
}

getNumber().then(data => console.log(data)) // 1
複製程式碼

如果按照上邊說的話,我們在then裡邊獲取到的data應該是傳入resolve中的值 ,也就是另一個Promise的例項。
但實際上,我們會直接獲得返回值:1,也就是說,如果在Promise中返回一個Promise,實際上程式會幫我們執行這個Promise,並在內部的Promise狀態改變時觸發then之類的回撥。
一個有意思的事情:

function getNumber () {
  return new Promise(resolve => {
    resolve(Promise.reject(new Error(`Test`)))
  })
}

getNumber().catch(err => console.error(err)) // Error: Test
複製程式碼

如果我們在resolve中傳入了一個reject,則我們在外部則可以直接使用catch監聽到。
這種方式經常用於在async函式中丟擲異常
如何在async函式中丟擲異常:

async function getNumber () {
  return Promise.reject(new Error(`Test`))
}
try {
  let number = await getNumber()
} catch (e) {
  console.error(e)
}
複製程式碼

一定不要忘了await關鍵字

如果忘記新增await關鍵字,程式碼層面並不會報錯,但是我們接收到的返回值卻是一個Promise

let number = getNumber()
console.log(number) // Promise
複製程式碼

所以在使用時一定要切記await關鍵字

let number = await getNumber()
console.log(number) // 1
複製程式碼

不是所有的地方都需要新增await

在程式碼的執行過程中,有時候,並不是所有的非同步都要新增await的。
比如下邊的對檔案的操作:
我們假設fs所有的API都被我們轉換為了Promise版本

async function writeFile () {
  let fd = await fs.open(`test.log`)
  fs.write(fd, `hello`)
  fs.write(fd, `world`)
  return fs.close(fd)
}
複製程式碼

就像上邊說的,Promise內部的Promise會被消化,所以我們在最後的close也沒有使用await
我們通過await開啟一個檔案,然後進行兩次檔案的寫入。
但是注意了,在兩次檔案的寫入操作前邊,我們並沒有新增await關鍵字。
因為這是多餘的,我們只需要通知API,我要往這個檔案裡邊寫入一行文字,順序自然會由fs來控制 。
最後再進行close,因為如果我們上邊在執行寫入的過程還沒有完成時,close的回撥是不會觸發的,
也就是說,回撥的觸發就意味著上邊兩步的write已經執行完成了。

合併多個不相干的async函式呼叫

如果我們現在要獲取一個使用者的頭像和使用者的詳細資訊(而這是兩個介面 雖說一般情況下不太會出現

async function getUser () {
  let avatar = await getAvatar()
  let userInfo = await getUserInfo()

  return {
    avatar,
    userInfo
  }
}
複製程式碼

這樣的程式碼就造成了一個問題,我們獲取使用者資訊的介面並不依賴於頭像介面的返回值。
但是這樣的程式碼卻會在獲取到頭像以後才會去傳送獲取使用者資訊的請求。
所以我們對這種程式碼可以這樣處理:

async function getUser () {
  let [avatar, userInfo] = await Promise.all([getAvatar(), getUserInfo()])

  return {
    avatar,
    userInfo
  }
}
複製程式碼

這樣的修改就會讓getAvatargetUserInfo內部的程式碼同時執行,同時傳送兩個請求,在外層通過包一層Promise.all來確保兩者都返回結果。

讓相互沒有依賴關係的非同步函式同時執行

一些迴圈中的注意事項

forEach

當我們呼叫這樣的程式碼時:

async function getUsersInfo () {
  [1, 2, 3].forEach(async uid => {
    console.log(await getUserInfo(uid))
  })
}

function getuserInfo (uid) {
  return new Promise(resolve => {
    setTimeout(_ => resolve(uid), 1000)
  })
}

await getUsersInfo()
複製程式碼

這樣的執行好像並沒有什麼問題,我們也會得到123三條log的輸出,
但是當我們在await getUsersInfo()下邊再新增一條console.log(`done`)的話,就會發現:
我們會先得到done,然後才是三條uidlog,也就是說,getUsersInfo返回結果時,其實內部Promise並沒有執行完。
這是因為forEach並不會關心回撥函式的返回值是什麼,它只是執行回撥。

不要在普通的for、while迴圈中使用await

使用普通的forwhile迴圈會導致程式變為序列:

for (let uid of [1, 2, 3]) {
  let result = await getUserInfo(uid)
}
複製程式碼

這樣的程式碼執行,會在拿到uid: 1的資料後才會去請求uid: 2的資料


關於這兩種問題的解決方案:

目前最優的就是將其替換為map結合著Promise.all來實現:

await Promise.all([1, 2, 3].map(async uid => await getUserInfo(uid)))
複製程式碼

這樣的程式碼實現會同時例項化三個Promise,並請求getUserInfo

P.S. 草案中有一個await*,可以省去Promise.all

await* [1, 2, 3].map(async uid => await getUserInfo(uid))
複製程式碼

P.S. 為什麼在使用Generator+co時沒有這個問題

在使用koa1.x的時候,我們直接寫yield [].map是不會出現上述所說的序列問題的
看過co原始碼的小夥伴應該都明白,裡邊有這麼兩個函式(刪除了其餘不相關的程式碼):

function toPromise(obj) {
  if (Array.isArray(obj)) return arrayToPromise.call(this, obj);
  return obj;
}

function arrayToPromise(obj) {
  return Promise.all(obj.map(toPromise, this));
}
複製程式碼

co是幫助我們新增了Promise.all的處理的(膜拜TJ大佬)。

總結

總結一下關於async函式編寫的幾個小提示:

  1. 使用return Promise.reject()async函式中丟擲異常
  2. 讓相互之間沒有依賴關係的非同步函式同時執行
  3. 不要在迴圈的回撥中/forwhile迴圈中使用await,用map來代替它

參考資料

  1. async-function-tips

相關文章