JavaScriptでつまずきやすいトピックをとりあげて解説する、本連載。今回は「非同期処理」について。ある程度実用的なアプリケーションをブラウザのJavaScriptで作ろうとすると、なんらかの通信処理が発生することが多いのではないでしょうか。Ajaxによる具体的な通信については今後取り上げる予定ですが、通信処理を扱う際には「非同期処理」の概念と向き合うことになるでしょう。今回は非同期の概念と、その扱いについてご紹介します。
今回の学び
- 非同期処理の仕組み
- 非同期処理を使い、快適な動作を実現するには
- ここが難解!「実行処理の流れ」と「可読性」
- 「可読性」の解決には「Promise」と「Async/await」
契約社員、客員研究員、フリーランスなどさまざまな立場で開発現場を経験。フロントエンド、バックエンド開発、チームリードやOJTの育成担当まで幅広くこなしてきた。現在プログラミングトレーニング事業を行うサークルアラウンド株式会社を立ち上げ、経営や現場開発を行いながらOJT経験をもとに後進の育成に励んでいる。ノウハウのコンテンツ化にも取り組んでおり、著書に『ステップアップJavaScript』(翔泳社)がある。
*記事内に使用しているサンプルコードは、以下からも確認することができます。
https://github.com/CircleAround/engineer_style_js/blob/main/03/03_async.js
そもそも「非同期で処理を実行する」とは?
通常のコードを書いているときは「同期処理」のコードを書いています。「非同期」は特定の処理を行うときに発生するため、まずは非同期を体験するためにもsetTimeout関数を使って説明していきます。以下に簡単なコードを用意しました。
console.log("start"); // *1
const handler = () => {
console.log("1秒経ちました"); // *2
};
setTimeout(handler, 1000);
console.log("next"); // *3
// start → next → 1秒後に「1秒経ちました」と表示
setTimeoutは指定の時間が経ったあとにコールバック関数として指定された関数を実行します。したがって、上記のコードを実行すると、start、nextと即表示された1秒後に「1秒経ちました」と表示されます。
ここで起きていることをもう少し掘り下げて図で解説します。
上の線はコードが実行されていくメインスレッドの動作を示しています。左側の丸から順番に処理が実行されていくイメージをしてください。今回の処理は、以下のような流れで実行されていきます。
- *1の処理でstartと表示する
- setTimeoutを呼び出して、1秒後の処理を依頼
- 続けて*3の処理でnextと表示する
- 1秒後に、handlerの内容を実行依頼する
- handlerを実行して*2の処理で「1秒経ちました」と表示する
setTimeoutは非同期に処理を実行する関数です。そのためsetTimeoutの処理の終了を待たずに*3を実行していきます。「*3の処理が1秒待っている間に先に実行できている」と考えても良いでしょう。
今回サンプルに出したコードは処理系に依頼した内容が「指定秒数待つ」という簡単な処理でしたが、この部分が「サーバーに通信をして、そのレスポンスを待つ」のような処理と置き換え可能だと考えていただけると内容の理解が深まるでしょう。
もしも「setTimeoutが同期で処理する関数」だったら
同期と非同期のことを理解するために、「もしも」を考えてみましょう。「もしもsetTimeoutが同期で処理する関数」だったら、1秒待つという処理はメインスレッドのコード実行を1秒間停止させます。
以下のような流れとなり、「1秒待って*2の処理を行なってから」*3の処理を行います。
- *1の処理でstartと表示する
- setTimeoutを呼び出して、1秒間コード実行が全て止まる
- 1秒後にhandlerを実行して*2の処理で「1秒経ちました」と表示する
- 続けて*3の処理でnextと表示する
コードの実行が止まった1秒間については、画面をクリックしても反応しなかったり、描画が止まってしまったりするイメージです。使いにくいアプリになることが想像できるでしょう。そのため、JavaScriptで処理を書く際にはメインスレッドを同期の処理で占有しないことが求められます。回数の多いループをメインスレッドで回さないなどの対応をしていきましょう。
非同期処理が難解な理由その1:実行処理の流れがややこしい
「非同期処理を利用すると、複数の処理が同時に実行できる」とよく言われますが、同時に実行できる処理には制限があります。そのことを確認しておきましょう。
この説明以降に「1秒待つ」処理が何度も出てくるため、wait1secという関数にしました。
const wait1sec = (handler) => {
setTimeout(() => {
console.log("1秒経ちました");
handler();
}, 1000);
};
その上で「一度に複数の非同期処理を開始するコード」を用意しました。
console.log("[1]");
wait1sec(() => console.log("[2]")); // console.logを実行する関数が引数です
wait1sec(() => console.log("[3]"));
wait1sec(() => console.log("[4]"));
console.log("[5]");
// [1] → [5] → 1秒後に一気に3つ「1秒経ちました」と表示
これまでと同様の図で解説します。
非同期の関数で呼び出される処理系内の動作は同時に複数走らせることができますが、コールバック関数の実行がメインスレッドへ依頼されると「今後実行される処理」として積まれます。つまり「コールバック関数に書いたJSのコード」は複数同時並行で実行されません。1つずつ実行されていきます。
また、今回は[5]の処理が1行書かれているのみでしたが、同期で処理が続いている場合にはそれが先に実行される仕様です。[5]の後[6]、[7]…と数多くの同期処理が続いた場合でも「非同期の結果のコールバックよりも先に実行される」ことが保証されています。つまり、進んでいる同期のコードが全て終わってから[2]、[3]、[4]の非同期処理後のコールバックがメインスレッド上で1つずつ実行されます。
非同期処理が難解な理由その2:関数が入れ子になり、可読性が悪い(コールバック地獄)
実際にコードを書く上では「ある非同期の処理が終わったら、次の処理をはじめたい」といった数珠繋ぎの要望は多く発生します。例えば以下のような動作をイメージしてみてください。
- ユーザー情報を取るために、サーバーに通信する
- 1.で取得してきたユーザー情報を利用して商品の購入履歴を取るために、もう一度サーバーに通信する
1が終わらないと2の処理ははじめられないため、「先の非同期が終わってから、次の非同期の実行」を行うことになります。
wait1secを使ったサンプルを示します。非同期の処理が終わった際のコールバック関数の中で、次の非同期の処理を開始しています。
console.log("[1]");
wait1sec(() => {
console.log("[2]");
wait1sec(() => {
console.log("[3]");
wait1sec(() => {
console.log("[4]");
});
});
console.log("[2']"); // [2]の次に呼ばれる
});
console.log("[5]");
このときの動作は以下のようになります。
ここで紹介したコードは「コールバック関数が入れ子になって読みにくい」はずです。通常のコードは処理を上から下へと読めるのですが、コールバック関数が入れ子になると、外から内へ処理が進みます。そのため[1]の直後[5]が実行されたり、[2]のあとに[2’]が実行されるような、直感的ではない動作になります。このことは「コールバック地獄」と呼ばれて嫌われていました。
このあと示すような方法で読み易い非同期処理の書き方が模索されてきましたので、今回は簡単に紹介していきます。
「可読性が悪い」を解決する:Promise
コールバック地獄に対応しようとした方法の一つがPromiseです。先のコードと同様の動作は、Promiseを使うと以下のコードで実現できます。
const wait1sec = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
};
console.log("[1]");
wait1sec()
.then(() => {
console.log("[2]");
return wait1sec();
})
.then(() => {
console.log("[3]");
return wait1sec();
})
.then(() => {
console.log("[4]");
});
console.log("[5]");
少なくとも、外から内ではなく上から下へ読めるようになり、可読性が増しました。しかし、Promise自体の概念が複雑なので、初見で理解するのが難しいとよく言われました。
さらに読みやすく:Async/await
先に挙げたPromiseである程度読みやすくなったのですが、処理系の対応によりasync/awaitを用いて同期に近い直感的な書き方ができるようになりました。実体としてはPromiseの短縮記法になっていますので、Promiseの理解があると使いこなしやすいです。
const asyncFunc = async () => {
await wait1sec(); // このコードの非同期処理が終わった後、次の行が呼ばれる
console.log("[2]");
await wait1sec();
console.log("[3]");
await wait1sec();
console.log("[4]");
};
console.log("[1]");
asyncFunc();
console.log("[5]");
次回はPromiseの理解を深めて、async/awaitの利用、“非同期処理を利用してアプリケーションを作る際のよくある課題への対応”をお送りします。お楽しみに。
今回のまとめ
今回は難解なトピックである非同期処理について、その概念を頭の中で描くことをゴールに進めてみました。
- JavaScriptでは「時間のかかる処理を処理系に依頼して、終わったら指定のコードを実行させる」非同期処理の仕組みが提供されています。
- 非同期処理によってメインスレッドの実行コードとは別に裏で処理を動かすことができ、システムを完全停止させることない快適な動作を実現できます。
- コールバック関数の入れ子が読みにくいという「コールバック地獄」の課題がありましたが、Promiseや async/awaitの利用が模索され、今ではかなり読みやすく記述できます。
▼これまでのJavaScriptを学ぶ上でつまずきやすいポイントを理解するための連載
・JS特有の「this」の使い方6つをおさえよう
・2つの利用シーンから理解する「クロージャ」活用法