TECHNICAL BLOG

2022/5/30 # Node.js # JavaScript 2022/5 async/awaitから理解するJavaScript非同期処理

最近身の回りにJavaScriptを使う人が増えています。フロントエンドエンジニア(自称)としては嬉しい限りです。そんな中、非同期処理について聞かれることが多いので、初学者にもなるべくわかりやすく説明してみたいと思います。

インターネットでJavaScriptの非同期処理について検索すると、コールバック、Promise、async/await...というように、難しい話がたくさん出てきて混乱すると思います。

混乱した人

非同期処理についてはコールバック関数、Promise、async/awaitの順番で学んでいくのが普通ですが、コールバック関数、Promiseは少し難しいので、以下の順番で説明していこうと思います。

  1. async/await
  2. コールバック関数
  3. Promise
  4. 非同期処理tips

async/await

まず先に非同期処理の概念について説明したいところですが、後輩がとてもわかりやすく説明してくれているのでここでは割愛します。
「非同期処理とは何か」から知りたい方は『2021/9 新人が知らない技術を身に着けるまで(Node.js非同期処理編)』を先に読んでみてください。

それではいよいよ説明に入りますが、まずはコールバック関数、Promiseのことは一旦忘れてください。 JavaScriptの非同期処理はasync/awaitだけだと思いこみましょう。

async/awaitは以下のようにして使います。

async function getUserAccount() {
    ...
    // 非同期処理
    const response = await fetch('https://example.com/user');
    ...
}

ここで出てくるfetchはサーバにリクエストを送信する非同期関数です。そのため、普通はfetchを実行してもレスポンスが返ってくるのを待たずに次の行を実行します。

fetchの前にawaitをつけることで、fetchの処理が最後まで完了してから次の行を実行させることができます。つまり、awaitをつけると上から下へ一行ずつ処理を終わらせてから進んでいきます。非同期処理は処理の順番がややこしくなりますが、その順番を意識しなくていいので楽ですね。

awaitしたいときは今書いている関数にasyncをつけてあげる必要がありますので、async/awaitはセットで使います。

どのタイミングでasync/awaitを使うかがわからないという人もいると思います。とりあえず、非同期関数が出てきたらawaitを使う、awaitを使いたいからasyncをつける、と覚えておきましょう。

そして、asyncがついた関数も非同期になります。そのため、以下のlog関数は期待通りに動作しません。

async function getUserAccount() {
    ...
    // 非同期処理
    const response = await fetch('https://example.com/user/1');
    if (!response.ok) {
        return 'ユーザアカウント情報を取得できませんでした';
    }
    return 'ユーザアカウント情報を取得できました';
}

function log() {
    // ユーザアカウント情報を取得
    const message = getUserAccount();
    console.log(message);
}

log();
// Promise?{<pending>}

getUserAccountasyncをつけたことによって非同期処理となっているので、getUserAccount()を実行する際もawaitをしないと処理が終わる前に次の行のconsole.logを実行してしまいます。

getUserAccountの処理が完了してから次を実行してほしいため、log関数を以下の様に修正します。

// getUserAccountをawaitしたいのでasyncを前につける
async function log() {
    // getUserAccountは非同期関数のため、awaitする
    const message = await getUserAccount();
    console.log(message);
}

log()
// ユーザアカウント情報を取得できませんでした
// もしくは
// ユーザアカウント情報を取得できました

繰り返しますが、非同期関数が出てきたらasync/awaitをつける、asyncをつけた関数も非同期関数になると覚えておきましょう。

コールバック関数

async/awaitは新しい書き方ということもあり見た目もスッキリしていて実装も簡単ですが、非同期処理の歴史はコールバック関数から始まりました。

コールバック関数とは、以下のような関数に渡す関数のことです。

function hello(callbackFunction) {
    console.log('hello');
    callbackFunction();   // 引数で受け取った関数(コールバック関数)の実行
}

function world() {
    console.log('world');
}

hello(world);  // 関数helloの引数として関数worldを渡している。

関数に関数を渡すことに違和感を覚える人もいると思います。JavaScriptではオブジェクトや配列と同様に、関数にもFunction型というデータ型があります。つまり、関数も「値」として扱われるので問題がないということになります。

