うえだなです。
新卒研修中はJavaScriptの「非同期処理」の理解に苦しみました。
今までプログラムは書いた順に実行してくれていたのに、なぜかエラーが出たり、console.logでエラーの原因を見ると書いた覚えのないのPromiseというよくわからない値が返ってきていたり…
ネットで調べたり、上司に聞いてみると「時間のかかる処理の完了を待たずに、次の処理へ進むことができる仕組み」「asyncとawaitを付けるだけ」といった、シンプルな説明が返ってきました。
今思うと非常に洗練された説明です。しかし、JavaScriptに触れたばかりの自分にとっては、その説明と、実際に非同期処理のコードを扱えるようになることの間には、大きなギャップがありました。
本記事では、当時の自分と同じように悩んでいる初学者の方に向けて、「非同期処理とは何か」「非同期処理でよく使われるPromiseやasync / awaitの正体は何か」について、私自身が理解するうえでカギになったポイントを交えながら解説していきます。
目次
1,JavaScriptにおける非同期処理とは何か
JavaScriptにおける非同期処理 = 「時間のかかる処理の完了を待たずに、次の処理へ進むことができる仕組み」はシンプルな説明ではあるのですが、この言葉だけで非同期処理のイメージを掴むのは難しいです。
これを「時間のかかる処理」「待つ」という2つの概念から紐解いていきます。
時間のかかる処理について
時間のかかる処理は、主に「通信」と「ファイルの読み書き」の2つがあります。
どれもコンピュータが一瞬で行っているように見えますが、URLにアクセスしたときに画面が開くのに若干のラグがあるのと同様に、通信は基本的に時間がかかるものです。
そして、時間のかかる処理の結果を待つ = 「処理の結果が返ってくるまで、プログラムの実行を一時的に止める」ことになります。
ブラウザで動く簡単なコードであれば問題ありませんが、Node.jsを用いてサーバで作動させるとなると深刻な問題につながります。
Node.jsの本来の役割として、Webサーバを作ることがあります。
Webサーバは複数クライアントからの接続に応対する必要がありますが、ここで通信に対して処理が完了するまでプログラムが止まってしまうと、複数クライアントへの応対という本来のwebサーバの役割を遂行することが難しくなってしまいます。
このような理由から、時間のかかる処理があっても結果を待たずに次の処理へと進む処理、非同期処理が必要になるのです。
待ったプログラムはいつ実行されるのか?
時間のかかる処理といっても、最近のコンピュータは凄いのでほぼ一瞬で結果は返ってきます。
結果が返ってきた場合、待ったプログラムはいつ実行されるのでしょうか?
ここで、非同期処理を理解するために覚えておいてほしいJavaScriptの原則が2つあります。
・JavaScriptは同時に2つのプログラムを並行して行うことはない。
・JavaScriptは同期処理の最中に、非同期処理が割り込むことはない。(同期処理は、上から順に実行されていく通常のコードを指す。)
つまりJavaScript内で非同期処理が行われる場合、「同期的なプログラムを全て実行する → 非同期的なプログラムを実行する」という順番で処理が行われるのです。
下記は、同期処理の最中に非同期処理が割り込まないことを示すコードです。ここではtimer関数が非同期処理にあたります。
const timer = () => {
return new Promise((resolve) => {
setTimeout(() => { // setTimeout関数を非同期処理として使用
console.log("Number 3"); // 非同期な処理なので、この行は同期処理が全て実行された後に実行される
const p = "Hello, World!";
resolve(p);
}, 0); // setTime関数の第二引数に0を指定したため、数値上timer関数の待ち時間は存在しない
});
};
console.log("Number 1"); // 一番初めに実行される
const P = timer(); // 非同期処理を行う関数の呼び出し
P.then((result) => {
console.log(result); // 非同期処理が解決した後に実行される
});
console.log("Number 2"); // 非同期処理の完了を待たずに、同期処理であるこのプログラムが実行される
// 出力順
Number 1
Number 2
Number 3
Hello, World!
2, JavaScriptの非同期処理でよく見る「Promise」とは何か?
Promiseの正体は、非同期処理の解決(成功 or 失敗) + その結果を扱うことのできるオブジェクトです。
Promiseを利用したプログラムは、Promiseを定義した時点では処理がすぐに実行されるわけではありません。Promiseが解決(成功または失敗)したタイミングで、続きの処理が実行されます。
Promise登場以前の非同期処理の書き方
Promiseの使い方について説明する前に少し回り道になりますが、Promiseが登場する以前の非同期処理の書き方を知っておくと理解が深まるため、その点について説明します。
Promiseが登場する以前は、非同期処理に直接コールバック関数を渡す書き方が一般的でした。
非同期処理が解決する(成功 or 失敗の判明)と、その結果をコールバック関数が受け取り、処理していました。
下記は、コールバック関数を使った非同期処理の例です。
// コールバック関数ベースの非同期処理
export const timer = (num: number, callback: (p: string) => void) => {
setTimeout(() => {
const p = "Hello, World!";
callback(p); // 非同期処理が完了したら、結果をコールバック関数に渡す
}, num);
};
// timerを呼び出し、コールバック関数を渡す
const x = timer(3000, (result) => {
console.log(result); // 3秒後に "Hello, World!" が出力される
});
しかし、コールバック関数ベースの非同期処理には「可読性が低い」「ネストが深くなった際に、コードを理解するのが難しい」といったデメリットがありました。
Promiseを使った非同期処理の書き方
そこでPromiseを使った非同期処理では、従来の「非同期処理に直接コールバック関数を渡し、完了後にその関数を実行する」という一体化された流れを
「非同期処理の解決時にPromiseオブジェクトを返す」
「そのPromiseに対しての処理を定義する(従来のコールバック関数の役割)」
という2つの工程に分離することで、この課題を解決しています。
// timer関数は、非同期な処理(setTimeout)が解決したとき、Promiseを返す
const timer = (num: number) => {
return new Promise((resolve) => {
setTimeout(() => {
const p = "Hello, World!";
resolve(p); // 非同期処理の完了時、Promiseを「成功(resolve)」として返す
}, num);
});
};
//timerを呼び出し、返り値のPromiseをPに代入
const P = timer(3000);
P.then((result) => { // Pが成功したときの処理
console.log(result);
}).catch((error) => { // Pが失敗したときの処理
console.error(error);
});
返ってきたPromiseオブジェクトはそのままでは使えず、.thenメソッドや.catchメソッドを使って成功時、失敗時に応じた中身の処理を行う必要があります。
この時、成功時の処理だけではなく失敗時の処理を書かなければエラーになってしまうため、必ず両方書く必要があります。
3, async / awaitとは何か?
async / awaitは、Promiseをベースとした「非同期処理を、「上から順に実行されるように」見えるコードとして書ける方法」です。
async / awaitを使うことにより、Promiseによる記述だけだと、thenやcatchで複雑になりがちだった非同期処理のネストが、シンプルに記述できるようになります。
asyncについて
asyncは関数宣言の先頭につけることで、その関数がPromiseオブジェクトを返すようになります。(async function宣言と呼ぶ)
例として、下記の関数は返り値として3を返します。asyncを付けない場合は返り値の型がnumberになるはずですが、asyncを付けることによって、返り値がPromise
awaitについて
一方、ややこしいのはawaitです。
awaitはasync関数の中でのみ実行でき、Promiseオブジェクトが解決するまでasync関数を中断し、次の同期的な処理の実行に移るといった特徴があります。
await Promiseオブジェクトを返す式
と宣言します。
const timer = (num: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Number 4"); // 非同期な処理なので、この行は同期処理が全て実行された後に実行される
const p = "Hello, World!";
console.log(p);
resolve();
}, num);
});
};
async function returnHello() {
console.log("Number 2"); // 関数呼び出しの際に実行される
await timer(3000); // awaitを使用してtimer()の返り値のPromiseの解決を待つため、returnHello()を中断
console.log("Number 5"); //timerの解決後に実行される
}
console.log("Number 1"); // 一番最初に実行される
returnHello(); // 関数呼び出し
console.log("Number 3"); //returnHello()が中断されたので、次の同期処理であるこの行が実行される
// 出力順
Number 1
Number 2
Number 3
Number 4
Hello, World!
Number 5
awaitには上記の機能だけでなく、「成功時の戻り値(解決値)を取り出す」という機能もあります。
ただし、「2.JavaScriptでよく見る「Promise」とは何か?」 の項目で説明したように、Promise には成功時と失敗時の両方の処理を記述する必要があります。
await単体では成功時の処理しか扱えないため、失敗時の処理をtry-catch文を使って記述する必要があります。
それを踏まえて先ほどの文をみると、timer関数が失敗したときの処理が書かれていないため、不完全なコードでした。
timer関数が失敗したときの処理を書くと、下記のようになります。
// コールバック関数ベースの非同期処理
const timer = (num: number): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
const p = "Hello, World!";
resolve(p);
}, num);
});
};
async function returnHello() {
try {
console.log(await timer(3000)); // awaitを使用して、Promiseが解決されるのを待つ。これにより、非同期処理が完了するまで次の行は実行されない。
} catch (error) {
console.error("Error:", error);
}
}
returnHello();
ここまで読むと「returnHello関数もasync functionなのだから、returnHelloの返り値のPromiseにも失敗時の処理が必要なのではないか?」と思われるかもしれません。
しかし、ネストの最上位のasync関数は、その関数内でtry…catch文でエラーの処理をしていると「もうこれ以上例外が起きることはない」と判断し、返り値のPromiseに失敗時の処理を書く必要はなくなるのです。
最後に
非同期処理の実行タイミングのイメージがつかめるまでは、非同期という概念がなかなか腑に落ちなかったのですが、そこが分かると一気に理解が深まったように思います。
今回のような、ぼんやりしていた部分がふと分かる瞬間があるのが、プログラミングの面白さだと思いますね。
まだまだ知らないことだらけで毎日が勉強の連続ですが、めげずに頑張っていきたいです。
【参考文献】
ところで
弊社ロジカルスタジオでは、一緒に働く仲間を募集しています!
下記リンクからぜひご応募ください!