for 循環(huán)里的 await

之前有篇我的 blog 提到過 js 的異步發(fā)展史:從 callbackpromise 再到 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)贊。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容