async語法升級踩坑小記

從今年過完年回來,三月份開始,就一直在作重構相關的事情。
就在今天剛剛上線了最新一次的重構代碼,但願高峯期安好,接近半年的Node.js代碼重構。
包含從 callback+ async.waterfallgenerator+ co,通通升級爲了 async,還順帶推進了 TypeScript在我司的使用。
這些日子也踩了很多坑,也總結了一些小小的優化方案,進行精簡後將一些比較關鍵的點,拿出來分享給你們,但願有一樣在作重構的小夥伴們能夠繞過這些。

爲何要升級

首先仍是要談談改代碼的理由,畢竟重構確定是要有合理的理由的。
若是單純想看升級相關事項能夠直接選擇跳過這部分。javascript

Callback

從最原始的開始提及,期間確實遇到了幾個年代久遠的項目,Node 0.x,使用的普通callback,也有一些會應用上async.waterfall這樣在當年看起來很優秀的工具。html

// 普通的回調函數調用
var fs = require('fs')

fs.readFile('test1.txt', function (err, data1) {
  if (err) return console.error(err)


  fs.readFile('test2.txt', function (err, data2) {
    if (err) return console.error(err)

    // 執行後續邏輯
    console.log(data1.toString() + data2.toString())
    // ...
  })
})

// 使用了async之後的複雜邏輯
var async = require('fs')

async.waterfall([
  function (callback) {
    fs.readFile('test1.txt', function (err, data) {
      if (err) callback(err)

      callback(null, data.toString())
    })
  },
  function (result, callback) {
    fs.readFile('test2.txt', function (err, data) {
      if (err) callback(err)

      callback(null, result + data.toString())
    })
  }
], function (err, result) {
  if (err) return console.error(err)

  // 獲取到正確的結果
  console.log(result) // 輸出兩個文件拼接後的內容
})

雖然說async.waterfall解決了callback hell的問題,不會出現一個函數前邊有二三十個空格的縮進。
可是這樣的流程控制在某些狀況下會讓代碼變得很詭異,例如我很難在某個函數中選擇下一個應該執行的函數,而是隻能按照順序執行,若是想要進行跳過,可能就要在中途的函數中進行額外處理:java

async.waterfall([
  function (callback) {
    if (XXX) {
      callback(null, null, null, true)
    } else {
      callback(null, data1, data2)
    }
  },
  function (data1, data2, isPass, callback) {
    if (isPass) {
      callback(null, null, null, isPass)
    } else {
      callback(null, data1 + data2)
    }
  }
])

因此極可能你的代碼會變成這樣,裏邊存在大量的不可讀的函數調用,那滿屏充斥的null佔位符。 node

因此callback這種形式的,必定要進行修改, __這屬於難以維護的代碼__。面試

Generator

實際上generator是依託於co以及相似的工具來實現的將其轉換爲Promise,從編輯器中看,這樣的代碼可讀性已經沒有什麼問題了,可是問題在於他始終是須要額外引入co來幫忙實現的,generator自己並不具有幫你執行異步代碼的功能。
不要再說什麼async/await是generator的語法糖了 express

由於我司Node版本已經統一升級到了8.11.x,因此async/await語法已經可用。
這就像若是document.querySelectorAllfetch已經能夠知足需求了,爲何還要引入jQuery呢。 npm

因此,將generator函數改造爲async/await函數也是勢在必行。json

期間遇到的坑

callback的升級爲async/await其實並無什麼坑,反卻是在generator + co 那裏遇到了一些問題:api

數組執行的問題

co的代碼中,你們應該都見到過這樣的:數組

const results = yield list.map(function * (item) {
  return yield getData(item)
})

在循環中發起一些異步請求,有些人會告訴你,從yield改成async/await僅僅替換關鍵字就行了。

那麼恭喜你獲得的results其實是一個由Promise實例組成的數組。

const results = await list.map(async item => {
  return await getData(item)
})

