はじめに
フロントエンド / バックエンド間の通信処理を実装する際にJavaScriptの非同期処理について学習したので、ここではその内容を記事に書き起こしてみました。
まず非同期処理とは処理を同時並行的に行える処理のことを指し、一般にネットワーク通信などコストの高い(=時間がかかる)処理を扱うときによく用いられます。
非同期処理の概要
初めに非同期処理について深堀りする前になぜこのような処理があるのか、という点から解説していきます。結論を言うと、非同期処理を用いると複数のタスクを並行して実行できるのでパフォーマンスを向上させることができる、というのがその理由になります。
この理由を理解するにはJavaScriptの動作原理を知る必要があります。そこでJavaScriptの内部の処理について深堀って見てみます。
JavaScriptの動作原理
JaJavaScriptは、通常の処理では複数のタスクを同時に実行することはできません。1つのタスクのみを順番にこなすことで処理を実行させているのです。これはJavaScriptは1つのスレッドのみ(シングルスレッド)で動作しているためです。ちなみにスレッドとはタスクを実行するための場所のことです。
同期処理について
そして、このタスクを1つずつ順番に実行していく処理方法を同期処理と呼びます。JavaScriptでは基本的にはこの同期処理によってプログラムの処理が実行されていきます。しかしこの同期処理には1つ大きな欠点があります。それは、時間のかかるタスクが実行されると全体のパフォーマンスが大きく低下する点です。
上の図のような、タスクAが実行中でタスクBが待機中のスレッドに新たにタスクCが追加された場面を考えてみます。この時、タスクAが完了するとすぐにタスクBが実行されますが処理が重く時間がかかってしまいます。この間タスクCはずっと待機状態のままで、タスクBが終了するとようやくタスクCが実行されすべての処理が完了します。
各タスクと実行時間の推移を可視化したものが上図になりますが、タスクBが実行されている間、他のタスクは一切処理を実行できずブロッキングされているのがわかります。このブロッキングが起こるとアプリの処理に時間がかかったり、画面がフリーズするなどパフォーマンスが大きく低下してしまいます。
つまり同期処理ではタスクを順番に実行していくため、時間のかかるタスクが1つでもあると後ろの処理がつっかえてしまい全体の処理が大幅に遅くなるのです。この問題を解決するために登場したのが非同期処理になります。
非同期処理について
従来の同期処理ではシングルスレッドにより複数の処理を同時にこなせないという課題を抱えていました。これを解決したのが非同期処理という処理方法になります。
非同期処理では下図のようにJavaScriptとは別のWEB APIという場所を用意してそこでも処理を実行させることができるようになっています。これによってサーバとの通信処理などコストの高い処理はWEB APIへ、文字の出力など低コストな処理はJavaScriptで行わせるといった同時並行処理が可能となり、処理時間の短縮やアプリのフリーズを防げるなどパフォーマンスの大幅な向上を図ることができるようになります。
今回のアプリではクライアント / サーバ間で通信を行う部分でこの非同期処理を用いました。通信処理は一般的にコストが高くなる傾向があるので非同期処理とし、その間にユーザーからの操作があった場合でもフリーズしないアプリになるよう意識しました。
コードの書き方
非同期処理についての基本を学んだところで、実際のJavaScriptの非同期処理の使い方について見ていきます。ただし、かなり記述量が増えてしまったのでここに関しては別ページにて紹介を行います。
fetch API
▸ fetch APIの簡単な紹介
ここからは今回のアプリ実装で実際に使用した非同期処理のfetch APIについて紹介していきます。そもそもですが、fetch APIとは通信処理を行うWEB APIになります。
通信処理というのはコストが高くなりやすい処理で、これを同期的に実装するとサーバからレスポンスを受けるまで他の処理はすべてブロッキングされてしまいます。そこで通信処理というのは非同期で実行するよう実装されることが多いです。この通信処理の非同期化を非常に簡単に実装できるのがfetch APIになります。
▸ 実装例
fetch APIの使用例を見ながらその挙動について解説していきます。
async function getData() {
const url = "https://example.org/products.json";
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`レスポンスステータス: ${response.status}`);
}
const result = await response.json();
console.log(result);
} catch (error) {
console.error(error.message);
}
}
const result = getData();
上のコードはfetch APIを使用した通信の一例になります。fetch APIはPromiseベースで処理を行うため基本的にはPromiseを用いた非同期処理のコードの書き方と同じになります。順にコードを見ていきましょう。
1行目のasync function getData()でまずは非同期処理を扱う関数の定義を行っています。この中にfetch APIを使ったコードを記述していきます。
以降の行では通信先のURLを指定した後、具体的な通信処理の記載を行っています。4行目の
const response = await fetch(url);
の部分で非同期による通信処理が実行されます。fetch 関数を使用することで指定した引数に対して非同期的に通信を実行させることができます。
なお、fetch 関数は返り値としてPromiseオブジェクトを受け取るようになっています。そしてこのオブジェクトにはステータスコードやHTTPボディの情報などレスポンス情報が格納されています(便宜上ここではレスポンスオブジェクトと呼びます)。なのでレスポンス情報を受け取りたい場合は先頭にawait をつけて、Promise内の処理(fetch 関数では通信処理が該当)が完了したらオブジェクトを格納するようにします。
そしてこのfetch 関数によって得られたオブジェクトから詳細なレスポンス情報を取り出しているのが以下の部分になります。
if (!response.ok) {
throw new Error(`レスポンスステータス: ${response.status}`);
}
const result = await response.json();
この部分のコードでは処理が失敗したら例外処理を投げる、成功していたらレスポンスデータをJSONに変換する処理が記述されています。
response.ok は通信が成功した(ステータスコードが200番台)場合にはtrue、それ以外はfalseを返す、response.status はレスポンスのステータスコードを取得するのにそれぞれ使用するfetch APIの標準装備の機能です。
またresponse.json はレスポンスのHTTPボディのデータをJSON形式に変換する機能になりますが、こちらは非同期で処理が実行されるのでawait をつけて変換が完了したらデータを受け取るようにします。
async function getData() {
const url = "https://example.org/products.json";
try {
const response = await fetch(url);
if (!response.ok) { // ...処理 }
} catch (error) {
// 処理
}
}
ここで通信そのものが失敗したときの処理についても考えてみます。ネットワークの影響などによって通信が切断された場合はfetch 関数はPromiseを拒否状態にしてレスポンスを出力します。なのでこのような通信に関する重大な事象が発生した場合には例外処理としてキャッチして処理の実装を行います。
つまり、ひとことに通信エラーと言っても2種類タイプがありそれぞれ
・通信としてはOKが内容が処理的に×(ステータスコードが200番台以外)→ !response.ok
・通信そのものに異常が発生した → catch 文
という形で使い分けします。
▸まとめ
最後にfetch APIについて紹介してきた部分を簡単にまとめます。
まず、fetch APIとは非同期でサーバとの通信を行う際に簡単に処理を実装できるWEB APIのことです(クライアント / サーバ間の通信処理はコストが高いで非同期処理での実装が望ましい)。
そして、このfetch APIはPromiseオブジェクトにてその処理を扱います。そのため実装するときにはPromiseオブジェクトとasync /await を理解しておくことが望ましいです。
動作原理について
そもそも(JavaScriptの)非同期処理はどのように実装されているのか、について最後に触れたいと思います。
先の説明でJavaScriptはシングルスレッドなので複数の処理を同時にこなすことはできないと述べました。即ちJavaScript単体では非同期処理は実行することはできません。非同期処理を行うときには、主にWEB APIのようなJavaScriptとは別の場所で処理を任せることで並行的に処理をこなしているのです。
まずはJavaScriptとブラウザの内部構造について見ていきましょう。
下の図はJavaScriptとブラウザのうち、非同期処理に関連する部分をピックアップしたものになります。ここでは主にコールスタック、タスクキュー、マイクロタスクキュー、イベントループの4つの要素が登場します。簡単な動作を交えながらこれら要素について見ていきましょう。
▸コールスタック
コールスタックはJavaScriptが実際に処理を実行する、極めて重要な場所になります。もう少し言うと実行中の関数を管理して、順番に実行していく場所になります。
名前からわかる通り、LIFOで処理の順番を管理しています。以下のコードを実行したときのコールスタックと処理実行の挙動を見てみましょう。
function third() { }
function second() { third() }
function first() { second() }
first();
このコードを実行すると、まずfirst が呼び出されてコールスタックに格納されます(格納された時点ではまだ実行はされません)。そして次にfirst の中で定義されているsecond が呼び出されて積み上げられる形で格納されます。同様の流れで thrid が呼び出されてさらに上に積み上げられます。
そして呼び出される関数がなくなったら今度はコールスタックの最も上に積まれているthrid から処理の実行を行います。処理が実行されるとコールスタックからは取り除かれて、次に上に積まれているsecond が実行されます。そして最後にfirst が実行されるとコールスタックは空となり処理は終了します。
※ 以下のページでステップバイステップで動作を確認できます。
このようにJavaScriptではコールスタックに格納された関数をLIFO(後入れ先出し)で実行していきます。今回は同期処理で見てきましたが非同期処理でも問題なく動作します(JavaScriptではコールスタックを通じて処理結果を出力するため)。ただし、非同期処理を扱う場合はコールスタックに加えて後述のタスクキューやマイクロタスクキューと合わせて処理を実行します。
▸タスクキュー
タスクキューはsetTimeoutのような非同期処理を一時的に保管しておく場所になります。実際にコードを見ながら挙動を確認しましょう。
setTimeout(function a() {}, 100);
function b() {}
b();
このコードを動かすとb , aの順に処理が実行されます。順に流れを見ていきます。
まずは上のコードからJavaScriptは見ていくので、1行目の処理がコールスタックに格納されます。しかしこの処理は非同期処理なのですぐには実行されず、一旦WEB APIへ処理が渡されます。なのでaのsetTimeout処理はWEB APIへ格納されます。
そしてWEB APIでは経過時間の管理を行い、WEB APIに格納された時を起点として指定時間を経過するとタスクキューへ格納を行います。今回の場合ではsetTimeoutで指定した100ms後にタスクキューへa を格納します。
しかし、100ms経過するよりも先にJavaScript側ではb の呼び出しが行われます。そしてこのb は同期関数なのでコールスタックに格納されると即座に実行が行われます。
100msが経過するとWEB APIからタスクキューへa が格納されます。そしてイベントループがそれを検知してコールスタックへa を移動して処理が実行されます(イベントループについては後述)。
※ 以下のページでステップバイステップで動作を確認できます。
ではここでもう1つコードを見てみましょう。以下のコードのようにsetTimeoutを0msに設定して実行するとどうなるでしょうか。実はこの場合でもb , aの順に処理が実行されます。
setTimeout(function a() {}, 0);
function b() {}
b();
先ほどと同様に処理の流れを見ていきます。
まず上からコードを読み込んでいくのでsetTimeoutがWEB APIに格納されますが、設定時間が0msなので瞬時にタスクキューへ格納されます。
そして次にコードを読んでいきb がコールスタックに読み込まれて実行されます。タスクキューに入ったからと言ってすぐにコールスタックに格納して出力されるのではなく、まずは同期処理からコールスタックで実行します。なのでタスクキューにある非同期処理のaではなく、同期処理であるb が先にコールスタックにて出力されます。
そして同期処理の実行がひと通り完了しコールスタックが空になったらイベントループが作動し、タスクキューに処理が格納されていないか確認を行います。ここで処理が格納されていればコールスタックに移動させます。
今回はa がタスクキュー格納されているのでイベントループがそれを発見してコールスタックに移動させます。
後の流れは同期処理と同じになります。コールスタックにa が格納されたら後はsetTimeout内の処理を行って全体の動作は完了となります。
※ 以下のページでステップバイステップで動作を確認できます。
このようにタスクキューとうまく組み合わせることにより非同期的に処理を実行させることが可能となるのです。ここで押さえておいてほしいポイントは同期処理が最優先で実行される、ということです。
JavaScriptではコードを読み込む際に同期処理であればコールスタックに格納して処理を実行、非同期処理であればWEB APIなど外部に処理を委託するという形を取ります。つまり自分の担当処理である同期処理を先にすべて実行します。
その後、コールスタックが空になるとイベントループが動き出し、タスクキューに格納された処理を発見することで初めて非同期の処理をコールスタックに格納して出力できるようになるのです。
▸マイクロタスクキュー
ここまで、setTimeoutのような非同期処理についてはWEB APIなど外部に処理を委託し、その結果をタスクキューに格納していると説明してきました。しかし実際にはこれに合わせてもう1つ、委託した非同期処理の結果を格納する場所があります。これがマイクロタスクキューになります。
今までわかりやすさのため簡略化して説明してきましたが、実際には下図のように委託した非同期処理の結果を格納する場所はタスクキューとマイクロタスクキューの2か所存在しています。
ではこのタスクキューとマイクロタスクキューの違いは何でしょうか?
最も大きな違いは処理の呼び出し順序です。実行の優先度についてタスクキューの最後の説明で
「同期処理 > 非同期処理」
の順に処理が行われると説明しました。そしてこの非同期処理の中にもさらに優先度があり、
「マイクロタスクキューに格納された処理 > タスクキューに格納された処理」
という順序で出力が行われるのです(このあたりの動作は後ほどコードを交えてじっくりと見ていきます)。
その他に、非同期で呼び出す際の関数が異なるという点も挙げられます。以下はそれぞれの代表的な例を紹介したものです。
| 種別 | 代表例 |
|---|---|
| タスクキュー | setTimeout, setInterval |
| マイクロタスクキュー | fetch, queueMicrotask, Promise*(Promise.then, Promise.catch, Promise.finally) |
* Promiseオブジェクト内でresolve /reject メソッドによって完了状態となり、then / catch によって呼び出された処理がマイクロタスクキューに格納されます(Promiseオブジェクト=マイクロタスクキューではないので注意)。
例えば以下のコードであればPromiseオブジェクト内で記述した内容は同期処理なのでコールスタックに格納されてすぐに処理が実施されます。その一方でresolve /reject によってPromiseの状態を確定させた後にthen / catch で呼び出される処理は一度マイクロタスクキューに格納した後にイベントループによってコールスタックに呼び出されて処理が行われます。
ではここで1つ実際のコードを動かして処理の流れを確認してみましょう。
setTimeout(function a() {}, 0);
Promise.resolve().then(function b() {});
function c() {}
c();
このコードを実行するとc , b , a の順に関数が実行されます。処理の過程を順番に見ていきましょう。
まず1行目のsetTimeoutが呼び出されてWEB APIでタイマーのカウントを行います。ただし今回は0msで設定しているのですぐにsetTimeout内の関数a がタスクキューへ格納されます(下図参照)。
次の2行目ではPromise.resolve()で履行状態にしたのちthen で呼び出しを行っているので関数bはマイクロタスクキューへ格納されます(下図参照)。
そして3, 4行では関数c の定義と呼び出しを行っています。4行目が実行されると、c は同期間数なのでコールスタックに格納されて一番最初に処理が実行されます(下図参照)。
ここで一旦コードの読み込みは終了します。c が実行されコールスタックは空になったタイミングでイベントループが動き出し、まずはマイクロタスクキューに格納されている処理を見に来ます。今回はbが格納されているので、これをコールスタックに取り出してきて出力を行います(下図参照)。
b の実行が終わりコールスタックが空になると再びイベントループがマイクロタスクキューを見に行きます。しかしもうマイクロタスクキューには処理は格納されていないので次にタスクキューを見に行きます。そして格納されているa をコールスタックに呼び出し実行を行います(下図参照)。
※ 以下のページでステップバイステップで動作を確認できます。
https://www.jsv9000.app/?code=c2V0VGltZW91dChmdW5jdGlvbiBhKCkge30sIDApOw0KUHJvbWlzZS5yZXNvbHZlKCkudGhlbihmdW5jdGlvbiBiKCkge30pOw0KZnVuY3Rpb24gYygpIHt9DQpjKCk7
このようにタスクキューに格納されるのか、マイクロタスクキューに格納されるのかによって実行の順序が変わってくることがお判りいただけたと思います。
▸イベントループ
既に開設の中で出てきていますが、改めてイベントループについて簡単に解説します。
イベントループとはJavaScriptの非同期処理の管理を担う極めて重要な要素の1つで、コールスタックが空になったタイミングを見計らってマイクロタスクキューやタスクキューに格納されている処理をコールスタックに移動させる役割を担っています。
既に何度か出てきていますが、イベントループではマイクロタスクキューとタスクキューの優先度の管理も行っています。コールスタックが空になったときにはまずマイクロタスクキューを確認し空であればタスクキューを確認する、という動作を取るためマイクロタスクキューが優先的に実行されるような挙動を取ります。
▸まとめ
JavaScriptではまず最優先に同期処理を実行、次にfetchなど優先度の高い非同期処理等をマイクロタスクキューに格納して実行、setTimeoutなど比較的優先度の低い非同期処理をタスクキューに格納して実行、という形で基本的には動作することになります。
▸サンプルコード
最後に内部の具体的な動作の理解をより深める目的で、1つサンプルコードをトレースしていきたいと思います。
以下のようなコードを実行すると出力結果はどのようになるでしょうか?
setTimeout(() => a());
queueMicrotask(() => b());
const promise = new Promise((resolve) => {
c();
setTimeout(() => d());
resolve();
});
promise.then(() => e());
f();
function a() { console.log('a') }
function b() { console.log('b') }
function c() { console.log('c') }
function d() { console.log('d') }
function e() { console.log('e') }
function f() { console.log('f') }
トレースしながらその挙動を見ていきましょう。
【1行目】
まずは①のように1行目のsetTimeoutがコールスタックに呼び出され、②のようにWEB APIへ関数オブジェクト(ここでは関数a)が渡されます。ただし今回はsetTimeoutの第二引数がないのですぐに③のようにタスクキューへ格納されます。
【2行目】
2行目のqueueMicrotaskも同様にまずはコールスタックに①のように呼び出されます。今回はqueueMicrotaskを使用しているので②のように関数オブジェクト(関数b)がマイクロタスクキューへ格納されます。
【3~7行目】
3行目からのPromiseオブジェクトの部分では、コールスタックにまずは①のように変数promiseと変数の定義情報が格納されます。そして②のように変数の内部情報を取得して積み上げていきます。さらにPromiseオブジェクト内の処理を③のように積み上げていきます。
③を見ると処理がただ記されているだけなのであとは順に見ていけばOKです。最初に一番上の関数cが読み込まれます。この関数cは同期処理なので④のようにコールスタック上で実行がなされます。即ち、一番最初に出力されるのはc となります。
関数cの実行が完了すると次の行であるsetTimeoutの部分が読み込まれます。そしてsetTimeout内の関数オブジェクト(関数d)が⑤→⑥→⑦のように渡されていき最終的にはタスクキューに格納されます。
setTimeoutの処理も終わると残りは⑧のようにresolveのみとなります。この処理自体は同期処理なのでそのまま実行されて、Promiseオブジェクトは履行状態となります。これで3~7行目のコードの読み込みは終わりとなります。
【8行目】
Promiseの状態が履行状態になると①のようにthenメソッドが呼び出されます。そして内部の処理を順番に読み込んでいきます。今回は関数オブジェクト(関数e)が1つあるのみなので、②のように関数オブジェクトがマイクロタスクキューへと格納されます。
【9行目】
コードの最後である9行目は同期処理なのでコールスタックに格納されると即座に実行されます。即ち、2番目に出力されるのはf となります。
【マイクロタスクキューの処理】
関数fが実行されてコールスタックが空になるとイベントループが作動してマイクロタスクキューに格納されている処理を確認しに行きます。
そしてコールスタックへ**FIFO(先入れ先出し)**で呼び出しを行います。つまりマイクロタスクキューに格納された順番に処理が呼び出される、というわけです。
今回の場合では最初に関数bがコールスタックに呼び出されます。関数bの中身は単なるconsole.logなので即座に処理が実行されます。即ち、3番目に出力されるのはb となります。
そして次に関数eがコールスタックに呼び出されて同様の流れで処理が行われます。従って4番目に出力されるのはe となります。
【タスクキューの処理】
マイクロタスクキューに格納されていた処理がすべて呼び出されて空になるとイベントループは今度はタスクキューに格納されている処理を確認しに行きます。
そしてコールスタックへ**FIFO(先入れ先出し)**で呼び出しを行います。マイクロタスクキューと同様に格納された順番に処理が呼び出されていきます。
今回の場合では最初に関数aがコールスタックへ呼び出されてa が出力されます。そして次に関数dがコールスタックへ呼ばれてd が呼び出されます。
以上で、すべての要素が空となり処理は終了します。
【答え】
今回のコードの処理ではc , f , b , e , a , dの順に出力されます。
まとめ
今回は非同期処理について取り扱ってみました。普通のコードのように順番に実行されるわけではないので制御が難しい一方、フロント / サーバ間通信といったコストが高い処理でもWebアプリケーションのパフォーマンスを落とすことなく動作させることができるといった大きなメリットもあるため、今後Webアプリ開発を本格的に進めていく上で欠かせない技術を学ぶことができたと思います。




