ところで、コールバックという名前の由来はあまり気にしないほうがいいかもしれません。僕は調べてもあまりイメージが掴めなかったので、「呼ぶ(call)のが後になる(back)関数」と勝手に解釈しています。

先ほどのサンプルコードでは、コールバック関数をworldとして定義していましたが、普通は以下のように事前に定義せずに書くことが多いです。

function hello(callbackFunction) {
    console.log('hello');
    callbackFunction();
}

hello(() => {
    console.log('world');
});

関数helloを呼び出すときに実行してほしい関数を定義する方法です。これは定義した関数に名前をつけないので無名関数と呼ばれます。また、無名関数の中でも関数定義に矢印のような=>を使っている関数はアロー関数といいます。

無名関数や関数定義については本題ではないので詳しい説明は省略しますが、とても一般的な書き方なので慣れましょう。
これ以降のサンプルコードで出てくるコールバック関数はこちらのやり方で定義していきます。

上のほうでコールバック関数について紹介したサンプルコードには非同期処理がありませんが、コールバック関数は多くの場合、非同期処理と一緒に使われます。

そのため、非同期処理について調べると必ずと言っていいほどコールバック関数の話が出てきますが、これによって非同期処理とコールバック関数を混同してしまいやすくなります。
そのため、まずは非同期処理を切り離してしっかりコールバック関数を理解するようにしましょう。

紙を切断する侍

コールバック関数と非同期処理

今のようにasync/awaitという便利な機能はなかったので、昔は非同期処理が完了してから実行したい処理をコールバック関数として渡していました。

例えば、コールバック関数を使う非同期処理で有名なものの一つに、setTimeoutがあります。

setTimeout(コールバック関数, ミリ秒);

第二引数で指定した時間の経過後、第一引数に指定したコールバック関数を実行(正確にはキューへ登録)します。

以下はログ出力のサンプルコードです。「2」はsetTimeoutを使って5秒後に出力するようにしています。

console.log('1');
setTimeout(() => {
    console.log('2');
}, 5000);
console.log('3');

もしsetTimeoutが非同期処理ではなかった場合、以下の結果になるでしょう

1
(5秒後...)
2
3

ですが、実際はsetTimeoutは非同期処理なので以下の結果になります。

1
3
(5秒後...)
2

もう一つ別の例を紹介します。以下はNode.jsのfs.readFileという関数で、指定のファイルを読み込む非同期処理です。読み込み完了後、第二引数に渡したコールバック関数を実行します。

const result = fs.readFile("./example.txt", (error, data) => {
    if (error) {
        return 'error';
    } else {
        return 'success';
    }
});
console.log(result);
// undefined

ファイル読み込みの結果によってログの出力内容を切り替えるコードです。ファイルの読み込みが失敗したら「error」の文字列、成功したら「success」の文字列をresultに代入しようとしています。しかし、fs.readFileは非同期処理なので処理完了前にconsole.log(result)を実行してしまい、期待通りの動きにはなりません。

この例のように、非同期処理の結果がその後の処理に影響するケースが、コールバック関数を使った非同期処理の難しいポイントではないかなと個人的に考えています。

僕が初学者だった頃はこの問題をどう解決すればいいのかわかりませんでした。

わかる人にとっては当たり前なことですが、この例のように非同期処理の結果に依存する処理はすべてコールバック関数内に記述してあげる必要があります。

fs.readFile("./example.txt", (error, data) => {
    if (error) {
        console.log('error');
    } else {
        console.log('success');
    }
});
// error もしくは success

コールバック関数と非同期処理についてここまで説明してきましたが、イメージは掴めたでしょうか。改めて要点をまとめておきます。

  • まずは非同期処理と切り離してコールバック関数を理解する
  • 非同期処理の実行順序は同期処理とは異なるので注意
  • 非同期処理の結果に依存する処理はコールバック関数の中に記述する

Promise

コールバック型の非同期処理では処理結果に依存する処理はコールバック関数の中に記述します。もし、そのコールバック関数の中に非同期処理が含まれていて、さらにそのコールバック関数にも非同期処理が...といった状況だとどうなるでしょう。

