從今年過完年回來,三月份開始,就一直在做重構相關的事情。
就在今天剛剛上線了最新一次的重構程式碼,希望高峰期安好,接近半年的Node.js程式碼重構。
包含從callback
+async.waterfall
到generator
+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.querySelectorAll
、fetch
已經可以滿足需求了,為什麼還要引入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
例項。
這個是因為yield
、await
兩個關鍵字執行順序不同所導致的。
在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
之類的工具來幫助更好的進行開發。