原文: Asynchronous JavaScript – Callbacks, Promises, and Async/Await Explained

JavaScript をある程度学習している方なら、「非同期」という言葉を聞いたことがあるかもしれません。

それは JavaScript が非同期言語だからなのですが、そもそも非同期とはどういう意味なのでしょうか?この記事では、非同期の概念をできるだけわかりやすく説明しようと思います。

同期 vs 非同期

本題に入る前に、同期と非同期という言葉にフォーカスしてみましょう。

デフォルトでは、JavaScript は同期的でシングルスレッドのプログラミング言語です。

つまり、命令は一つずつしか実行できず、並列には実行できません。

こちらのコードを見てみましょう:

let a = 1;
let b = 2;
let sum = a + b;
console.log(sum);

上記のコードはとてもシンプルです。二つの数値を合計し、その合計値をブラウザのコンソールに出力しています。インタプリタがこれらの命令を一つずつ順番に実行しているのです。

しかし、この仕組みにはデメリットがあります。例えば、何かサイズの大きいデータをデータベースから取得して、そのデータを画面上に表示するとします。インタプリタがデータ取得のコードまで到達した時、そのデータ取得が完了するまで他のコードの実行はブロックされてしまいます。

「データ取得の処理にそんなに時間がかかるわけがない」と思われたかもしれません。しかし、このようなデータ取得を複数箇所で行うことを想像してみてください。短時間の処理が積み重なった結果、ユーザーにとって無視できない遅延につながってしまうでしょう。

幸運なことに、JavaScript の同期処理の問題は、非同期処理の登場により解決されました。

非同期処理のコードは、今すぐ実行を開始し、後で実行を終了できるコードだと考えてください。JavaScript が非同期で実行される場合、必ずしも先ほどのコードのように命令が順番に実行されるとは限りません。

この非同期処理を適切に実装するために、開発者達が長年にわたって使用してきたいくつかの異なるソリューションがあります。それぞれの解決策は、その解決策以前の解決策を改良することで、コードをより最適化し、複雑になった場合でも理解しやすくしています。

JavaScript の非同期処理をさらに理解するために、コールバック関数、Promise、そして async と await について説明します。

コールバック関数とは?

コールバック関数とは、他の関数に渡され、その関数内で呼び出されてタスクを実行する関数のことです。

混乱してきましたか?実装しながら詳しく見ていきましょう。

console.log('fired first');
console.log('fired second');

setTimeout(()=>{
    console.log('fired third');
},2000);

console.log('fired last');

上記のコードは、ブラウザのコンソールにログを出力する小さなプログラムです。しかし、見慣れないコードがありますね。インタプリタは一つ目の命令を実行し、次に二つ目の命令を実行します。しかし、三つ目の処理はスキップし、四つ目の命令を実行します。

setTimeout は JavaScript の関数で、二つのパラメータを取ります。最初の引数は関数で、二番目の引数はその関数が実行されるまでの時間をミリ秒単位で指定します。これで、コールバック関数の定義が見えてきたのではないでしょうか。

この場合、setTimeout 内の関数は 2 秒後 (2000 ミリ秒後) に実行される必要があります。他の命令が実行され続けている間に、この関数がブラウザの別の場所で実行されることを想像してみてください。そして 2 秒後、関数の実行結果が返されます。

そのため、上記のコードを実行すると、次のようになります。

fired first
fired second
fired last
fired third

setTimeout 内の関数が結果を返す前に、最後の実行結果がログに出力されていることがわかります。この方法を使ってデータベースからデータを取得したとしましょう。ユーザーがデータベースからの取得結果を待っている間、実行中の処理が中断されることはありません。

この方法は特定条件下ではとても効率的です。しかし、開発者はコード内で一度に複数のリソース先へ処理の呼び出しをすることがあります。このような呼び出しを行おうとすると、コールバックは非常に読みづらく、保守しづらくなるまでネストされていきます。これはコールバック地獄と言われます。

この問題を解決するために、Promise が導入されました。

Promise とは?