fs.readFile('example1.txt', (error, data1) => {
    fs.readFile('example2.txt', (error, data2) => {
        fs.readFile('example3.txt', (error, data3) => {
            fs.readFile('example4.txt', (error, data4) => {
                // do something
            })
        })
    })
 })

いわゆるコールバック地獄と呼ばれる問題です。これは極端な例かもしれませんが、ネストが深くて可読性が悪い上、メンテナンスも大変です。このコールバック型非同期処理の問題を解決するべく登場したのがPromiseです。

概要

ただでさえ非同期処理は難しいのに、Promiseという聞き慣れない概念が出てくると余計にわからなくなりますね。Promiseは和訳すると「約束」ですが、僕が初学者だった頃、非同期処理と約束がどう結びつくの?と混乱していました。

ここに違和感を覚えたままだとPromiseを理解しにくいので、僕は
「時間がかかっても必ず処理を終わらせることを約束してくれる」ものだと解釈しています。

ゆびきりげんまん

Promiseは以下のようにPromiseオブジェクトを生成して使います。

new Promise(コールバック関数)

コールバック関数の中に非同期で実行したい処理を書きます。このコールバック関数には引数が決まっていて、第一引数にresolve、第二引数にrejectを持ちます。

new Promise((resolve, reject) => {
    // do something
    ...
})

ここで出てくるresolveはコールバック関数が正常に終了する際に実行する関数であり、rejectは失敗したときに実行する関数です。

new Promise()で生成したPromiseオブジェクトは、コールバック関数の実行状況によってpendingfulfilledrejectedの3つの状態を持ちます。

  • pending:処理が終わるのを待っている状態
  • fulfilled:処理が正常に終了した状態
  • rejected:処理が失敗してしまった状態

new Promise()でPromiseオブジェクトを生成した時点ではpendingの状態になっています。
その後、コールバック関数内でresolve()が実行されればfulfilledに、rejectが実行されればrejectedに変わります。

Promiseオブジェクトの状態変化

then と catch

Promiseは非同期処理なので、以下の例のようにpending状態のPromiseオブジェクトがfulfilledもしくはrejectedになるのを待ちません。

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve();
    }, 5000);
});
console.log(promise);       // Promise {<pending>}

then

非同期処理が正常に終了してから実行してほしい処理は関数にし、Promiseオブジェクトが持つthenメソッドにコールバック関数として渡してあげます。thenメソッドは非同期処理が正常に終了した場合(pending → fulfilled)に実行されます。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve();
    }, 5000);
}).then(() => {
    console.log('5秒経過');
})

resolveに引数を渡した場合は、以下のようにthenメソッドのコールバック関数に渡されます。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('5秒経過');
    }, 5000);
}).then((message) => {     // messageには「5秒経過」が入っている
    console.log(message);
})

catch

new Promise()に渡したコールバック関数内でreject()が実行されたり、Exceptionが発生した場合、catchメソッドでキャッチすることができます。

new Promise((resolve, reject) => {
    reject(new Error('failed'));
}).catch((err) => {
    console.log(err.message);
});
// failed

コールバック地獄の回避

Promiseを使った非同期処理の基本的な書き方については説明が終わったので、ここで一度コールバック地獄の問題を思い出してください。Promiseはこの問題を解決すべく登場した概念です。

Promiseを使った非同期処理はthenメソッドをつなげることができます。そのため、先ほど説明したファイル読み込み処理のコールバック地獄は以下のように修正することができます。

// before
fs.readFile('example1.txt', (error, data1) => {
    fs.readFile('example2.txt', (error, data2) => {
        fs.readFile('example3.txt', (error, data3) => {
            fs.readFile('example4.txt', (error, data4) => {
                // do something
            })
        })
    })
})

