點燈坊

失くすものさえない今が強くなるチャンスよ

如何在 forEach 內使用 Asynchronous Function 計算 Sum ?

Sam Xiao's Avatar 2021-08-04

ECMAScript 2015 最大特色是將 Promise 定為語言標準,以 Promise 取代 Callback,當在 forEach 內使用 ECMAScript 2017 的 Asynchronous Function 時,會有意想不到結果。

Version

ECMAScript 2017

Asynchronous in For Loop

let data = [1, 2, 3]

let inc = async x => x + 1

let f = async a => {
  let sum = 0

  for (let x of a)
    sum = sum + await inc (x)

  return sum
}

f (data) // ?

第 3 行

let inc = async x => x + 1

ES2017 引進了 async await,只要在 function 名稱前加上 async,則回傳為 Promise,而非單純 Number。

第 8 行

for (let x of a)
  sum = sum + await inc (x);

使用 for loop 調用 inc 累加至 sum

因為 inc 為 async function 回傳 Promise,需使用 await 解開 Promise。

第 5 行

let f = async a => {
  ...
}

因為 f 內使用了 await 解開 Promise,ES6 規定在 function 前面要宣告為 async 成為 async function 再次包進 Promise 回傳。

儘管因為 inc 宣告為 asyncf 也成為 async function,但整體思維與寫法還是與 sync function 很像,這就是 async await 魅力。

async000

Asynchronous in forEach

let data = [1, 2, 3]

let inc = async x => x + 1

let f = a => {
  let sum = 0
  a.forEach (async x => sum = sum + await inc (x))
  return sum
}

f (data) // ?

第 7 行

a.forEach (async x => sum = sum + await inc (x))

隨著 Array.prototype 自帶 forEach 後,由於可搭配 ES6 的 arrow function,因此越來越多人使用 forEach 取代 for of loop。

由於 await 是寫在 arrow function 內,因此 arrow function 也要宣告為 async

async001

但實際執行卻為非預期的 0,why not ?

第 7 行

a.forEach (async x => sum = sum + await inc (x))

forEach 為 sync function,會一行一行在 stack 執行,這沒問題。

但其 callback 為 async function,根據 event loop model,該 callback 並不會立即執行,而是先擺到 callback queue 排隊,等全部 sync function 都執行完後,才會執行 callback queue 中的 async function 。

第 8 行

return sum

此為 synchronous 會先執行,但因為 callback quene 中所有的 async function 皆還未執行,因此 sum 仍然為初始值 0

Q:所以 await 沒有等待 inc 的 Promise 嗎 ?

await 仍然有等待 incPromise,但問題是在 callback queue 執行 async function 時才 await 等待 Promise,此時 return sum 早已執行過了,所以 sum 還是 0,因此 await 等待也沒用。

syncForEach

let data = [1, 2, 3]

let inc = async x => x + 1

Array.prototype.syncForEach = function (f) {
  for (let i = 0; i < this.length; i++)
    f (this [i], i, this)
}

let f = a => {
  let sum = 0
  a.syncForEach (async x => sum = sum + await inc (x))
  return sum
}

f (data) // ?

Q:之前這樣解釋還是似懂非懂,可以用 code 解釋嗎 ?

第 5 行

Array.prototype.syncForEach = function (f) {
  for (let i = 0; i < this.length; i++)
    f (this [i], i, this)
}

forEach 其內部實作相當於 syncForEach,是封裝 for loop 的 higher order function。

但這裡有個問題,因為 inc 是 async function,導致傳入 f 成為 async function,但為什麼 f 之前沒加 await ? 這導致 syncForEach 成為在 synchronous 去執行 asynchronous f,所以結果是錯的。

async002

asyncForEach

let data = [1, 2, 3]

let inc = async x => x + 1

Array.prototype.asyncForEach = async function (f) {
  for (let i = 0; i < this.length; i++)
    await f (this [i], i, this)
}

let f = async a => {
  let sum = 0
  await a.asyncForEach (async x => sum = sum + await inc (x))
  return sum
}

f (data) // ?

第 5 行

Array.prototype.asyncForEach = async function (f) {
  for (let i = 0; i < this.length; i++)
    await f (this [i], i, this)
}

自行實作 asyncForEach,在 f 前加上 await,也在 function 前加上 async 成為 async function,這使得 asyncForEach 是在 asynchronous 去執行 asynchronous f

10 行

let f = async a => {
  let sum = 0
  await a.asyncForEach (async x => sum = sum + await inc (x))
  return sum
}

如此除了 asyncForEach 的 callback 要加上 async await 外,連 asyncForEach 前也要加上 await,最後使 f 成為 asynchronous function,如此所有的 function 都在 asynchronous 下,不再有 sync function 內執行 async function 問題。

async003

Promise.all

let data = [1, 2, 3]

let inc = async x => x + 1

let f = a => 
  Promise
    .all (a.map (inc))
    .then (x => x.reduce ((a, x) => a+=x, 0))

f (data) // ?

與其使用 for loop 或 asyncForEach,其實有更 FP 與 Promise 寫法。

改用 map 使用 inc,結果為 Array 中一堆 Pending Promise,再使用 Promise.all 使 Array 中的 Pending Promise 成為 Fulfilled Promise。

也由於 Promise.all 回傳為 Promise,可用 then 直接修改 Promise 內部資料並回傳新 Promise,在 then 使用 reduce 直接計算其 sum 回傳。

我們可發現整個過程都在 Promise 內處理,沒透過 await 轉成一般值,也沒透過 Imperative 的 for loop 處理,完全使用 FP 的 higher order function 與 pure function 處理,這才是 Promise 初衷。

async004

Conclusion

  • 實務上避免在 Array.prototype 下的 method 使用 async await,因為 Array.prototype 下的 method 皆為 sync function,也就是 forEach 其實就是 syncForEach,會先執行完最後才執行 callback 內的 async function,導致結果不如預期,此時應該簡單使用 for of loop,就不會有 callback 為 async function 延後執行問題
  • 結論是要避免在 sync function 內執行 async function,forEach 本質是 syncForEach,因此執行 async inc 會有問題,除非使用 asyncForEach,這也是為什麼 ES6 規定只要使用到 await,所有的 function 都要搭配 async 成為 async function,因為這樣才能避免在 sync function 內執行 async function
  • Promise 原本的設計是希望你用 higher order function + pure function 直接修改 Promise 內部值,如此可避開 ECMAScript 複雜的 asynchronous 機制,也不必在 Promise 與 await 之中切換

Reference

Sebastien Chopin, JavaScript: async/await with forEach()