🦔

for await of を使うとEslintに怒られるから、Promise.all()を使う

2021/11/05に公開
4

はじめに

TypeScript + Eslint で開発中にfor await ofを使って、非同期関数を同期的にループで実行しようとしたら、Eslintに怒られました。
その際の、解決策として、Promise.all()で解決する方法をご紹介します。[1]

訂正

解決策は、僕の憶測が入ってしまっていたため、適切なものではなかったことが判明したので、修正します。
厳密には、非同期に実行されるべきものを同期的にループで実行しようとしていることそのものをEslintに怒られていました。
以降の記述では、まるでPromise.all()が同期的な処理を行っているような表現をしていますが、Promise.all()は同期的に処理を実行するという記述はどのドキュメントにもありませんでした。m(_ _)m

前提

以下の関数が定義されていることとします。

functions.ts
async function createUser(userId: number, userName: string) {
    ︙
    ︙
}

async function deleteUser(userId: number) {
    ︙
    ︙
}

怒られる例

hogehoge.ts
// 同時に複数のユーザを同期的に作りたいとする
const USER_DATA_LIST = [
    { userId: 1, userName: "User1" }
    { userId: 2, userName: "User2" }
    { userId: 3, userName: "User3" }
    { userId: 4, userName: "User4" }
    { userId: 5, userName: "User5" }
]

for await (const userData of USER_DATA_LIST) {
    await createUser(userData.userId, userData.userName)
}

https://eslint.org/docs/rules/no-await-in-loop
ループ内でそもそもawait使うなって怒られます。

解決策

以下のように記述することで怒られなくなります。

Promise.all

fugafuga.ts

// 同時に複数のユーザを同期的に作りたいとする
const USER_DATA_LIST = [
    { userId: 1, userName: "User1" }
    { userId: 2, userName: "User2" }
    { userId: 3, userName: "User3" }
    { userId: 4, userName: "User4" }
    { userId: 5, userName: "User5" }
]

Promise.all(
    USER_DATA_LIST.map(userData => createUser(userData.userId, userData.userName))
)

Promise.all()は、非同期関数の配列を受け取ります(実態としてはプロミスを返す関数等の配列)。そこで、データ元の配列をmapしつつ、非同期関数を返すようにすることで、スマートに非同期処理の繰り返し実行が可能となります。同期処理ではありません。
スプレッド構文で、イミュータブルにするのも忘れずに。[2]

応用編(誤った記述のため削除しました)

Promise.allでトランザクションっぽいことをしたい、という記述をしていましたが、上記でもあります通り、同期的な実行ではなく非同期的な実行であるため、トランザクションのような記述を簡単に書くことはできません。

番外編

Promise.allSettled

途中でいずれかの関数が失敗しても、残りの関数を実行したい場合は、Promise.allSettled()を使用します。
こちらのメソッドでは、すべての関数が実行完了するのを待って、結果を判定します。

fugahoge.ts

// 同時に複数のユーザを同期的に作りたいとする
const USER_DATA_LIST = [
    { userId: 1, userName: "User1" }
    { userId: 2, userName: "User2" }
    { userId: 3, userName: "User3" }
    { userId: 4, userName: "User4" }
    { userId: 5, userName: "User5" }
]

Promise.allSettled(
    USER_DATA_LIST.map(userData => createUser(userData.userId, userData.userName))
)
    .then(results => {
        console.log(results) // すべての関数から返された結果の配列
    })
    // errorはThrowされず、resultsの中で、エラー理由等が確認できる。

Promise.race

ここまで紹介してきたメソッドはすべて、同期的に関数を実行するものでしたが、非同期でまとめて実行させることもできます。
Promise.race()は、最も早く完了した関数の結果を返します。
ぱっと用途は思いつかないですが、覚えておくとどこかで役に立つかもしれません。

fugehoga.ts

// 同時に複数のユーザを同期的に作りたいとする
const USER_DATA_LIST = [
    { userId: 1, userName: "User1" }
    { userId: 2, userName: "User2" }
    { userId: 3, userName: "User3" }
    { userId: 4, userName: "User4" }
    { userId: 5, userName: "User5" }
]

Promise.race(
    USER_DATA_LIST.map(userData => createUser(userData.userId, userData.userName))
)
    .then(result => {
        console.log(result) // 最も早く終わった関数の結果が返ってくる
    })
    .catch(error => {
	console.log(error) // 最も早くエラーがthrowされた関数のエラーが返ってくる
    })

まとめ