// after
new Promise((resolve, reject) => {
        fs.readFile('example1.txt', (error, data1) => {
            resolve();
        }
    })
    .then(() => {
        return new Promise((resolve, reject) => {
            fs.readFile('example2.txt', (error, data1) => {
                resolve();
            }
        });
    })
    .then(() => {
        return new Promise((resolve, reject) => {
            fs.readFile('example3.txt', (error, data1) => {
                resolve();
            }
        });
    })
    .then(() => {
        // do something
    })
    .catch(err => {
        // error handling
    })

コールバック型の非同期処理を使っていてネストが深くなるようなら、Promiseを検討したほうがよいでしょう。

async/await と Promise

ここで最初に説明したasync/awaitについて補足しておきますが、実は裏ではPromiseが動いています。Promiseで非同期処理を書くと少し複雑になるため、ES2017でasync/awaitという新しい書き方が登場しました。

書き方の違いを見るために、async/awaitで書いたコードをPromiseで書き直してみましょう。

// async version
async function getUserAccount() {
    const response = await fetch('https://example.com/user/1');
    if (!response.ok) {
        return 'ユーザアカウント情報を取得できませんでした';
    }
    return 'ユーザアカウント情報を取得できました';
}

// Promise version
function getUserAccount() {
    const promise = new Promise((resolve, reject) => {
        fetch('https://example.com/user/1')
            .then(response => {
                if (!response.ok) {
                    resolve('ユーザアカウント情報を取得できませんでした');
                    return;
                }
                resolve('ユーザアカウント情報を取得できました');
            });
    });
    return promise;
}

Promiseと比べると、async/awaitのほうがとても見やすくてスッキリしています。また、Promiseを使った書き方では最後にPromiseオブジェクトを返しています。つまり、async関数の戻り値もPromiseです。

const message = getUserAccount();
console.log(message);
// Promise?{<pending>}

async/awaitの最初の説明でawaitをつけると処理の完了を待つと言っていたのはつまり、このPromiseオブジェクトがpendingからfulfilledに変わるのを待っているということです。

そのため、awaitの右側にはPromiseオブジェクトを置くこともできます。

async function hello() {
    const promise = new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve('hello');
        }, 5000);
    });
    const message = await promise;
}

非同期処理 tips

コールバック型の非同期処理をawaitする

コールバック型の非同期関数は戻り値がなかったり、Promiseオブジェクトも返さないのでawaitはできません。awaitしたい場合はPromiseでラップしてあげましょう。

// before
async function sayHelloAfter5Seconds() {
    await setTimeout(() => {      // awaitできない
        console.log('hello');
    }, 5000);
    console.log('world');
}

// after
async function sayHelloAfter5Seconds() {
    // await できる
    await new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('hello');
            resolve();
        }, 5000);
    });
    console.log('world');
}

ループ中にawait

よく言われることですが、forEachの中ではawaitできません。ループ中にawaitしたいときは代わりにforを使いましょう。

// before
items.forEach(async item => {
    await sendItem(item);     // awaitできない
});

// after
for (const item of items) {
    await sendItem(item);    // awaitできる
}

まとめて非同期処理を実行する

ループを繰り返すごとにawaitをしているとパフォーマンスが悪くなります。前のループが終わってからでないと次に進めない場合以外ではPromise.allを使うとパフォーマンスが良くなります。

// before
for (const item of items) {
    await sendItem(item);
}

// after
const promises = [];
for (const item of items) {
    const promise = sendItem(item);
    promises.push(promise);
}
Promise.all(promises).then(results => {
    // promisesがすべてfulfilledになれば実行される
    // resultsにはsendItem()の実行結果が配列になっている
})

ただし、promisesのいずれかがrejectedになった場合はその時点で処理が中断されます。

一部が失敗しても最後まで処理を完了させたい場合は2通りの方法があります。

①実行結果の成否を返すようにする

const promises = [];
for (const item of items) {
    const promise = sendItem(item)
        .then(() => { return true})
        .catch(() => { return false});
    promises.push(promise);
}
Promise.all(promises).then(results => {
    // resultsにはbooleanの配列
})

Promise.allSettledを使う

const promises = [];
for (const item of items) {
    const promise = sendItem(item)
    promises.push(promise);
}
Promise.allSettled(promises).then(results => {
    // resultsの中身はpromiseの結果(以下の形式)の配列
    // 成功 { status: fulfilled, value: 実行結果 }
    // 失敗 { status: rejected, reason: エラー内容 }
})

ただし、Promise.allSettledはES2020で登場したので、プロジェクトによっては使えない場合もあります。

最後に

非同期処理を最初から完璧に理解するのは難しいと思います。僕も非同期処理をここまで理解するのに1年以上かかりました。まずはasync/awaitから少しずつ使えるようになっていきましょう。