之前有篇我的 blog 提到過 js 的異步發(fā)展史:從 callback
到 promise
再到 async/await
。async/await
之后的 JS 開始允許我們以一種看似順序執(zhí)行的方式書寫代碼,這讓入門 JS 變得更簡單,但在一些復(fù)雜的場景里比如 for-loop 環(huán)境里,async/await
還是會有不少坑的。
Warm up
開始前,先寫個“菜籃子工程”,getVegetableNum
是本文中最基礎(chǔ)的一個異步函數(shù)——異步獲取蔬菜數(shù)量:
const Basket = {
onion: 1,
ginger: 2,
garlic: 3,
}
const getVegetableNum = async (veg) => Basket[veg];
知識點(diǎn):async
是一個語法糖,表示把結(jié)果包在 Promise 里返回;該異步函數(shù)等價于
function getVegetableNum (veg) {
return Promise.resolve( Basket[veg] );
}
OK,我們再試著異步獲取三種蔬菜的數(shù)量:
const start1 = async () => {
console.log('Start');
const onion = await getVegetableNum('onion');
console.log('onion', onion);
const ginger = await getVegetableNum('ginger');
console.log('ginger', ginger);
const garlic = await getVegetableNum('garlic');
console.log('garlic', garlic);
console.log('End');
}
最后打印結(jié)果如下:
Start
onion 1
ginger 2
garlic 3
End
await in a for loop
OK,前言到此為止?,F(xiàn)實(shí)中開發(fā)中,上述代碼枚舉每一種蔬菜的方式太過冗余,一般我們更傾向于寫個循環(huán)來調(diào)用 getVegetableNum
方法:
const start = async () => {
console.log('Start');
const arr = ['onion', 'ginger', 'garlic'];
for(let i = 0; i < arr.length; ++i>){
const veg = arr[i];
const num = await getVegetableNum(veg);
console.log(veg, num);
}
console.log('End');
}
結(jié)果依舊,這說明在普通的 for 循環(huán)里,程序會等待上一步迭代結(jié)束執(zhí)行 await 后,再繼續(xù)下一步迭代。這個和我們的預(yù)期一致,for 循環(huán)里的 async/await 是順序執(zhí)行的;同理也適用于 while、for-in、for-of 等等形式中。
Start
onion 1
ginger 2
garlic 3
End
await in callback loop
不過,for 循環(huán)還有可以寫成其他形式,如 forEach、map、reduce、filter 等等,這些需要 callback(回調(diào)方法)的循環(huán),似乎就不那么好理解了。
forEach
我們試著用 forEach 代替上面的 for-loop 代碼:
const start = async () => {
console.log('Start');
['onion', 'ginger', 'garlic']
.forEach(async function callback(veg){
const num = await getVegetableNum(veg);
console.log(veg, num);
});
console.log('End');
}
看下方的輸出結(jié)果:顯然亂了,End
比預(yù)期更早出現(xiàn)了。原因很簡單,async/await
只是一種語法糖,而 forEach 并非 promise-aware 語法,它的 transform&compile 是有問題的:callback 直接返回了第一個 await 后的 Promise,而之后的判定,被放在了下一個 tick 里。
Start
End
onion 1
ginger 2
garlic 3
map
使用 map 來觀察 callback 會更加直觀:
const start = async () => {
console.log('Start');
const promises = ['onion', 'ginger', 'garlic']
.map(async function callback(veg) {
const num = await getVegetableNum(veg);
console.log(veg, num);
});
console.log('promises:', promises);
console.log('End');
}
小改了一下代碼,map 執(zhí)行結(jié)果和 forEach 如出一轍;看下方的打印結(jié)果:執(zhí)行完 map 后返回的是一個 Pending 狀態(tài)的 Promise 數(shù)組;而 await 之后的判定,在下一個 microTask 里執(zhí)行(MiroTask 分析見《MacroTask & MicroTask》)
Start
promises: [ Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ]
End
onion 1
ginger 2
garlic 3
filter
再看看 filter,callback 的返回事實(shí)上也是一個 Promise,而 Promise 在條件判斷時為 true,所以這種情況下 filter 的判斷永遠(yuǎn)為真,所以只淺拷貝了一份數(shù)組而已。
const moreThan1 = ['onion', 'ginger', 'garlic']
.filter(async (veg) => {
const num = await getVegetableNum(veg);
return num > 1;
});
//moreThan1 = ['onion', 'ginger', 'garlic']
reduce
最后還有 reduce,下面代碼里的 sum 返回的也是 Promise:但它的 callback 和上述的幾個方法還不一樣,竟然是 promise-aware 的(別問我為什么,就是這么規(guī)定的?。?/p>
const sum = ['onion', 'ginger', 'garlic']
.reduce(async (acc, veg) => {
const num = await getVegetableNum(veg);
return acc + num;
}, 0);
console.log(sum); // Promise { <pending> }
console.log(await sum); // [object Promise]3
我們看看 sum 這個 promise 的判定結(jié)果是[object Promise]3
,很有趣吧。稍微分析一下:
- 在第一次迭代時,callback 里的
acc
是 0——初始值,num
是 1,acc+num
是 2,但由于是 async 函數(shù),返回的是一個 Promise(上面提到過) - 第二個迭代開始,
acc
就一直是 Promise 了,而 Promise+num
的打印結(jié)果是[object Promise]${num}
- 最后一個迭代的
num
是 3, 所以返回的sum
也就成了Promise{ '[object Promise]3' }
reduce 既然是 promise-aware 語法,所以它的問題比上面三個好解決:acc
不是 Promise 嗎?直接利用 await 返回 acc
判定結(jié)果就是了:
const sum = await ['onion', 'ginger', 'garlic']
.reduce(async (acc, veg) => {
const num = await getVegetableNum(veg);
return (await acc) + num;
}, 0);
console.log(sum); // 6
當(dāng)然這個寫法確實(shí)挺難看的。
Promise.all
我們看了上面四種迭代方法——forEach、map、filter、reduce,只要是 callback 使用了async/await
,結(jié)果就不是很靠譜了,所以應(yīng)該盡量避免這種寫法。那怎么改寫呢?可以先把所有異步數(shù)據(jù)一次性取過來,再進(jìn)行后續(xù)循環(huán)操作;批量取數(shù)據(jù)常用的手段就是 Promise.all
:
const fetchNums = (vegs) => {
const promises = vegs.map( getVegetableNum );
return Promise.all( promises );
}
const start = async () => {
console.log('Start');
const nums = await fetchNums( ['onion', 'ginger', 'garlic'] );
console.log(nums); // [1, 2, 3]
// then map, forEach, filter or reduce according to nums
console.log('End');
}
好處還是挺明顯的:
- 從代碼質(zhì)量上來說,符合單一原則,將取數(shù)據(jù)和操作數(shù)據(jù)分開來
- 從性能上來說,循環(huán)里的異步請求是順序執(zhí)行的,而
Promise.all
是并發(fā)執(zhí)行的,速度更快
小結(jié)
今天回顧了 async/await
在循環(huán)語句里的使用方法,對于普通的 for-loop,所有的 await 都是串行調(diào)用的,可以放心使用,包括 while、for-in、for-of 等等;但是在有 callback 的 array 方法,如 forEach、map、filter、reduce 等等,有許多副作用,最好就別使用 await 了。當(dāng)然最優(yōu)解還是 Promise.all
,無論從質(zhì)量上還是效率上都是不二選擇。
相關(guān)
文章同步發(fā)布于an-Onion 的 Github。碼字不易,歡迎點(diǎn)贊。