JavaScript基礎(6)~非同期処理~

JavaScriptは、非同期処理を得意とする言語です。
非同期処理を理解することで、ネットワーク通信やタイマーなどの複雑な動作を効率よく処理できるようになります。
本記事では、非同期処理の基本から最新の実装方法まで、具体的なコード例を交えながら解説します。

1. 非同期処理の必要性

JavaScriptがシングルスレッドで動作する仕組み

JavaScriptは、シングルスレッドで動作するプログラミング言語です。
これは、同時に1つのタスクしか実行できないという意味です。
この仕組みは「イベントループ」によって制御されています。
イベントループは、実行中のコード(主に同期処理)が完了するのを待ち、それから次のタスクを処理するように設計されています。

この特性はシンプルな動作を保証しますが、次のような問題を引き起こします。

時間のかかる処理によるブロックの問題

もしJavaScriptが長時間実行されるタスク(例: 大量の計算や外部データの取得)に取り組む場合、その間は他のタスクを処理できません。
この状態をブロッキングと呼びます。ユーザーインターフェース(UI)の応答が遅くなったり、アプリケーションがフリーズしたように見える原因になります。

ブロッキングの具体例

function heavyTask() {
  // 大量の計算を行う処理
  for (let i = 0; i < 1e9; i++) {
    // 時間のかかるループ処理
  }
  console.log("タスクが完了しました");
}

// UI操作
console.log("ボタンがクリックされました");
heavyTask();
console.log("次の処理に進みます");

上記のコードでは、heavyTaskが完了するまで次の処理が実行されません。
この間、UIも応答しなくなり、ユーザーエクスペリエンスが大きく損なわれます。

非同期処理でブロッキングを回避

非同期処理を活用することで、この問題を回避できます。
非同期処理を使うと、長時間かかる処理を「バックグラウンドで実行」する形になります。
JavaScriptはこの間に他のタスク(UIの操作や別の計算処理など)を並行して処理できます。

非同期処理の具体例

以下は、非同期処理を用いてAPIデータを取得するコード例です。

console.log("APIリクエストを送信中...");

setTimeout(() => {
  console.log("サーバーからデータを受信しました");
}, 2000); // 2秒間待機

console.log("次の処理を開始します");

出力結果は次のようになります:

APIリクエストを送信中...
次の処理を開始します
サーバーからデータを受信しました

非同期処理を使うと、データの受信を待つ間も他の処理を進められます。
このように、非同期処理はスムーズなユーザー体験を保証する重要な役割を果たします。

実際のユースケース

非同期処理が特に役立つ具体例を挙げてみましょう。

  1. ネットワーク通信
    • サーバーからデータを取得するAPIリクエスト(fetchやXMLHttpRequest)

    • クラウドストレージにファイルをアップロード

    • WebSocketを使ったリアルタイム通信
  2. ユーザーインターフェースの更新
    • ボタンをクリックした後、ユーザーにフィードバックを表示(例: スピナーやロード中のメッセージ)

    • フォーム送信時に入力内容をサーバーへ送信
  3. 時間がかかる処理の分散
    • 動画や画像の加工

    • 複雑な計算やシミュレーション

非同期処理がもたらすメリット

  • スムーズな操作性:
    ユーザーがアプリケーションを操作する際にフリーズすることがない。
  • 効率的なリソース使用:
    処理が完了するまで待機せず、他のタスクを並行して進められる。
  • 優れたユーザーエクスペリエンス:
    タスクがバックグラウンドで処理されるため、操作が快適。

2.コールバック関数

コールバック関数とは?

コールバック関数とは、ある処理が完了した後に実行される関数です。
非同期処理を実現するための基本的な仕組みで、処理の流れを柔軟に制御できます。
JavaScriptでは、関数が一級オブジェクト(関数を他の関数に引数として渡せる)であるため、コールバック関数を簡単に実装できます。

コールバック関数の仕組み

  1. 非同期処理の終了を検知
    コールバック関数を指定することで、時間のかかる処理が終わったときにその結果を取得したり、次の処理を実行したりできます。
  2. 引数として渡す
    コールバック関数は、別の関数の引数として渡されることで動作します。

実際の動作例

以下のコードは、非同期処理でコールバック関数を使用するシンプルな例です。

コールバック関数を利用した非同期処理

function fetchData(callback) {
  setTimeout(() => {
    console.log("データを取得しました");
    callback("取得したデータ");
  }, 2000); // 2秒後に実行
}

// fetchDataを呼び出し、結果をコールバック関数で処理
fetchData((data) => {
  console.log("コールバック内で処理: ", data);
});

実行結果:

データを取得しました
コールバック内で処理: 取得したデータ

  • fetchData関数では、2秒後に「データを取得しました」と表示され、次に渡されたコールバック関数が実行されます。
  • コールバック関数により、非同期処理の結果を効率よく処理できます。

