async語法升級踩坑小記

賈順名發表於2018-09-28

從今年過完年回來,三月份開始,就一直在做重構相關的事情。
就在今天剛剛上線了最新一次的重構程式碼,希望高峰期安好,接近半年的Node.js程式碼重構。
包含從callback+async.waterfallgenerator+co,統統升級為了async,還順帶推動了TypeScript在我司的使用。
這些日子也踩了不少坑,也總結了一些小小的優化方案,進行精簡後將一些比較關鍵的點,拿出來分享給大家,希望有同樣在做重構的小夥伴們可以繞過這些。

為什麼要升級

首先還是要談談改程式碼的理由,畢竟重構肯定是要有合理的理由的。
如果單純想看升級相關事項可以直接選擇跳過這部分。

Callback

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

// 普通的回撥函式呼叫
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的問題,不會出現一個函式前邊有二三十個空格的縮排。
但是這樣的流程控制在某些情況下會讓程式碼變得很詭異,例如我很難在某個函式中選擇下一個應該執行的函式,而是隻能按照順序執行,如果想要進行跳過,可能就要在中途的函式中進行額外處理:

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佔位符。

所以callback這種形式的,一定要進行修改, 這屬於難以維護的程式碼

Generator

實際上generator是依託於co以及類似的工具來實現的將其轉換為Promise,從編輯器中看,這樣的程式碼可讀性已經沒有什麼問題了,但是問題在於他始終是需要額外引入co來幫忙實現的,generator本身並不具備幫你執行非同步程式碼的功能。
不要再說什麼async/await是generator的語法糖了

因為我司Node版本已經統一升級到了8.11.x,所以async/await語法已經可用。
這就像如果document.querySelectorAllfetch已經可以滿足需求了,為什麼還要引入jQuery呢。

所以,將generator函式改造為async/await函式也是勢在必行。

期間遇到的坑

callback的升級為async/await其實並沒有什麼坑,反倒是在generator + co 那裡遇到了一些問題:

陣列執行的問題

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之類的工具來幫助更好的進行開發。

相關文章