今回は、Promise.all()と関連する、Promise.allSettledPromise.raceについて紹介しました。
ぜひコーディンで採用して、スマートなコードを書いてみてください。

最後まで読んでいただき、ありがとうございました。

脚注
  1. petamorikenさんにご指摘いただき、訂正します。 ↩︎

  2. petamorikenさんにご指摘いただき、削除しました。 ↩︎

Discussion

petamorikenpetamoriken

いくつか気になる点がありますので指摘させてください。

for-await-of について

JavaScript には Iterable と呼ばれるインターフェースがあり、それを満たしているものについてはスプレッド構文や for-of を使ってループ処理をすることが出来ます。配列はそれを満たしているものの一つです。

https://ja.javascript.info/iterable

const arr = [1, 2, 3, 4];

// スプレッド構文でコピーを作る
const copy = [...arr];
console.log(copy); // [1, 2, 3, 4]
console.log(arr === copy); // false

// for-of でループする
for (const val of arr) {
  console.log(val); // 1, 2, 3, 4
}

一方で AsyncIterable というインターフェースもあります。Iterable では値を順に取り出す際に全て同期的に取得することが出来ましたが、逆に全て非同期に取得する場合がこちらです。イメージとしては動画のストリーミング再生で、決まった順番のものを少しずつ取得しにいくような感じでしょうか。こちらは for-await-of でループ処理をします。

最初の悪い例として for-await-of を使われていますが、配列は Iterable ですので for-of と勘違いされているかと思います(Iterable から AsyncIterable へ変換可能なため for-await-of が使えてしまっていますが、普通はそうしません)。

for (const userData of USER_DATA_LIST) {
  await createUser(userData.userId, userData.userName);
}

Promise について

JavaScript の Promise は作った時点で非同期の処理が実行されています。つまり Async Function を実行した時点で既にその処理は走っています。

何故 ESLint がループ内の await を禁止し Promise.all を使うことを勧めるかというと、前者だと直列に、後者だと並行に実行されることになるからです(前者はまさしく AsyncIterable と同じことをしています)。同時に並行に実行できるものはなるべくそうしたほうがパフォーマンスが良くなるため Promise.all を使うことを勧めているわけです。

Promise.all()は、非同期関数の配列を受け取ります。そこで、データ元の配列をmapしつつ、非同期関数を返すようにすることで、スマートに非同期処理の繰り返し実行が可能となります。
いずれかの関数で、errorがThrowされると、それ移行の実行していない関数は実行せず終了します。

つまりこれは誤りです。非同期函数(Async Function)の配列ではなく、非同期函数が実行されその結果を待つ Promise の配列を受け取ります。あくまで全てが成功した、もしくは一つ失敗したタイミングをはかるためのものであって、他のものが実行されなかったり中断されるようなことはありません。

イミュータブルについて

イミュータブルは不変という意味です。そもそも変更できないものについて使われる言葉です。

https://developer.mozilla.org/ja/docs/Glossary/Immutable

スプレッド構文で、イミュータブルにするのも忘れずに。

スプレッド構文ではあくまでコピーを作っているだけですので、イミュータブルという言葉は使われません。

また配列の Array#map というメソッドは配列から新しい配列を作るものになっているため、ここにスプレッド構文はなくていいかなと思います。

Promise.all(
  USER_DATA_LIST.map(userData => createUser(userData.userId, userData.userName))
)

指摘は以上となります。技術記事を書くことはとても素晴らしいことだと思うのでこれからも是非続けていってください。以下の記事が役に立つかもしれません。読んでいただきありがとうございました。

https://zenn.dev/uhyo/articles/technical-articles

takeru0430takeru0430

貴重なご意見ありがとうございます。
改めて調べ直しまして、お手数かと思いますが、不明な点について質問させていただきます。
お時間ありましたらご回答いただきたいです。

また、いただきましたご指摘をもとに、記事の方更新しよう思います。

for-await-of について

MDNのドキュメントを改めて確認しましたが、

for await (variable of iterable) {
  statement
}

とありますように、ただの配列でも理論上は、動作可能かと理解していました。また、Eslint実行外では、通常に動作しているという認識でこれまで使用していました。
暗黙的に、IterableからAsyncIterableへ変換されてしまうという記述はMDNを参照したところ見つかりませんでした。どのドキュメントを参照されたのでしょうか?

ご教授いただきたいのですが、例えばあるデータの配列をもとに繰り返し処理を実行したい場合(かつ同期的に実行したい場合)は、以下のようにfor ofで記述することが可能、ということで僕の理解正しいでしょうか?

