[Javascript] forEach()はasync/awaitを待たない

May 12, 2021

programming
article cover

awaitが機能しない

とある状況でAPIを複数回叩く必要があるとして、

const delay = () => {
  return new Promise(resolve => setTimeout(resolve, Math.random() * 1500))
}

// Fake API
const API = (ep) => {
  return delay().then(() => console.log(`API [${ep}] is called.`))
}

それをこのようにforEachで順番に処理していきます。

const asyncInForEach = () => {
  console.log('start')
  
  const endpoints = ['endpoint1', 'endpoint2', 'endpoint3', 'endpoint4']
    
  endpoints.forEach(async (ep) => {
    await API(ep)
  })
  
  console.log('end')
}

asyncInForEach()

おそらく次のような出力になる、

start
API [1] is called.
API [2] is called.
API [3] is called.
API [4] is called.
end

と思いきや実際は違います。

screen shot

APIの結果を待たずにforEach()が終了してしまいました。

これはforEach()がasync/awaitをサポートしていないためで、forEach()の中でawaitを使っても正しく動作しません。

解決策

1. for loopを使う

const asyncInForLoop = async () => {
  console.log('start')
  
  const endpoints = ['endpoint1', 'endpoint2', 'endpoint3', 'endpoint4']
  
  for (let i = 0; i < 4; i++) {
    await API(endpoints[i])
  }
  
  console.log('end')
}

asyncInForLoop()

for loopはasync/awaitをサポートしているため、問題なくawaitが機能します。
シンプルでわかりやすいですが、次のPromise.all()はより効率的に処理することができます。

screen shot

2. Promise.all()を使う

Promise.all()は配列のような反復処理可能(iterable)なオブジェクトを引数にとり、

  • 引数の中の全てのPromiseが解決された
  • 引数の中のPromiseが一つでもrejectされた

以上2つのどちらかの条件が満たされた時点で終了します。

これを使うことで並列にAPIを処理することができます。

const asyncWithPromiseAll = async () => {
  console.log('start')
  
  const endpoints = ['endpoint1', 'endpoint2', 'endpoint3', 'endpoint4']
  
  await Promise.all(
  	endpoints.map((ep) => {
      return API(ep)
    })
  )
  
  console.log('end')
}

asyncWithPromiseAll()

screen shot

出力結果を見ると、実行された順番に関係なく出力されており、ちゃんと並列処理されているのが確認できます。

Promise.all()の注意点

さきほど触れたように、Promise.all()では一つでもPromiseがrejectされると終了してしまいます。
その方が都合が良い時もありますが、知らずに使っているとバグの元です。

先ほどの例をもう少し現実的にしてみます。

APIに失敗するケースを足して、

const delay = () => {
  return new Promise(resolve => setTimeout(resolve, Math.random() * 1500))
}

const API = (ep) => {
+  if (ep === 'fail') {
+    return new Promise((_, reject) => {
+      reject(new Error('error on api call'))
+    })
+  }
  
  return delay().then(() => `return from [${ep}]`)
}

まとめて返り値を受け取ります。

const fetchParallel = async () => {
  const endpoints = [
    'endpoint1', 
    'endpoint2', 
    'endpoint3', 
    'endpoint4',
    'endpoint5',
  ]
  
  const result = await Promise.all(
    endpoints.map(ep => API(ep))
  )
    .catch(error => error.message)
  
  console.log(result)
}

fetchParallel()

実行するとしっかりとAPIの返り値がまとめて返ってきます。

// return

[
  "return from [endpoint1]", 
  "return from [endpoint2]", 
  "return from [endpoint3]", 
  "return from [endpoint4]", 
  "return from [endpoint5]"
]

では一つ失敗するエンドポイントを含めてみます。

const fetchParallel = async () => {
  const endpoints = [
    'endpoint1', 
    'endpoint2', 
    'endpoint3', 
    'endpoint4',
    'endpoint5',
    'fail',  // added this
  ]
  
  const result = await Promise.all(
    endpoints.map(ep => API(ep))
  )
    .catch(error => error.message)
  
  console.log(result)
}

fetchParallel()
// return 

"error on api call"

ひとつのAPIがrejectされただけで、他のAPIの返り値も得られなくなりました。

これは一つ一つのPromiseにcatchを追加することで防ぐことができます。

const fetchParallel = async () => {
  const endpoints = [
    'endpoint1', 
    'endpoint2', 
    'endpoint3', 
    'endpoint4',
    'endpoint5',
    'fail',  // added this
  ]
  
  const errorHandler = (e) => {
    // do something
    return null
  }
  
  const result = await Promise.all(
    endpoints.map(ep => API(ep).catch(errorHandler)) 
  )
    .catch(error => error.message)
  
  console.log(result)
}

fetchParallel()

*Promise一つ一つに対するcatchとPromise.all()に対するcatchの違いに注意。

// return 
[
  "return from [endpoint1]", 
  "return from [endpoint2]", 
  "return from [endpoint3]", 
  "return from [endpoint4]", 
  "return from [endpoint5]",
]

これで無事に並列処理でAPIから値を取得することができました。


Profile picture

元カメラマン。今はベルリンで働くWEBエンジニア。
スロバキア人の妻とドイツ猫二匹の多国籍家族。