見出し画像

[JavaScript] asyncのloop処理をfor文以外で順番に処理する方法

async/awaitとは

非同期処理を同期処理のように書ける文法。
古くは非同期処理はcallbackを渡す方法で書いていた。

myAsyncProcess(function () {
  console.log('Done!')
})

これだといちいちcallback引数を実装しないといけないし、エラーした場合はもう一つcallbackを受け付けるか、callbackにerrorを渡すか実装がバラけるのでPromiseという機構で解決するようになった。

myAsyncProcess()
  .then(function() { console.log('Done!') })
  .catch(function() { console.error('Error') })

これで大分いい感じになったけど、nestが深くなって書きにくい&読みにくいという問題がまだあった。
そこでasync/awaitが登場して同期処理のように書けるようになって、try/catchでerror処理もできるようになった。

// async関数の中にある前提
try {
  await myAsyncProcess()
  console.log('Done!')
} catch (err) {
  console.error('Error')
}

ただ非同期に処理を流しつつ、同期処理を続けていきたい時は依然として.thenで繋げていく方法でもちろん問題ない。

async(非同期)loop処理という難問

例えばstring配列があって、それぞれに絵文字が含まれているかを確認する関数があったとする。この関数自体が最初に呼ばれるまで重い絵文字判定ライブラリを読み込まないような実装だとするとasync loop問題が発生すると思う。

async function detectEmoji(text: string): Promise<boolean> {
  const emojiLib = await import('emoji-lib')
  return emojiLib.detect(text)
}

上記のような関数をstring配列に適用してく場合、loop内で非同期処理の完了を待つ必要がある。

for文だと大体いい感じに書ける

for ofを使えばloop内でのawaitは簡単

for (const text of textList) {
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}

for文の弱点

もしindexを使いたい場合は? for in/for of/forでもできる

for (const i in Object.entries(textList)) {
  const text = textList[i]
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}

for (const [i, text] of Object.entries(textList)) {
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}

for (let i = 0; i < textList.length; i++) {
  const text = textList[i]
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}

for文でも問題ないけど、Object.entries使うの謎とか自分で要素を取り出さないといけないとか従来のforに至っては書くのがめんどくさいまである。

mapで全部のawaitを待てる

for文がいやならarrayメソッドを使えばいいじゃない。ということでmapを使えば全部ぶん回して処理するのはできる。

await Promise.all(textList.map(async (text, i) => {
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}))

mapの弱点

非同期処理をどんどん実行してくだけなので何番目が最初に終わるとかがまったく分からない。先頭から順番に実行されると思ったら大間違いで、かなりランダムになる。

reduceを使うと一つずつawaitできる

かっちり先頭から順番に実行したいなら実はreduceを使うといい感じにできてしまう。

await textList.reduce(async (prev, text, i) => {
  await prev
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  }
}, Promise.resolve())

reduceは前回のloopの結果を最初に引数に渡してくれるのでそれがPromiseなら最初にawaitすればいいという仕組み。

arrayメソッドはbreakできない問題の対処方法

実はarrayメソッドはloopを途中で脱出する方法が備わっていない。だから例外を投げるとか、フラグで処理をスキップするとかという方法が必要になる。でも例外は本当になにかエラーが発生した時と見分けをつけるのが面倒になるのでフラグを使うといいと思う。

// フラグパターン
async function asyncForEach(array, process) {
  let aborted = false
  const abort = () => aborted = true
  await array.reduce(async (prev, item, i) => {
    await prev
    if (aborted) {
      return
    }
    await process(item, i, abort)
  }, Promise.resolve())
}

await asyncForEach(textList, async (text, i, abort) => {
  const emojiExists = await detectEmoji(text)
  if (emojiExists) {
    ...
  } else {
    abort()
  }
})

aborted === trueなら処理を行わずに早期returnすれば実質脱出と同じになる。