哥,我好想在 Array 的高階函數前面 await 喔!該怎麼做呢?🥺🥺🥺
你是否有這種經驗:當你把 await 寫在 forEach 前面,結果裡面的東西通通執行起來,你在 await 下面的 console.log 只印出一坨垃圾…
破題
我們老樣子約定一個簡單的、方便驗證的 Promise 函數 — wait。
常見的面試題如下:
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
// 面試官:大門在那邊,
這篇文章就是要來討論一下怎麼會這樣,以及如何優雅解決它。
摘要
當你高階函數(forEach
、map
、…)用習慣之後,往往不想回頭再寫傳統的 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 2console.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 (為了優化效能),這時 map
與 Promise.all
正好絕妙搭配。
但是,其實我覺得這個問題的本身與它的解法蠻有趣的,必須同時了解 Promise 的特性以及 reduce
的操作,也因此讓我這個已經快沒梗的人找到了寫文章的靈感。
在自己的專案中,我們也許可以 hardcode 寫一個超級長的 promise chain,但是在設計一個函式庫時,必須要設計的夠 general ,與此同時就不可避免的需要去設計一個未知長度的非同步陣列。
即使傳統 for loop 仍然能輕鬆 await 解決,但總會覺得:靠腰我前面都寫一堆 forEach、map惹,這邊畫風怪怪的。
終於,今天能在這篇文章中,回答當初自己的疑惑,相當開心。