コールバック関数のメリット

  • 柔軟性:
    非同期処理の完了タイミングで実行する処理を自由に設定可能。
  • 軽量な実装:
    特別な構文を必要とせず、シンプルに非同期処理を実現。

コールバック地獄とは?

コールバック関数が複数ネストすると、コードの可読性が低下する問題が生じます。
これを「コールバック地獄」または「ピラミッドコード」と呼びます。

問題例: 多重コールバック

function fetchUserData(userId, callback) {
  setTimeout(() => {
    console.log(`ユーザー${userId}のデータを取得しました`);
    callback({ userId, name: "Alice" });
  }, 1000);
}

function fetchUserPosts(user, callback) {
  setTimeout(() => {
    console.log(`ユーザー${user.name}の投稿を取得しました`);
    callback(["投稿1", "投稿2"]);
  }, 1000);
}

function fetchComments(post, callback) {
  setTimeout(() => {
    console.log(`${post}のコメントを取得しました`);
    callback(["コメント1", "コメント2"]);
  }, 1000);
}

// 多重コールバックの例
fetchUserData(1, (user) => {
  fetchUserPosts(user, (posts) => {
    fetchComments(posts[0], (comments) => {
      console.log("コメント: ", comments);
    });
  });
});

実行結果:

ユーザー1のデータを取得しました
ユーザーAliceの投稿を取得しました
投稿1のコメントを取得しました
コメント:  [ 'コメント1', 'コメント2' ]

  • 処理がネストされ、複雑になるにつれてコードが見づらくなります。
  • デバッグや保守が難しくなり、エラーハンドリングも複雑になります。

コールバック地獄を解決する方法

この問題を解決するために、JavaScriptには以下の仕組みが導入されました。

  1. Promise:
    コールバックをチェーン式に管理することでコードのネストを減少。
  2. async/await:
    非同期処理を同期処理のように記述でき、さらに可読性が向上。

これらの仕組みについては次のセクションで詳しく解説します。

3.Promise

Promiseとは?

Promiseは、非同期処理の結果を表すJavaScriptのオブジェクトです。
非同期処理の状態や結果を効率よく管理でき、コールバック地獄を解消する手段として登場しました。

Promiseは、次の3つの状態を持ちます:

  1. Pending(保留中):
    非同期処理がまだ完了していない状態。
  2. Fulfilled(成功):
    非同期処理が成功し、結果が得られた状態(resolve関数が呼ばれる)。
  3. Rejected(失敗):
    非同期処理が失敗し、エラーが発生した状態(reject関数が呼ばれる)。

Promiseの基本的な構文

const promise = new Promise((resolve, reject) => {
  // 非同期処理を実行
  const isSuccess = true; // 処理結果
  if (isSuccess) {
    resolve("成功しました!"); // 成功時
  } else {
    reject("失敗しました..."); // 失敗時
  }
});

// 成功時、失敗時の処理
promise
  .then((result) => {
    console.log("成功: ", result);
  })
  .catch((error) => {
    console.error("失敗: ", error);
  });

  • resolve:
    非同期処理が成功したときに呼び出します。
  • reject:
    非同期処理が失敗したときに呼び出します。
  • then:
    成功時の処理を定義します。
  • catch:
    失敗時の処理を定義します。

実例: Promiseを使った非同期処理

以下は、Promiseを使ったAPIデータ取得の例です。

Promiseの利用例

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = Math.random() > 0.5; // 成功/失敗をランダムに決定
      if (success) {
        resolve("データ取得成功!");
      } else {
        reject("データ取得失敗...");
      }
    }, 2000); // 2秒後に実行
  });
}

// Promiseの実行
fetchData()
  .then((data) => {
    console.log("成功: ", data);
  })
  .catch((error) => {
    console.error("失敗: ", error);
  });

出力例(ランダム):

成功: データ取得成功!

または

失敗: データ取得失敗...

  • resolveが呼ばれるとthenの処理が実行されます。
  • rejectが呼ばれるとcatchが実行されます。

Promiseのメリット

1. 可読性の向上

  • コールバック関数をネストする必要がないため、コードがフラットで読みやすくなります。
  • 処理の流れが視覚的に理解しやすくなります。
比較: コールバック関数 vs Promise

コールバック:

function fetchData(callback) {
  setTimeout(() => {
    callback("データ取得成功");
  }, 1000);
}

fetchData((data) => {
  console.log(data);
  fetchData((data2) => {
    console.log(data2);
    fetchData((data3) => {
      console.log(data3);
    });
  });
});

Promise:

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("データ取得成功");
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log(data);
    return fetchData();
  })
  .then((data2) => {
    console.log(data2);
    return fetchData();
  })
  .then((data3) => {
    console.log(data3);
  });

2. エラーハンドリングが簡単

  • コールバック関数では、エラー処理が分散しやすく複雑になりますが、Promiseではcatchを使って一元的に管理できます。
例: 一括エラーハンドリング