console.log(results) // [Promise, Promise, Promise, ...]

由於async並不會判斷你後邊的是否是一個數組(這個是在co中有額外的處理)而僅僅檢查表達式是否爲一個Promise實例。
因此正確的作法是,添加一層Promise.all,或者說等新的語法await*Node.js 10.x貌似還不支持。。

// 關於這段代碼的優化方案在下邊的建議中有提到
const results = await Promise.all(list.map(async item => {
  return await getData(item)
}))

console.log(results) // [1, 2, 3, ...]

await / yield 執行順序的差別

這個通常來講遇到的機率不大,可是若是真的遇到了而栽了進去就欲哭無淚了。

首先這樣的代碼在執行上是沒有什麼區別的:

yield 123 // 123

await 123 // 123

這樣的代碼也是沒有什麼區別的:

yield Promise.resolve(123) // 123

await Promise.resolve(123) // 123

可是這樣的代碼,問題就來了:

yield true ? Promise.resolve(123) : Promise.resolve(233) // 123

await true ? Promise.resolve(123) : Promise.resolve(233) // Promise<123>

從字面上咱們實際上是想要獲得yield那樣的效果,結果卻獲得了一個Promise實例。
這個是由於yieldawait兩個關鍵字執行順序不一樣所致使的。

在MDN的文檔中能夠找到對應的說明:MDN | Operator precedence

能夠看到yield的權重很是低,僅高於return,因此從字面上看,這個執行的結果很符合咱們想要的。
await關鍵字的權重要高不少,甚至高於最普通的四則運算,因此必然也是高於三元運算符的。

也就是說await版本的實際執行是這樣子的:

(await true) ? Promise.resolve(123) : Promise.resolve(233) // Promise<123>

那麼咱們想要獲取預期的結果,就須要添加()來告知解釋器咱們想要的執行順序了:

await (true ? Promise.resolve(123) : Promise.resolve(233)) // 123

必定不要漏寫 await 關鍵字

這個其實算不上升級時的坑,在使用co時也會遇到,可是這是一個很嚴重,並且很容易出現的問題。

若是有一個異步的操做用來返回一個布爾值,告訴咱們他是否爲管理員,咱們可能會寫這樣的代碼:

async function isAdmin (id) {
  if (id === 123) return true

  return false
}

if (await isAdmin(1)) {
  // 管理員的操做
} else {
  // 普通用戶的操做
}

由於這種寫法接近同步代碼,因此遺漏關鍵字是頗有可能出現的:

if (isAdmin(1)) {
  // 管理員的操做
} else {
  // 普通用戶的操做
}

由於async函數的調用會返回一個Promise實例,得益於我強大的弱類型腳本語言,Promise實例是一個Object,那麼就不爲空,也就是說會轉換爲true,那麼全部調用的狀況都會進入if塊。

那麼解決這樣的問題,有一個比較穩妥的方式,強制判斷類型,而不是簡單的使用if else,使用相似(a === 1)(a === true)這樣的操做。_eslint、ts 之類的都很難解決這個問題_

一些建議

什麼時候應該用 async ,什麼時候應該直接用 Promise

首先,async函數的執行返回值就是一個Promise,因此能夠簡單地理解爲async是一個基於Promise的包裝:

function fetchData () {
  return Promise().resolve(123)
}

// ==>

async function fetchData () {
  return 123
}

因此能夠認爲說await後邊是一個Promise的實例。
而針對一些非Promise實例則沒有什麼影響,直接返回數據。

在針對一些老舊的callback函數,當前版本的Node已經提供了官方的轉換工具util.promisify,用來將符合Error-first callback規則的異步操做轉換爲Promise實例:

而一些沒有遵照這樣規則的,或者咱們要自定義一些行爲的,那麼咱們會嘗試手動實現這樣的封裝。
在這種狀況下通常會採用直接使用Promise,由於這樣咱們能夠很方便的控制什麼時候應該reject,什麼時候應該resolve