人が約束をするのはよく聞く話です。無条件でお金を送ると約束したいとこ、クッキーの瓶を勝手に触らないと約束した子供…しかし、JavaScript における約束 (Promise) は少し違います。

JavaScript の文脈では、約束 (Promise) とは、実行するまでに時間がかかることです。Promise は以下の 2 種類のうちどちらかの結果を返します:

  • 命令を実行し、Promise を解決する
  • 命令の実行途中で何らかのエラーが発生し、Promise が拒否される

Promise は、コールバック関数の問題を解決するために登場しました。Promise は resolvereject の 2 つの関数を引数として受け取ります。resolve は成功時、reject はエラー発生時と覚えておきましょう。

実際のコードで Promise を見てみましょう:

const getData = (dataEndpoint) => {
   return new Promise ((resolve, reject) => {
     // エンドポイントへのリクエスト;
     
     if(request is successful){
       // リクエスト成功後のコード;
       resolve();
     }
     else if(there is an error){
       reject();
     }
   
   });
};

上記のコードは、あるエンドポイントへのリクエストを実行する関数内にある Promise です。Promise は、前述したように resolvereject を受け取ります。

例えば、API エンドポイントへリクエスト後、リクエストが成功すれば、Promise を resolve (解決) し、そのレスポンスを使って実行を続けます。しかし、エラーがあれば、Promise は reject (拒否) されます。

Promise は、プロミスチェーンと呼ばれる方法で、コールバック地獄によって引き起こされる問題を解決する最適な方法です。この方法を使えば、複数の API エンドポイントから順次、より少ないコードと簡単なメソッドでデータを取得することができます。

しかし、さらに良い方法があります!以下の方法は、JavaScript でデータや API 呼び出しをする際によく使われるので、ご存知の方も多いかもしれません。

async と await とは?

結局、コールバック関数のようにプロミスチェーンで Promise を繋げていくのはコードの肥大化や難読化を引き起こします。それを解決するために登場したのが async と await です。

以下のように async 関数を定義できます:

const asyncFunc = async() => {

}

async 関数を呼び出すと必ず Promise が返されることに注意してください。こちらを見てください:

const test = asyncFunc();
console.log(test);

上記のコードをブラウザのコンソールで実行すると、asyncFunc が Promise を返していることが確認できます。

コードを分解して、以下の小さなコードについて考えてみましょう

const asyncFunc = async () => {
	const response = await fetch(resource);
   	const data = await response.json();
}

前述した通り、async というキーワードは、非同期関数を定義するために使うものです。では、await はどうでしょう?await は、Promise が解決されるまで、response 変数に fetch メソッドの結果が代入されるのを遅らせます。Promise が解決されると、fetch メソッドの結果を response 変数に代入できるようになります。

同じことが三行目でも起きます。.json メソッドは Promise を返すため、await を使って Promise が解決されるまで代入を遅らせることができます。

コードをブロックするかしないか

「遅らせる」というと、async や await を実装することでコードの実行がブロックされるのではないか、リクエストに時間がかかりすぎるのではないか、と思うかもしれません。

実際はそうなることはありません。非同期関数の中にあるコードはブロックされますが、プログラム全体の実行には何の影響もありません。コードの実行はこれまで通り非同期です。こちらのコードを見てください。

const asyncFunc = async () => {
	const response = await fetch(resource);
   	const data = await response.json();
}

console.log(1);
cosole.log(2);

asyncFunc().then(data => console.log(data));

console.log(3);
console.log(4);

ブラウザのコンソールでの出力結果は以下のようになります:

1
2
3
4
data returned by asyncFunc

asyncFunc 関数が結果を返すまでは、他のコードが実行され続けたことがわかります。

まとめ

この記事では、各コンセプトを深く扱うことはしませんでしたが、JavaScript における非同期処理がどのようなものなのか、そして注意すべき点をいくつか紹介できたかと思います。

これは JavaScript の非常に重要な部分であり、この記事は表面をなぞったに過ぎません。とはいえ、この記事がこれらの概念を理解する助けになれば幸いです。