fetchData()
  .then((data) => {
    console.log("成功1: ", data);
    return fetchData();
  })
  .then((data2) => {
    console.log("成功2: ", data2);
    return fetchData();
  })
  .catch((error) => {
    console.error("エラー発生: ", error);
  });

3. 非同期処理の連鎖が可能

  • Promiseはthenをチェーンできるため、複数の非同期処理を直列的に実行するのに適しています。

Promiseの限界と次のステップ

Promiseは便利ですが、次のような場合に冗長になることがあります:

  • 複数の非同期処理を並行して行う場合。
  • thenのチェーンが長くなった場合。

これを解決するために、JavaScriptにはasync/awaitが導入されました。
次のセクションで詳しく解説します。

4. asyncとawait

async/await とは?

async/await は、Promiseを扱うための構文で、非同期処理を同期処理のように直感的に記述できます。この機能はES2017(ES8)で導入され、JavaScriptでの非同期プログラミングを大幅に簡素化しました。

async と await の基本構文

1. async関数

  • asyncキーワードを付けた関数は、常にPromiseを返す関数になります。
  • 関数の戻り値がPromiseでなくても、自動的にPromiseでラップされます。

async function example() {
  return "結果"; // 自動的に Promise.resolve("結果") に変換される
}

example().then((result) => console.log(result)); // "結果"

2. await

  • awaitキーワードは、Promiseが解決するまで処理を一時停止します。
  • awaitはasync関数の中でのみ使用可能です。

async function fetchData() {
  const result = await Promise.resolve("データ取得成功");
  console.log(result);
}

fetchData();

async/await を使った具体例

基本的な例

以下は、async/awaitを使った非同期処理の例です。

async function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("データ取得成功"), 2000); // 2秒後に解決
  });
}

async function main() {
  try {
    console.log("処理を開始します...");
    const data = await fetchData(); // Promiseが解決するまで待機
    console.log("成功: ", data);
  } catch (error) {
    console.error("失敗: ", error);
  }
}

main();

出力例:

処理を開始します...
成功: データ取得成功

  • await fetchData() によって、Promiseが解決されるのを待ってから次の行が実行されます。
  • try-catch構文でエラーを簡単にハンドリングできます。

非同期処理の直列実行

複数の非同期処理を直列(順番に)実行する場合、async/awaitを使うと非常にわかりやすく書けます。

async function task1() {
  console.log("タスク1を開始");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log("タスク1完了");
}

async function task2() {
  console.log("タスク2を開始");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log("タスク2完了");
}

async function main() {
  await task1();
  await task2();
  console.log("全タスク完了");
}

main();

出力例:

タスク1を開始
タスク1完了
タスク2を開始
タスク2完了
全タスク完了

非同期処理の並行実行

直列実行とは異なり、非同期処理を並行して実行したい場合は、Promise.allと組み合わせます。

async function task1() {
  console.log("タスク1を開始");
  await new Promise((resolve) => setTimeout(resolve, 2000));
  console.log("タスク1完了");
}

async function task2() {
  console.log("タスク2を開始");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  console.log("タスク2完了");
}

async function main() {
  await Promise.all([task1(), task2()]); // 並行して実行
  console.log("全タスク完了");
}

main();

出力例:

タスク1を開始
タスク2を開始
タスク2完了
タスク1完了
全タスク完了

  • タスク1とタスク2が並行して実行され、最短のタスク(タスク2)が先に完了します。
  • Promise.allを使用することで、複数の非同期処理を効率よく実行できます。

async/await のメリット

  1. コールバック地獄の解消
    • 非同期処理がネストすることなく、フラットなコードが書ける。

    • コードの可読性が大幅に向上。
  2. 同期処理に近い書き方
    • 非同期処理を同期処理のように記述できるため、処理の流れを直感的に理解できる。
  3. エラーハンドリングが簡単
    • try-catch構文を利用することで、同期処理と同じようにエラーを扱える。

    • 個別の処理や全体のエラーを効率よく管理可能。

例: Promiseチェーン vs async/await

Promiseチェーン:

fetchData()
  .then((data1) => fetchData())
  .then((data2) => fetchData())
  .catch((error) => console.error(error));

async/await:

async function main() {
  try {
    const data1 = await fetchData();
    const data2 = await fetchData();
    const data3 = await fetchData();
  } catch (error) {
    console.error(error);
  }
}

注意点

  1. awaitはasync関数の中でのみ使用可能
    • awaitはasync関数の外で使うとエラーになります。
  2. 非同期処理の並列性を意識
    • awaitを連続して使うと処理が直列実行されるため、並行して処理したい場合はPromise.allを利用するべきです。

まとめ

非同期処理は、モダンなJavaScript開発において欠かせないスキルです。

  • コールバック関数:
    非同期処理の基本だが、複雑化しやすい
  • Promise:
    コールバック地獄を解消する手段
  • async/await:
    Promiseを簡潔に書ける新しい方法

これらを活用することで、効率的で保守性の高いコードを書くことができます。

SHARE
採用バナー