async () => {
    for (const userData of USER_DATA_LIST) {
        await createUser(userData.userId, userData.userName)
    }
}

上記の書き方が可能であり正の場合は、僕が記事内で提示している悪い例は、僕が勘違いで書いていますので、訂正させていただきます。

Promise.allについて

こちらも改めてMDNを確認させていただきました。
MDNの文章中には、

拒否の場合
渡されたプロミスのいずれかが拒否された場合、Promise.all は、他のプロミスが解決したかどうかに関わらず、拒否されたプロミスの値で非同期的に拒否されます。

とあるように、確かにご指摘の通り、渡されたプロミスという言葉が使われているので、関数ではなくPromiseが渡されていること理解できました。
この時、

他のプロミスが解決したかどうかに関わらず

というのは、Promise.allでは、Catchされた以外のPromiseがどうなっているのかを把握することができないということで理解正しいでしょうか?

イミュータブルについて

こちらについては、Array.mapに関して僕が仕様を勘違いしていました。
新しい配列を返す場合は、あえてコピーを作成する必要はないですね。
記事内で訂正させていただきました。

返信遅れましたが、ご確認お願いします。

petamorikenpetamoriken

for-await-of について

暗黙的に、IterableからAsyncIterableへ変換されてしまうという記述はMDNを参照したところ見つかりませんでした。どのドキュメントを参照されたのでしょうか?

JavaScript の言語仕様である ECMAScript に記述されています。JavaScript エンジン内部で暗黙的に使われている内部オペレーションの GetIterator にて、第二引数の hint に async を指定した際 CreateAsyncFromSyncIterator が使われていることが確認できます(for-await-of で使われます)。

https://tc39.es/ecma262/#sec-getiterator

ご教授いただきたいのですが、例えばあるデータの配列をもとに繰り返し処理を実行したい場合(かつ同期的に実行したい場合)は、以下のようにfor ofで記述することが可能、ということで僕の理解正しいでしょうか?

はい for-of で記述することが可能です。非同期函数の中の話なので同期的な実行と言えるのかわからないところですが、少なくともその函数は最初に await に到達するまでは同期的に実行されると言えると思います(非同期函数は await 式を使っていない部分は全て同期的に実行されます)。

Iterablefor-await-of を使用した場合については MDN にも記載されていますが、イテレーターリザルトの値が Promise だった場合に違いがあるものになっています。USER_DATA_LISTPromise が含まれない今回の例では for-offor-await-of も結果としては違いはないですね。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_sync_iterables_and_generators

Promise.allについて

Promise.allでは、Catchされた以外のPromiseがどうなっているのかを把握することができないということで理解正しいでしょうか?

その通りです。以下のコードを実行してみるとわかりやすいかもしれません。

// 全て成功(fullfilled)
Promise.all([
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3),
]).then((result) => {
  console.log(result); // [1, 2, 3]
}).catch((error) => {
  console.log(error); // 呼ばれない
});
// 一つ失敗(rejected)
Promise.all([
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.reject(new Error("error")),
]).then((result) => {
  console.log(result); // 呼ばれない
}).catch((error) => {
  console.log(error); // Error("error")
});

なお余談ですが非同期関数内では Promise.all に対しても await することが出来ます。個人的には Promise#thenPromise#catch メソッドを使うよりはこちらを使いますね。

(async () => {
  try {
    const result = await Promise.all([
      Promise.resolve(1),
      Promise.resolve(2),
      Promise.resolve(3),
    ]);
    console.log(result); // [1, 2, 3]
  } catch (error) {
    console.log(error); // この例だと呼ばれない
  }
})();

JavaScript の非同期処理全般については以下の連載記事がかなり詳しいです。入門記事へのリンクもありますので確認してみてください。

https://zenn.dev/qnighy/articles/345aa9cae02d9d

takeru0430takeru0430

すべての質問に対してご丁寧に対応いただきましてありがとうございます!
アウトプットのリスクとして、この記事を閲覧した人に勘違いを与えてしまう可能性がにはあることを改めて認識できました。
より、詳細にドキュメントを調べてから、アウトプットをしていこうと思います!

そもそもの前提として、for await ofがEslintに怒られる原因だと勘違いしていましたが、非同期処理を同期的に繰り返す行為そのものに対して、Eslintは怒ってくれてたということですね。。

そういう意味では、脳死でPromise.allを使おうという方針自体が間違っていると思いますので、この記事の修正をさせていただこうと思います。

改めまして、こんな記事でも丁寧に対応いただきまして本当にありがとうございます。