4
3

awaitの挙動からforEachがawaitされない理由を紐解く

Last updated at Posted at 2024-07-09

はじめに

async/awaitを使っていて制御の順番が思い通りにならなくて困ったことは誰しもあると思います。
そんな方のために、awaitの挙動からなぜ書いたコードが思い通りの順番で実行されないのか少しだけ深掘りしていきます。
今回はArray.prototype.forEach()編です。
なお、setTimeout()編などほかの記事をご覧になっている場合、内容が重複することがあります。ご了承ください。

目次

  1. async/awaitとは
  2. 待ってくれないawait
  3. awaitはPromiseインスタンスの値を取り出す
  4. forEachは値を返さない
  5. awaitにPromise以外を待たせようとしても待ってくれない
  6. mapとPromise.allを使おう

async/awaitとは

async/awaitとは、非同期処理を行う際に処理の順番をコントロールするために使う記述です。
例えば以下のようにasync関数内でfetch()を行う関数を実行すると、コード上の順番と処理の実行順がずれます。

asyncFunc.js
const asyncFunction = async(url) => {
    const data = await fetch(url).data;
    console.log(data);
}

console.log("start");
asyncFunction(取得したいURL);
console.log("finish");
出力結果
start
finish
(取得したデータ)

取得したデータのコンソールへの出力が最後になりましたね。

待ってくれないawait

このような例から、await式は「非同期処理の完了を待つ」記述だといわれます。
しかし本質的には、「Promiseインスタンスの値を取り出す」という操作が行われています。
これをきちんと理解しないと、「非同期処理なのにawaitが待ってくれない」という残念な現象が発生します。

thisIsBad.js
const targetUrls = [
    "https://jsonplaceholder.typicode.com/todos/1",
    "https://jsonplaceholder.typicode.com/todos/2",
    "https://jsonplaceholder.typicode.com/todos/3"
]

const asyncFunction = async(urls) => {
    console.log("start");
    await urls.forEach(async(url) => {
        const res = await fetch(url);
        const data = res.json();
        console.log(data);
        console.log("データ取得完了");
    })
    console.log("finish");
}

asyncFunction(targetUrls);
出力結果
start
finish
(todos/1の取得したデータ)
データ取得完了
(todos/2の取得したデータ)
データ取得完了
(todos/3の取得したデータ)
データ取得完了

forEach()のコールバック関数内部では確かにawaitがされていてデータの取得→データの出力→"データ出力完了"の出力の順になっていますが、start→finish→urls.forEach()という順番になってしまい、どうやらforEach()の繰り返し処理が終了するのをawaitが待ってくれなかったようです。
なぜなのか順に説明していきます。

awaitはPromiseインスタンスの値を取り出す

前回の記事と全く同じ内容になります。

awaitの機能をもう少し正確に記述すると、それは 「右にあるPromiseの確定した値を評価する(取り出す)」 という機能になります。
ここで一つ押さえておきたいのがPromiseについてです。
Promiseインスタンスは3つの State(状態) を持ち、それぞれ

  • Pending(待機状態)
  • Fulfilled(履行状態)
  • Rejected(拒否状態)

があります。Pendingはその名の通り、まだ実行されていない状態ですが、FulfilledとRejectedは値が確定し、これ以上変わらなくなります。
例えばそれは、fetch()で正しくデータを取得できた、あるいはできずに例外が発生した時だとか、Promise.resolve()Promise.reject()されたときなどです。
awaitはPromiseインスタンスがFulfilledやRejectedになって確定した値を評価しよう(取り出そう)と「待つ」わけです。

forEachは値を返さない

実は、forEachは値を返しません。正確にはundefinedが返ってきます。

setTimeout.js
const foo = [1, 2, 3].forEach((element) => element)
console.log(foo);
出力結果
undefined

このように配列の値をそのまま返す処理を行っても、fooの値は[1, 2, 3]ではなく、undefinedとなってしまっています。
そうです。forEach()コールバック関数でどんな処理をしようとundefinedを返すのです。

awaitにPromise以外を待たせようとしても待ってくれない

Promiseの確定した値を取り出すために、PromiseインスタンスがFulfilledやRejectedになるのを待つawaitですが、Promiseではなく、ほかの値を渡すとどうなるでしょうか。
それが、先ほどの例になります。

