次世代 Hugo

智能を研ぎ、創造を編む

如何在 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 宣告為 async, f 也成為 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 仍然有等待 inc 的 Promise,但問題是在 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()