2018年已經到了5月份,
node
的4.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
}
}
複製程式碼
這樣的修改就會讓getAvatar
與getUserInfo
內部的程式碼同時執行,同時傳送兩個請求,在外層通過包一層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()
複製程式碼
這樣的執行好像並沒有什麼問題,我們也會得到1
、2
、3
三條log
的輸出,
但是當我們在await getUsersInfo()
下邊再新增一條console.log(`done`)
的話,就會發現:
我們會先得到done
,然後才是三條uid
的log
,也就是說,getUsersInfo
返回結果時,其實內部Promise
並沒有執行完。
這是因為forEach
並不會關心回撥函式的返回值是什麼,它只是執行回撥。
不要在普通的for、while迴圈中使用await
使用普通的for
、while
迴圈會導致程式變為序列:
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
函式編寫的幾個小提示:
- 使用
return Promise.reject()
在async
函式中丟擲異常 - 讓相互之間沒有依賴關係的非同步函式同時執行
- 不要在迴圈的回撥中/
for
、while
迴圈中使用await
,用map
來代替它