JavaScriptは同期処理と非同期処理を扱うことができる言語です。同期処理と非同期処理について、調べたことをまとめました。
同期処理とは
同期処理とは、サーバーやブラウザで、処理が順番に実行されることをいいます。
同期処理では、1つの処理が終了するまで、次の処理が開始されません。コードを上から下に向かって、順番に実行し、あるコードの実行が終わってから、次のコードに移ります。
このため、大量のデータを処理するような場合、ブラウザがフリーズしたり、ページの読み込みが遅くなったりすることがあります。
例えば、下記のコードでは、3つの「console.log」の呼び出しが、同期的な処理として、順番に実行されます。
console.log("Started");
console.log("Completed 1");
console.log("Completed 2");
出力は次のようになります。
Started
Completed 1
Completed 2
非同期処理とは
上でも説明したように、同期処理は、1つの処理が終了するまで次の処理を待機する方法を指しますが、非同期処理は、1つの処理が終了するまで待たずに、次の処理を実行する方法を指します。
例えば、大量のデータをサーバーから取得しながら、画面上に表示する処理を行いたい場合、同期処理だと、データの取得が終わるまで画面に表示されなかったり、データの取得が遅い場合、ユーザーの操作がブロックされてしまいます。しかし、非同期処理を使えば、データ取得と画面の表示を同時に実行することができます。
これにより、ブラウザのパフォーマンスが向上し、ユーザーが待たされることがなくなります。
どのようなシーンで非同期処理が使われるのか
少しイメージしづらいので、非同期処理を使用する具体的な例を挙げようと思います。
- サーバーからデータを取得するとき
- ファイルのアップロードやダウンロードを行うとき
- 外部のAPIやサービスからデータを取得するとき
- 長い時間がかかる処理(例:大量の計算やデータベースのクエリー)を実行するとき
- ユーザーアクションに応じて動的なコンテンツを生成するとき
このようなタスクは同期的に実行すると、画面がフリーズしたりして、ユーザーが困惑します。非同期処理を使用することで、これらのタスクをバックグラウンドで実行することが可能になり、ユーザーはスムーズな操作を維持することができるようになります。
このように、非同期処理には、ユーザーを待たせないというメリットがあります。
非同期処理を使う方法
JavaScriptで非同期処理を行う方法には、以下の3つがあります。どの方法でも、非同期処理ができます。
- Callback関数
- Promise
- async/await
それぞれの違いと特徴
Callback関数とは、特定の処理が完了した後に呼び出される関数のことです。例えば、A関数が実行されるとB関数が呼び出される、といった形式です。
Promiseは、処理の結果を受け取ることができます。成功した場合、失敗した場合の結果を扱うことができます。これにより、非同期処理の結果に応じて処理を制御することができます。
async/awaitは、非同期処理を同期的な記述スタイルで行うことができる比較的新しい構文です。async/awaitを利用することで、非同期処理をより簡潔に記述することができます。
Callback関数の例
Callback関数の例を作成してみました。
callback
const doFirstTask = (value, callback) => {
setTimeout(() => {
if(!value) {
console.error("First task is not done");
callback(null);
}else{
console.log("First task is done with value: ", value);
callback(value + 1);
}
}, 1000);
};
const doSecondTask = (value, callback) => {
setTimeout(() => {
if(!value) {
console.error("Second task not done");
callback(null);
}else{
console.log("Second task is done with value: ", value);
callback(value + 1);
}
}, 2000);
};
const doThirdTask = (value, callback) => {
setTimeout(() => {
if(!value) {
console.error("Third task not done");
callback(null);
}else{
console.log("Third task is done with value: ", value);
callback(value + 1);
}
}, 3000);
};
const runTasks = (value) => {
if (!value) {
console.error("Error: input value is null");
return;
}
doFirstTask(value, (firstTaskValue) => {
doSecondTask(firstTaskValue, (secondTaskValue) => {
doThirdTask(secondTaskValue, (thirdTaskValue) => {
console.log("All tasks done with final value: ", thirdTaskValue);
});
});
});
};
//成功
runTasks(1);
//失敗
runTasks(0);
runTasks(null);
下記のコールバック関数を実行するコードは、3つの関数(doFirstTask、doSecondTask、doThirdTask)が連続して呼び出されています。各関数は呼び出された結果を受け取り、次の関数に結果を渡すという形で、連続的に処理が実行されています。
const runTasks = (value) => {
if (!value) {
console.error("Error: input value is null");
return;
}
doFirstTask(value, (firstTaskValue) => {
doSecondTask(firstTaskValue, (secondTaskValue) => {
doThirdTask(secondTaskValue, (thirdTaskValue) => {
console.log("All tasks done with final value: ", thirdTaskValue);
});
});
});
};
コードを見るとわかるように、コールバックのネスト(階層)が深くなっています。コールバックのネストが深くなると、コードの読み手にとって、処理の流れが分かりづらくなります。
また、エラーが発生した際のデバッグが難しくなる可能性もあります。このような、コールバックのネストが深くなることを「コールバック地獄」と呼ぶそうです。
成功時の出力は下記のようになります。
First task is done with value: 1
Second task is done with value: 2
Third task is done with value: 3
All tasks are done with final value: 4
イベントリスナーもコールバック関数の一種
クリックイベントなどでよく利用されるイベントリスナーもコールバック関数の一種です。イベントリスナーは、特定のイベントが発生したときに呼び出される関数です。
document.querySelector('.button').addEventListener('click', callback);
function callback(e) {
console.log('Clicked!');
}
Promiseの例
先程のCallback関数をPromiseで書いてみます。
const doFirstTask = (value) => {
return new Promise((resolve,reject) => {
setTimeout(() => {
if(!value){
console.error("First task not done");
reject(null);
}else{
resolve(value + 1);
console.log("First task is done with value: ", value);
}
}, 1000);
});
}
const doSecondTask = (value) => {
return new Promise((resolve,reject) => {
setTimeout(() => {
if(!value){
console.error("Second task not done");
reject(null);
}else{
console.log("Second task is done with value: ", value);
resolve(value + 1);
}
}, 2000);
});
}
const doThirdTask = (value) => {
return new Promise((resolve) => {
setTimeout(() => {
if(!value){
console.error("Third task not done");
reject(null);
}else{
console.log("Third task is done with value: ", value);
resolve(value + 1);
}
}, 3000);
});
}
doFirstTask(1)
.then((firstValue) => doSecondTask(firstValue));
.then((secondValue) => doThirdTask(secondValue));
.then((thirdValue) => console.log("All tasks are done with final value: ", thirdValue));
.catch((error) => console.error(error));
実行する部分が、かなり、わかりやすくなったと思います。
doFirstTask(1)
.then((firstValue) => doSecondTask(firstValue));
.then((secondValue) => doThirdTask(secondValue));
.then((thirdValue) => console.log("All tasks are done with final value: ", thirdValue));
.catch((error) => console.error(error));
async/awaitを使った例
最後に、async/awaitを使った例です。
const doFirstTask = async (value) => {
return new Promise((resolve,reject) => {
if(value < 1){
console.error("First task not done");
reject(null);
}else{
resolve(value + 1);
console.log("First task is done with value: ", value);
}
});
}
const doSecondTask = async (value) => {
return new Promise((resolve ,reject) => {
if(!value){
console.error("Second task not done");
reject(null);
}else{
console.log("Second task is done with value: ", value);
resolve(value + 1);
}
});
}
const doThirdTask = async (value) => {
return new Promise((resolve) => {
if(!value){
console.error("Third task not done");
reject(null);
}else{
console.log("Third task is done with value: ", value);
resolve(value + 1);
}
});
}
const runTasks = async (value) => {
try {
if(!value){
console.error("Error: input value is null");
return;
}
const firstValue = await doFirstTask(value);
const secondValue = await doSecondTask(firstValue);
const thirdValue = await doThirdTask(secondValue);
console.log("All tasks are done with final value: ", thirdValue);
} catch (error) {
console.error(error);
}
}
//成功
runTasks(0);
//失敗
runTasks(0);
Promiseも、割とスマートではありますが、async/awaitを使うと、非同期処理を同期処理のようにさらにシンプルに書くことができます。
どこかの処理で、rejectされた場合は、catch節に処理が移り、後続の処理はストップします。catch節では、rejectで意図した独自のエラーも、意図していないエラーもキャッチしますので、エラーの内容に応じて、処理をわけたりする必要があります。
const runTasks = async (value) => {
try {
const firstValue = await doFirstTask(value);
const secondValue = await doSecondTask(firstValue);
const thirdValue = await doThirdTask(secondValue);
console.log("All tasks are done with final value: ", thirdValue);
} catch (error) {
console.error(error);
}
}
終わりに
今回は、JavaScriptの同期処理と非同期処理について、調べたことをまとめてみました。次回は、async/awaitで非同期処理と、try catchを使ってエラー処理をする方法についてもまとめてみたいと思います。