この記事では、「非同期処理」および、非同期処理に関連する概念である、「コールバック関数」「Promise」「Async/await」について、図を交えつつ分かりやすく解説します。
できるだけ平易な言葉で解説するので、JavaScriptの非同期処理を理解するための一助となれば幸いです。
では始めます!
Node:v18.9.0
TL;DR
- 非同期処理とは何か?なぜ必要か?
- 非同期処理とは、「並列に実行される処理」のこと
- 非同期処理を利用することで処理の効率が上がる
- JSにおいて非同期処理は、「コールバック関数」「Promise」「Async/await」を使って記述することができる
- コールバック関数の可読性を向上させたのがPromiseで、Promiseの可読性(記述容易性)を向上させたのがAsync/await
- 基本的にはAsync/awaitを使うことが多い
非同期処理とは何か?なぜ必要なのか?
そもそも「非同期処理」とは何でしょうか?
非同期処理とは、「並列に実行される処理」のことです。
例えば、父親とあなたが家事をしている場面を想定しましょう。
あなたのタスクは掃除で、父親のタスクは料理です。
このとき、あなたが掃除をした後に父親が料理をすれば、それは「同期処理」になります。
一方で、あなたが掃除をしている間に父親が料理をすれば、それは「非同期処理」になります。
非同期処理の場合は、タスクが「並列」に実行されていることが分かるでしょう。
では、「非同期処理」はなぜ必要なのでしょうか?
それは、「非同期処理の方が圧倒的に効率がいいから」です。
例えば、先ほどの家事の例で、掃除と料理にそれぞれ30分かかるとします。
この場合、同期処理だと、合計で1時間かかってしまいます。
一方、非同期処理の場合は、30分で完了します。
これだけでも非同期処理の方が効率がいいことが分かるでしょう。
家事をWebサービスに置き換えても同じです。
Webページを表示させるまでに、「API経由でデータを取得する処理」と「データベースからデータを取得する処理」の二つが必要と仮定します。
それぞれ10秒ずつ処理に時間がかかるとすると、同期処理の場合は、画面を表示させるまでに20秒かかってしまいます。
一方で非同期処理の場合は10秒です。
Webサービスのユーザー体験(およびSEO)を良くする上で、「速度」はとても重要です。
その意味で、「非同期処理」の需要は高まり続けているのです。
コールバック関数を用いた非同期処理の実行
では、具体的にどのように非同期処理を記述すれば良いのでしょう?
最初に登場するのは、「コールバック関数」を使う方法です。
コールバック関数とは、「関数の引数に渡される関数」のことです。
JavaScriptでは、例えば setTimeout は非同期処理を実行する関数として知られていて、第二引数に渡した時間(ms)後に、第一引数に渡したコールバック関数の処理が実行されます。
ここで先ほどの家事の例を思い出しましょう。
掃除と料理でそれぞれ10秒かかる場合、非同期でそれぞれの処理を実行するには、以下のコードを記述します。
setTimeout(() => { console.log('掃除が完了しました!'); }, 10000) setTimeout(() => { console.log('料理が完了しました!'); }, 10000)
このコードを実行すると、それぞれのsetTimeoutが非同期(並列)に実行され、10秒後に同時に「掃除が完了しました!」と「料理が完了しました!」が表示されます。
同時に結果が表示されることから、非同期に処理が実行されていることが分かるでしょう。
次に、少し違う状況を想像しましょう。
この日はあなたが掃除をして父親が料理をするのは変わりませんが、あなたの妹が父親の料理を手伝うことになりました。
料理の父と妹の役割分担はそれぞれ以下の通りです。
- 娘:肉を切る(4秒)
- 父:肉を炒める(3秒)
- 娘:肉に塩胡椒を振る(1秒)
- 父:肉をお皿に盛る(2秒)
ここで問題になるのは、「料理と掃除は同時並行で実行したいけど、料理の中の行程は同期的に実行する必要がある」点です。
このような場合、以下のように記述してしまっては、料理の順番がめちゃくちゃになってしまいます。
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) setTimeout(() => { console.log('娘:肉を切り終えた'); }, 4000) setTimeout(() => { console.log('父:肉を炒めた'); }, 3000) setTimeout(() => { console.log('娘:肉に塩胡椒を振った'); }, 1000) setTimeout(() => { console.log('父:肉をお皿に盛った'); }, 2000)
これを実行すると、以下の順に出力されてしまいます。
- 娘:肉に塩胡椒を振った
- 父:肉をお皿に盛った
- 父:肉を炒めた
- 娘:肉を切り終えた
- あなた:掃除が完了しました!
このように、「非同期処理を実行しつつ、一部では処理の順番を指定したい」場合は、以下のように記述します。
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) setTimeout(() => { console.log('娘:肉を切り終えた'); setTimeout(() => { console.log('父:肉を炒めた'); setTimeout(() => { console.log('娘:肉に塩胡椒を振った'); setTimeout(() => { console.log('父:肉をお皿に盛った'); }, 2000) }, 1000) }, 3000) }, 4000)
このように書くことで、以下のように、料理と掃除は非同期的に実行しつつ、料理の中の行程は同期的に(指定した順番通りに)実行することができます。
- 娘:肉を切り終えた
- 父:肉を炒めた
- 娘:肉に塩胡椒を振った
- あなた:掃除が完了しました!
- 父:肉をお皿に盛った
ただ、ここで一つ問題があります。
コールバックのネストが深いため、とてもコードが読みづらい点です。
setTimeout(() => { console.log('娘:肉を切り終えた'); setTimeout(() => { console.log('父:肉を炒めた'); setTimeout(() => { console.log('娘:肉に塩胡椒を振った'); setTimeout(() => { console.log('父:肉をお皿に盛った'); }, 2000) }, 1000) }, 3000) }, 4000)
一般にこのようなコードは「コールバック地獄(Callback Hell)」と呼ばれて敬遠されています。
そして、このような問題点を解消するために登場したのが、次に紹介する Promise という概念です。
Promiseはコールバック地獄を解消する!
Promiseとは、日本語で「約束する」という意味です。
Promiseは、一言でいうと、「後で結果を返す”約束”を必ず守るオブジェクト」です。
例えるなら、宝くじのようなものです。
宝くじは、外れるか当たるかは分かりますせんが、一定時間後に必ず結果を返すという「約束」を果たしてくれます。
Promiseも同様で、Promiseオブジェクト生成後に、引数に指定した処理が成功(resolve)したか失敗(reject)したかの結果を返す「約束」を果たしてくれます。
さらに、thenメソッドで処理が成功(resolve)した後の処理を、catchメソッドで処理が失敗(reject)した後の処理を指定することができます。
Promiseの基本的な記法は以下の通りです。
const promiseObj = new Promise((resolve, reject) => { // 何かしらの処理 resolve('処理に成功しました'); reject('処理に失敗しました'); }).then((resolveMessage) => { console.log(resolveMessage) }).catch((rejectMessage) => { console.log(rejectMessage) })
コールバック関数で何らかの処理を実行したあとに、resolveを実行することで、引数に渡した文字列がthen関数の引数に渡されて、「処理に成功しました」と表示されます。
const promiseObj = new Promise((resolve) => { // 何かしらの処理 resolve('処理に成功しました'); }).then((resolveMessage) => { console.log(resolveMessage) }).catch((rejectMessage) => { console.log(rejectMessage) })
逆にrejectを実行すると、引数に指定した文字列がcatchの引数に渡されて、「処理に失敗しました」と表示されます。
const promiseObj = new Promise((resolve, reject) => { // 何かしらの処理 reject('処理に失敗しました'); }).then((resolveMessage) => { console.log(resolveMessage) }).catch((rejectMessage) => { console.log(rejectMessage) })
このように、状態(resolve,reject)に応じて実行する処理を指定することができるオブジェクト(Promise)を利用することで、以下のコールバック地獄のコードは、より簡潔な形で書き直すことができます。
【Before:コールバック地獄のコード】
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) setTimeout(() => { console.log('娘:肉を切り終えた'); setTimeout(() => { console.log('父:肉を炒めた'); setTimeout(() => { console.log('娘:肉に塩胡椒を振った'); setTimeout(() => { console.log('父:肉をお皿に盛った'); }, 2000) }, 1000) }, 3000) }, 4000)
【After:Promiseを使うことで読みやすくなったコード】
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) new Promise((resolve) => { setTimeout(() => { console.log('娘:肉を切り終えた'); resolve() }, 4000) }).then(() => { return new Promise((resolve) => { setTimeout(() => { console.log('父:肉を炒めた'); resolve() }, 3000) }) }).then(() => { return new Promise((resolve) => { setTimeout(() => { console.log('娘:肉に塩胡椒を振った'); resolve() }, 1000) }) }).then(() => { setTimeout(() => { console.log('父:肉をお皿に盛った'); }, 2000) })
Promiseのコールバック関数の中でsetTimeoutを実行し、resolveしたあとに次の処理をthenで呼び出すことで、「非同期処理の中での処理順の制御」を実現しています。
さらに、共通部分をメソッド化して見やすくすると、以下のようになります。
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) const cook = (message, time) => { return new Promise((resolve) => { setTimeout(() => { console.log(message); resolve() }, time) }) } cook('娘:肉を切り終えた', 4000) .then(() => { return cook('父:肉を炒めた', 3000) }).then(() => { return cook('娘:肉に塩胡椒を振った', 1000) }).then(() => { return cook('父:肉をお皿に盛った', 2000) })
だいぶ読みやすくなりました。
このように、Promiseを使うことで、コールバック地獄が解消され、コードの可読性が上がります。
しかし、何度もthenで繋ぐのは相変わらず面倒ですし、普通に同期処理を書くときの楽さには及びません。
では、どうすれば非同期処理を同期処理のように簡潔に記述できるのでしょうか?
ここで登場するのが、Async/awaitです。
Async/awaitを使うことで、同期処理のように非同期処理を記述できる
Async/await(エイシンク/アウェイト)は、Promiseのシンタックスシュガー(syntax sugar・糖衣構文)と言って良いでしょう。
つまり、Promiseをより読みやすく、書きやすくした記法です。
実際に先ほどのコードがどれだけ簡潔になるか見てみましょう。
Async/awaitを使うと、先ほどのコードは以下のように書き直せます。
【Before:thenのメソッドチェーンを使ったコード】
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) const cook = (message, time) => { return new Promise((resolve) => { setTimeout(() => { console.log(message); resolve() }, time) }) } cook('娘:肉を切り終えた', 4000) .then(() => { return cook('父:肉を炒めた', 3000) }).then(() => { return cook('娘:肉に塩胡椒を振った', 1000) }).then(() => { return cook('父:肉をお皿に盛った', 2000) })
【After:Async/awaitを使ったコード】
setTimeout(() => { console.log('あなた:掃除が完了しました!'); }, 10000) const cook = (message, time) => { return new Promise((resolve) => { setTimeout(() => { console.log(message); resolve() }, time) }) } async function executeCooking() { await cook('娘:肉を切り終えた', 4000) await cook('父:肉を炒めた', 3000) await cook('娘:肉に塩胡椒を振った', 1000) await cook('父:肉をお皿に盛った', 2000) } executeCooking();
thenを使った処理を、asyncとawaitを使った処理に書き換えています。
asyncは、「非同期関数」を表すキーワードです。
さらに、asyncの中ではawaitを使って処理を記述しています。
awaitは、asyncの中でのみ使える構文で、指定したPromiseの結果(resolveかrejectか)が分かるまで、次の行の処理の実行を待ってくれます。
大雑把に言うと、「then = await」と捉えると分かりやすいでしょう。
このように、非同期処理を実行するasync関数の中でawaitを適切に使用することで、非同期処理の中の処理の順序を制御することができます。
Async/awaitは非同期処理を同期処理のように簡潔に記述できるとても便利な構文です。
実務でもコールバック関数やthenのメソッドチェーンを使って非同期処理を実装するよりAsync/awaitを使って記述することの方が多いので、何度も書いて慣れておくと良いでしょう。
【JS】非同期処理(コールバック関数・Promise・Async/await)を今度こそ完全に理解する おわりに
今回は、JavaScriptの非同期処理について、図や例を交えて解説しました。
最後にまとめをもう一度貼っておきます。
- 非同期処理とは何か?なぜ必要か?
- 非同期処理とは、「並列に実行される処理」のこと
- 非同期処理を利用することで処理の効率が上がる
- JSにおいて非同期処理は、「コールバック関数」「Promise」「Async/await」を使って記述することができる
- コールバック関数の可読性を向上させたのがPromiseで、Promiseの可読性(記述容易性)を向上させたのがAsync/await
- 基本的にはAsync/awaitを使うことが多い
この記事が非同期処理に対する理解を深める上で少しでも参考になっていれば幸いです。
最後まで読んでいただきありがとうございました!