哥,我好想在 Array 的高階函數前面 await 喔!該怎麼做呢?🥺🥺🥺

你是否有這種經驗:當你把 await 寫在 forEach 前面,結果裡面的東西通通執行起來,你在 await 下面的 console.log 只印出一坨垃圾…

realdennis
8 min readSep 27, 2019

破題

我們老樣子約定一個簡單的、方便驗證的 Promise 函數 — wait。

就是封裝 setTimeout 之等幾秒的非同步函數,如果看過我其他文章的對此應該不陌生。

常見的面試題如下:

const arr = [1,3,5,2];請`依序`將對應的元素 i,丟進去 wait 函數,一秒後印出 1 second pass,然後再過三秒後印出3 seconds pass...直到全部結束後印出 done ,全部印完時長約為 10 秒。

這時你發現丟到 forEach 裡頭會怪怪的。

await arr.forEach( async sec => await wait(sec) )
console.log('done')
// done
// 1 seconds pass
// 2 seconds pass
// 3 seconds pass
// 5 seconds pass
// 面試官:大門在那邊,

這篇文章就是要來討論一下怎麼會這樣,以及如何優雅解決它

摘要

當你高階函數(forEachmap、…)用習慣之後,往往不想回頭再寫傳統的 for loop,一來是 callback 改回去真 D 麻煩二來是這種高階遍尋往往令人感到務必安全感,問題是偏偏處理非同步函數時,它處理起來確實非常麻煩。

本文想透過三種最破爛的方法告訴你,如何順順利利解決一組非同步操作。

至於為什麼會感到安全感,我有機會再發一篇文章解釋解釋。

定位問題

這類 Array 與 Promise 同時出現的 case 往往會發生在一系列的非同步操作裡頭(廢話?),這類的一組非同步操作又分為以下兩種看法

  • 必須有順序的 (serialization 、 order-concern)
  • 不在乎順序的(concurrent、parallel)

這點很重要,因為當你各位啊,開始瘋狂使用 await 之後,常常會搞的順序都卡死惹,還以為在寫同步語言呢。這種 case 有機會我會再發一篇文章說明。

回到上面的面試題

await arr.forEach( async sec => await wait(sec) )
console.log('done')
// done
// 1 seconds pass
// 2 seconds pass
// 3 seconds pass
// 5 seconds pass

問題一、為什麼 done 會先跑出來?

await 只會去看後面是不是回傳 Promise ,才會去等,顯然 forEach 並不是 Promise 啦!

雖然裡頭跑的是 async ,但那僅是丟進去 forEach 裡頭的 callback 喲)

問題二、為啥沒有按照順序呀,而且總共好像才花五秒左右?

forEach 所做的是只是一個一個抽出來丟到 callback 罷了,他們之間的回呼沒有等待的關係喲。

// 你以為的[  口     口    口    口  ]
-> cb
-> cb
-> cb
-> cb
// 實際上[
(1s) 口 -> cb
(3s) 口 -> -> -> cb
(5s) 口 -> -> -> -> -> cb
(2s) 口 -> -> cb
]
// 結果根本沒依序

解法一、傳統的 for loop 解

for(let i of arr){
await wait(i)
}
console.log('done')

傳統的 for loop 之所以能解決,是因為它解決了本質上的問題,也就是不透過高階函數去丟入 Callback 這種事,直接 await 一波,不要慫、就是幹。這其實是我另一篇關於 Promise 的文章的解法。

// 這樣的做法大概就是這種感覺[  口     口    口    口 ]
-> cb
-> cb
-> cb
-> cb

哥,說好的在 Array 的高階函數前 await 呢!該怎麼做呢?🥺🥺🥺

解法二、高階函數 reduce 解

我們剛說了,forEach 的 callback 之間沒有啥互相牽制的關係,但是 reduce 就不一樣了,reduce 的 callback 所 return 的變數,會成為下一個 callback 所拿到的參數。另外最後 reduce 的結果會 assign 出去。

如果你不熟 reduce 在這邊幫你複習一下,一個累加的概念記得一下:

const sum = [1,3,5,2].reduce((prev,next)=>{
console.log(prev, next);
return prev+next;
})
// 1 3
// 4 5
// 9 2
console.log(sum);
// 11

那麼既然可以累加並丟給下一位,我們是不是也能丟一個…Promise 讓下一個人去 await 呢?

很好,這個想法就對了!

[1,3,5,2].reduce( async (_prev,next) => {
const prev = await Promise.resolve(_prev);
prev!=='DO_NOT_CALL' && await wait(prev);
await wait(next);
return Promise.resolve("DO_NOT_CALL");
});

第一輪會 return 一個 DO_NOT_CALL 的 Promise,並等待 1 、 3 執行完,

第二輪會等待第一輪的 1 、 3 執行完之後 解掉該輪第一個 await 並且拿到 DO_NOT_CALL 與 5

第三輪會等待第二輪的 5 執行完之後 解掉該輪第一個 await 並且拿到 DO_NOT_CALL 與 2

第三輪走完,大功告成!那最後一個DO_NOT_CALL跑去哪了呢

註記:你可以完全不會回傳任何 DO_NOT_CALL 字串,這邊只是為了教學方便,你也可以什麼都不回傳,檢查 undefined 即可。

還記得最後結束那個 done 嗎?還記得 reduce 跑完之後會 assign 給誰嗎

await [1,3,5,2].reduce( async (_prev,next) => {
const prev = await Promise.resolve(_prev);
prev!=='DO_NOT_CALL' && await wait(prev);
await wait(next);
return Promise.resolve("DO_NOT_CALL");
});
console.log('done')

最上面的 await 會等到最後一個輪次的DO_NOT_CALL 被 resolve 之後才會往下走,並且去執行 console.log

// 實際上[  口      口        口        口   ]
-> cb -> cb(等) -> cb(等) -> cb(等) // 同一時間
// 等啥?等前一個 resolve 內
// 前一個何時 resolve? 他的前一個resolve...

Another Case — 可以平行執行的 Case

許多時候我們的非同步操作不需要互相等待,往往可以分開去非同步執行。

比如我改個題目。

const arr = [1,3,5,2];// 請將對應的元素 i,丟進去 wait 函數,全部印完時長約 5 秒。
// 順序為1、2、3、5.
// 並且在結束後印出done

不過 forEach 仍然沒什麼卵用,因為我們沒有最後全部跑完的 Hook 可以用。

換個想法,我們可以把這組數字陣列 mapping 成一組 Promise 函數

arr.map( num => wait(num));
// [Promise,Promise,Promise,Promise]

並且透過 Promise 的 all 方法來做 parallel 。

await Promise.all(arr.map( num => wait(num)));console.log('done')

尾聲

退一百步說,其實我自己本身在 Coding 時也很少遇到這類的題目,往往比較多的是讓不同的非同步操作彼此 parallel (為了優化效能),這時 mapPromise.all 正好絕妙搭配。

但是,其實我覺得這個問題的本身它的解法蠻有趣的,必須同時了解 Promise 的特性以及 reduce 的操作,也因此讓我這個已經快沒梗的人找到了寫文章的靈感。

在自己的專案中,我們也許可以 hardcode 寫一個超級長的 promise chain,但是在設計一個函式庫時,必須要設計的夠 general ,與此同時就不可避免的需要去設計一個未知長度的非同步陣列。

即使傳統 for loop 仍然能輕鬆 await 解決,但總會覺得:靠腰我前面都寫一堆 forEach、map惹,這邊畫風怪怪的。

終於,今天能在這篇文章中,回答當初自己的疑惑,相當開心。

--

--