從今年過完年回來,三月份開始,就一直在作重構相關的事情。
就在今天剛剛上線了最新一次的重構代碼,但願高峯期安好,接近半年的Node.js代碼重構。
包含從callback
+async.waterfall
到generator
+co
,通通升級爲了async
,還順帶推進了TypeScript
在我司的使用。
這些日子也踩了很多坑,也總結了一些小小的優化方案,進行精簡後將一些比較關鍵的點,拿出來分享給你們,但願有一樣在作重構的小夥伴們能夠繞過這些。
首先仍是要談談改代碼的理由,畢竟重構確定是要有合理的理由的。
若是單純想看升級相關事項能夠直接選擇跳過這部分。javascript
從最原始的開始提及,期間確實遇到了幾個年代久遠的項目,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
是依託於co
以及相似的工具來實現的將其轉換爲Promise
,從編輯器中看,這樣的代碼可讀性已經沒有什麼問題了,可是問題在於他始終是須要額外引入co
來幫忙實現的,generator
自己並不具有幫你執行異步代碼的功能。
不要再說什麼async/await是generator的語法糖了 express
由於我司Node
版本已經統一升級到了8.11.x
,因此async/await
語法已經可用。
這就像若是document.querySelectorAll
、fetch
已經能夠知足需求了,爲何還要引入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, ...]
這個通常來講遇到的機率不大,可是若是真的遇到了而栽了進去就欲哭無淚了。
首先這樣的代碼在執行上是沒有什麼區別的:
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
這個其實算不上升級時的坑,在使用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
的包裝:
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
只能在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
是經過調用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
之類的工具來幫助更好的進行開發。