可是若是遇到了在回調執行的過程當中須要發起其餘異步請求,難道就由於這個Promise致使咱們在內部也要使用.then來處理麼?

function getList () {
  return new Promise((resolve, reject) => {
    oldMethod((err, data) => {
      fetch(data.url).then(res => res.json()).then(data => {
        resolve(data)
      })
    })
  })
}

await getList()

但上邊的代碼也太醜了,因此關於上述問題,確定是有更清晰的寫法的,不要限制本身的思惟。
__async也是一個普通函數__,徹底能夠放在任何函數執行的地方。

因此關於上述的邏輯能夠進行這樣的修改:

function getList () {
  return new Promise((resolve, reject) => {
    oldMethod(async (err, data) => {
      const res = await fetch(data.url)
      const data = await res.json()

      resolve(data)
    })
  })
}

await getList()

這徹底是一個可行的方案,對於oldMethod來講,我按照約定調用了傳入的回調函數,而對於async匿名函數來講,也正確的執行了本身的邏輯,並在其內部觸發了外層的resolve,實現了完整的流程。

代碼變得清晰不少,邏輯沒有任何修改。

合理的減小 await 關鍵字

await只能在async函數中使用,await後邊能夠跟一個Promise實例,這個是你們都知道的。
可是一樣的,有些await其實並無存在的必要。

首先有一個我面試時候常常會問的題目:

Promise.resolve(Promise.resolve(123)).then(console.log) // ?

最終輸出的結果是什麼。

這就要說到resolve的執行方式了,若是傳入的是一個Promise實例,亦或者是一個thenable對象(_簡單的理解爲支持.then((resolve, reject) => {})調用的對象_),那麼resolve實際返回的結果是內部執行的結果。
也就是說上述示例代碼直接輸出123,哪怕再多嵌套幾層都是同樣的結果。

經過上邊所說的,不知你們是否理解了 合理的減小 await 關鍵字 這句話的意思。

結合着前邊提到的在async函數中返回數據是一個相似Promise.resolve/Promise.reject的過程。
await就是相似監聽then的動做。

因此像相似這樣的代碼徹底能夠避免:

const imgList = []

async function getImage (url) {
  const res = await fetch(url)

  return await res.blob()
}

await Promise.all(imgList.map(async url => await getImage(url)))

// ==>

async function getImage (url) {
  const res = fetch(url)

  return res.blob()
}

await Promise.all(imgList.map(url => getImage(url)))

上下兩種方案效果徹底相同。

Express 與 koa 的升級

首先,Express是經過調用response.send來完成請求返回數據的。
因此直接使用async關鍵字替換原有的普通回調函數便可。

Koa也並非說你必需要升級到2.x纔可以使用async函數。
Koa1.x中推薦的是generator函數,也就意味着其內部是調用了co來幫忙作轉換的。
而看過co源碼的小夥伴必定知道,裏邊同時存在對於Promise的處理。
也就是說傳入一個async函數徹底是沒有問題的。

可是1.x的請求上下文使用的是this,而2.x則是使用的第一個參數context
因此在升級中這裏多是惟一須要注意的地方,__在1.x不要使用箭頭函數來註冊中間件__。

// express
express.get('/', async (req, res) => {
  res.send({
    code: 200
  })
})

// koa1.x
router.get('/', async function (next) {
  this.body = {
    code: 200
  }
})

// koa2.x
router.get('/', async (ctx, next) => {
  ctx.body = {
    code: 200
  }
})

小結

重構項目是一件頗有意思的事兒,可是對於一些註釋文檔都很缺失的項目來講,重構則是一件痛苦的事情,由於你須要從代碼中獲取邏輯,而做爲動態腳本語言的JavaScript,其在大型項目中的可維護性並非很高。
因此若是條件容許,仍是建議選擇TypeScript之類的工具來幫助更好的進行開發。

相關文章
相關標籤/搜索