thisIsBad.js
const targetUrls = [
    "https://jsonplaceholder.typicode.com/todos/1",
    "https://jsonplaceholder.typicode.com/todos/2",
    "https://jsonplaceholder.typicode.com/todos/3"
]

const asyncFunction = async(urls) => {
    console.log("start");
    await urls.forEach(async(url) => {
        const res = await fetch(url);
        const data = res.json();
        console.log(data);
        console.log("データ取得完了");
    })
    console.log("finish");
}

asyncFunction(targetUrls);
出力結果
start
finish
(todos/1の取得したデータ)
データ取得完了
(todos/2の取得したデータ)
データ取得完了
(todos/3の取得したデータ)
データ取得完了

そう、awaitは右にある値がPromiseインスタンスではない場合、即座に値を取り出してしまいます。
そして、forEach()は実行されるとコールバック関数内での処理にかかわらず、undefinedを返すんでしたよね。
よって、forEach()をawaitすると、awaitはundefinedforEach()からの値だと判断して評価を終了し、もう終わった処理扱いしてしまうのです。
そして、thisIsBad.jsのようにほかの処理がない場合、await以降の処理の開始がforEach()内のコールバック関数内の処理の完了よりも先になってしまうのです。

Array.mapやPromise.allをうまく使おう

awaitを用いて非同期的に配列を用いた繰り返し処理がしたいのであれば、Arrayのプロトタイプメソッドであるmap()やPromiseのプロトタイプメソッドであるall()をうまく使いましょう。
map()メソッドは配列のすべての要素に対してコールバック関数内の処理をしたうえで、処理を行った後の配列を返します。

mapExample.js
const foo = [1, 2, 3].map((element) => element + 1);
console.log(foo);
出力結果
[2, 3, 4]

そして、Promise.all()はPromiseインスタンスを返すメソッドです。
引数に受け取った配列(正確にはイテラブルなオブジェクト)内のすべてのPromiseインスタンスがFulfilledになった時に、自身が返すPromiseインスタンスをFulfilledにします。

allExample.js
const promises = [
    fetch("https://jsonplaceholder.typicode.com/todos/1"),
    fetch("https://jsonplaceholder.typicode.com/todos/2"),
    fetch("https://jsonplaceholder.typicode.com/todos/3")
];

Promise.all(promises).then(
    (array) => console.log(`${array.length}個のデータを取得しました`)
)
出力結果
3個のデータを取得しました

なお、細かいことを言うと、Promise.all()の返す値は引数に受け取ったPromiseの履行値の配列です。
よって、次のthen()が受け取る値はその値であることに注意してください。

ご説明した二つのメソッドをうまく利用すれば、きちんとawaitされる配列の繰り返し処理ができそうですね。
以下はコードの例です。

thisIsGood.js
const targetUrls = [
    "https://jsonplaceholder.typicode.com/todos/1",
    "https://jsonplaceholder.typicode.com/todos/2",
    "https://jsonplaceholder.typicode.com/todos/3"
]

const asyncFunction = async(urls) => {
    console.log("start");
    const promises = urls.map(url => 
        fetch(url).then(data => 
            data.json()
        )
    )
    await Promise.all(promises).then(datas => {
        datas.forEach(data => {
            console.log(data)
            console.log("データ取得完了")
        })
    })
    console.log("finish");
}

asyncFunction(targetUrls);
出力結果
start
(todos/1の取得したデータ)
データ取得完了
(todos/2の取得したデータ)
データ取得完了
(todos/3の取得したデータ)
データ取得完了
finish

このコード内で用いられているforEach()はawaitされていない、つまり非同期処理とは関係なく使っていることに注意してください。
本質はmap()でPromiseの配列を作成し、それがすべて解決するのをPromise.all()で待つということであり、そのPromise.all()自体をawaitすることで非同期処理部分をまとめて順序制御しています。

このようにして、Promiseの配列をmap()で作成し、その中の処理がすべて完了したのをPromise.all()で確認してから、得られたデータを使った処理をするようにすると、このように意図したとおりの順番で処理が行われます。
もちろん今度はawaitでPromiseインスタンスを待っているので一連の処理が終わるまで、finishは出力されませんね。

4
3
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3