【JavaScript】非同期処理:Promise、async/awaitの書き方

JavaScriptの非同期処理って何?

もくじ

1. はじめに:非同期処理の重要性

1.1 JavaScriptのシングルスレッドモデルとは

JavaScriptはシングルスレッド(1つの実行コンテキスト)で動作するため、同時に1つの処理しか実行できません。そのため、重たい処理や待ち時間が必要な処理があると、UIの描画やユーザー操作に悪影響を及ぼす可能性があります。
たとえば、長時間実行される計算処理が走っている間は、画面が固まってしまい、ユーザー体験が著しく低下します。

1.2 非同期処理が必要となる場面

現代のウェブアプリケーションでは、以下のような場面で非同期処理が必須となります:

  • API通信
    サーバーからのデータ取得や送信には時間がかかるため、待機中に他の処理をブロックしないよう非同期で実行します。
  • タイマー処理
    setTimeoutsetInterval を利用して、一定時間後や周期的に処理を行う場合。
  • ファイル読み込み・書き込み
    ブラウザやNode.jsにおいて、I/O処理は非同期で行うのが一般的です。
// 例:非同期でAPIからデータを取得する例
fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log('取得したデータ:', data))
  .catch(error => console.error('エラー:', error));

1.3 非同期処理を理解しないことによる問題点

非同期処理の仕組みや使い方を正しく理解しないと、以下のような問題に直面する可能性があります:

  • UIのフリーズ
    同期的な重い処理がUIスレッドをブロックして、ユーザー操作に応えられなくなる。
  • バグの原因
    コールバック関数の入れ子構造やエラーハンドリングが不十分なコードは、予期せぬ挙動やデバッグの難易度を高める。
  • メンテナンス性の低下
    非同期処理の設計が雑だと、コードの可読性が低くなり、将来的な修正が困難になる。

2. 非同期処理の基本概念

2.1 同期処理と非同期処理の違い

同期処理は、命令を順番に一つずつ実行する方式です。処理が終わるまで次の命令に進むことができないため、待ち時間がある場合には他の処理がブロックされます。
非同期処理は、命令の実行を待たずに次の処理に進み、処理が完了したら結果を受け取る方式です。これにより、待ち時間中も他の処理が実行され、アプリケーションがスムーズに動作します。

// 同期処理の例(すべての処理が順番に実行される)
console.log('1: 処理開始');
for (let i = 0; i < 1000000000; i++) {
  // 重たい処理
}
console.log('2: 処理終了');

// 非同期処理の例(setTimeoutで待機中も他の処理を実行)
console.log('1: 処理開始');
setTimeout(() => {
  console.log('2: 非同期処理完了');
}, 1000);
console.log('3: 次の処理へ');

2.2 JavaScriptが非同期処理を必要とする理由

JavaScriptの実行環境(特にブラウザ)は、ユーザーインターフェースの応答性が非常に重要です。

  • ユーザー操作の即時反応
    ユーザーがボタンをクリックした際、すぐにフィードバックを返す必要があります。
  • バックグラウンド通信
    サーバーと通信しながらも、画面は操作可能な状態を維持しなければなりません。

これらの理由から、JavaScriptでは非同期処理が多用されるのです。

2.3 ブラウザでの非同期処理の実例

実際のウェブページでは、以下のような非同期処理が行われています:

  • Ajax通信
    ユーザーの操作に応じて部分的にページを更新する際に、バックグラウンドでデータを取得する。
  • アニメーション処理
    リクエストに応じて非同期でアニメーションを開始し、ユーザーの操作感を向上させる。
  • フォームの自動保存
    ユーザーが入力中に、一定時間ごとにデータを非同期でサーバーに送信する。

3. コールバック関数の基本

3.1 コールバック関数とは

コールバック関数は、ある処理が完了した後に呼び出される関数です。非同期処理では、処理が終了したタイミングで結果を受け取るために使われます。
たとえば、setTimeoutfetch などの関数は、完了時にコールバック関数を実行します。

3.2 コールバック関数の書き方

基本的なコールバック関数の例として、setTimeout を使ったコードを見てみましょう。

function greet(name, callback) {
  console.log(`Hello, ${name}!`);
  callback();
}

greet('Alice', function() {
  console.log('コールバック関数が実行されました。');
});

または、ES6のアロー関数を使うこともできます。

greet('Bob', () => {
  console.log('アロー関数によるコールバック実行');
});

3.3 コールバック地獄(Callback Hell)の問題点

コールバック関数を多用すると、以下のような「コールバック地獄」と呼ばれる構造になり、コードの可読性が大幅に低下します。

doSomething(function(result1) {
  doSomethingElse(result1, function(result2) {
    doAnotherThing(result2, function(result3) {
      // さらにネストが深くなる
      console.log('最終結果:', result3);
    });
  });
});

このようなコードは保守やデバッグが非常に困難となるため、より良い非同期処理の手法が求められるようになりました。

3.4 コールバックのエラーハンドリング

コールバック関数では、エラーが発生した場合の処理も重要です。通常、エラーを第一引数として渡す手法が使われます。

function readFile(filePath, callback) {
  // 仮想的なファイル読み込み処理
  let error = null;
  let data = 'ファイルの内容';
  
  // エラーがあった場合は第一引数にエラーオブジェクトを渡す
  if (filePath === '') {
    error = new Error('ファイルパスが無効です');
  }
  
  callback(error, data);
}

readFile('', function(err, data) {
  if (err) {
    console.error('エラー発生:', err.message);
    return;
  }
  console.log('ファイル内容:', data);
});

4. Promise入門

4.1 Promiseとは何か、そのメリット

Promiseは、非同期処理の結果を表すオブジェクトであり、非同期処理をより直感的に記述できるように設計されています。Promiseを利用することで、以下のメリットが得られます:

  • ネストの解消
    コールバック地獄を防ぎ、コードが平坦化される。
  • エラーハンドリングの一元化
    catch を使ってエラー処理が容易になる。

4.2 Promiseの基本構文と状態(pending, fulfilled, rejected)

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

  • pending(保留中):まだ結果が得られていない状態。
  • fulfilled(成功):処理が成功し、結果が返された状態。
  • rejected(失敗):処理が失敗し、エラーが返された状態。
const promise = new Promise((resolve, reject) => {
  const success = true; // 仮の条件
  if (success) {
    resolve('成功しました!');
  } else {
    reject('失敗しました。');
  }
});

promise
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error(error);
  });

4.3 then()、catch()、finally()の使い方

Promiseチェーンにおいて、then() は成功時の処理、catch() はエラー時の処理、finally() は成功・失敗に関わらず必ず実行される処理を定義します。

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => {
    console.log('取得したデータ:', data);
  })
  .catch(error => {
    console.error('エラー発生:', error);
  })
  .finally(() => {
    console.log('処理終了');
  });

4.4 Promiseチェーンでコードを読みやすく

Promiseチェーンを利用することで、非同期処理を連続して実行しやすくなります。各thenで次の処理を記述することで、処理の流れが明確になります。

new Promise((resolve, reject) => {
  setTimeout(() => resolve(1), 1000);
})
  .then(result => {
    console.log(result); // 1
    return result * 2;
  })
  .then(result => {
    console.log(result); // 2
    return result * 3;
  })
  .then(result => {
    console.log(result); // 6
  });

4.5 Promise.all()、Promise.race()の実用例

複数の非同期処理を同時に扱う場合、Promise.all()Promise.race() が便利です。

  • Promise.all()
    複数のPromiseが全て解決されるのを待ち、配列で結果を返します。
const promise1 = new Promise(resolve => setTimeout(() => resolve('A'), 1000));
const promise2 = new Promise(resolve => setTimeout(() => resolve('B'), 1500));

Promise.all([promise1, promise2])
  .then(results => {
    console.log('すべての結果:', results); // ['A', 'B']
  });

Promise.race()
複数のPromiseのうち、最初に解決または拒否されたものの結果を返します。

const promise3 = new Promise(resolve => setTimeout(() => resolve('X'), 2000));
const promise4 = new Promise(resolve => setTimeout(() => resolve('Y'), 1000));

Promise.race([promise3, promise4])
  .then(result => {
    console.log('最初に完了した結果:', result); // 'Y'
  });

5. async/awaitで簡潔に書く

5.1 async/awaitとは

async/await は、Promiseをより直感的に扱える構文です。async キーワードを付けた関数は常にPromiseを返し、await キーワードを使うことでPromiseの解決を待つことができます。
これにより、非同期処理をあたかも同期処理のように書けるため、コードの可読性が向上します。

5.2 基本的な使い方

async/await の基本的な使用例を見てみましょう。

async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    console.log('取得したデータ:', data);
  } catch (error) {
    console.error('エラー発生:', error);
  }
}

fetchData();

5.3 エラーハンドリング(try/catch)

async/await では、通常の同期処理と同様に try/catch を利用してエラー処理が行えます。

async function fetchUser() {
  try {
    const response = await fetch('https://api.example.com/user');
    if (!response.ok) {
      throw new Error('ネットワークエラー');
    }
    const user = await response.json();
    console.log('ユーザー情報:', user);
  } catch (error) {
    console.error('エラー:', error.message);
  }
}

fetchUser();

5.4 Promiseとの併用テクニック

async/await とPromiseのチェーンを併用することで、柔軟な非同期処理が実現可能です。場合によっては、複数のPromiseを並行して実行し、await Promise.all() で結果をまとめることもできます。

async function fetchMultipleData() {
  try {
    const [data1, data2] = await Promise.all([
      fetch('https://api.example.com/data1').then(res => res.json()),
      fetch('https://api.example.com/data2').then(res => res.json())
    ]);
    console.log('Data1:', data1);
    console.log('Data2:', data2);
  } catch (error) {
    console.error('エラー:', error);
  }
}

fetchMultipleData();

5.5 await in loopの注意点

ループ内で await を使う場合、各反復処理が逐次実行されるため、並列実行のメリットが失われることに注意が必要です。並列処理を実現したい場合は、ループでPromiseの配列を作成し、後で Promise.all() を使う方法が推奨されます。

// 悪い例:逐次実行される
for (let i = 0; i < 5; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log(`処理${i}完了`);
}

// 良い例:並列実行
const promises = [];
for (let i = 0; i < 5; i++) {
  promises.push(new Promise(resolve => setTimeout(() => {
    console.log(`処理${i}完了`);
    resolve();
  }, 1000)));
}
await Promise.all(promises);

6. Promise vs async/await:徹底比較

6.1 コードの可読性の違い

Promiseチェーンでは .then().catch() を多用するため、処理の流れが分かりづらくなることがあります。一方、async/awaitは同期処理のように記述できるため、直感的に理解しやすいです。

6.2 エラーハンドリングの違い

  • Promiseの場合
    各チェーンでエラー処理を行う必要があり、見落としやすい。
  • async/awaitの場合
    try/catchブロックで一括してエラー処理ができるため、エラーハンドリングが明確です。

6.3 デバッグのしやすさ

async/awaitはコードの実行順序が同期的に見えるため、デバッグがしやすいという利点があります。しかし、Promiseチェーンも適切にログ出力などを行えば十分に管理可能です。

6.4 パフォーマンスの違い

どちらの手法も非同期処理の仕組み自体は同じPromiseを利用しているため、根本的なパフォーマンスの違いはほとんどありません。
ただし、ループ内でawaitを多用すると、意図せず逐次実行になってしまうため注意が必要です。

6.5 適材適所:使い分けるポイント

  • シンプルな非同期処理や直線的な処理フロー
    → async/awaitを推奨
  • 複雑な条件分岐や並列処理が必要な場合
    → PromiseチェーンやPromise.all()を利用すると効果的

7. 実践的な非同期処理パターン

7.1 APIからのデータ取得

実際のAPI通信での利用例です。以下のコードは、外部APIからユーザー情報を取得し、表示する例です。

async function getUserInfo(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    if (!response.ok) {
      throw new Error('API通信エラー');
    }
    const userData = await response.json();
    console.log('ユーザー情報:', userData);
  } catch (error) {
    console.error('エラー:', error.message);
  }
}

getUserInfo(123);

7.2 複数の非同期処理を順番に実行

一連の非同期処理を順番に実行する方法です。

async function processSequentially() {
  try {
    const step1 = await new Promise(resolve => setTimeout(() => resolve('Step 1 完了'), 1000));
    console.log(step1);
    const step2 = await new Promise(resolve => setTimeout(() => resolve('Step 2 完了'), 1000));
    console.log(step2);
    const step3 = await new Promise(resolve => setTimeout(() => resolve('Step 3 完了'), 1000));
    console.log(step3);
  } catch (error) {
    console.error('エラー:', error);
  }
}

processSequentially();

7.3 複数の非同期処理を並行して実行

Promise.allを利用して複数の非同期処理を同時に実行する例です。

async function processConcurrently() {
  const promises = [
    new Promise(resolve => setTimeout(() => resolve('処理A 完了'), 2000)),
    new Promise(resolve => setTimeout(() => resolve('処理B 完了'), 1500)),
    new Promise(resolve => setTimeout(() => resolve('処理C 完了'), 1000))
  ];
  
  try {
    const results = await Promise.all(promises);
    console.log('並行処理結果:', results);
  } catch (error) {
    console.error('エラー:', error);
  }
}

processConcurrently();

7.4 非同期処理のタイムアウト設定

一定時間経過後に非同期処理を中断するタイムアウト処理の例です。

function fetchWithTimeout(url, timeout = 3000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error('タイムアウトしました'));
    }, timeout);
    
    fetch(url)
      .then(response => {
        clearTimeout(timer);
        resolve(response);
      })
      .catch(error => {
        clearTimeout(timer);
        reject(error);
      });
  });
}

fetchWithTimeout('https://api.example.com/data', 2000)
  .then(response => console.log('レスポンス:', response))
  .catch(error => console.error('エラー:', error.message));

7.5 非同期処理のキャンセル方法(AbortController)

AbortControllerを利用して、非同期通信をキャンセルする方法です。これはユーザーの操作によってリクエストを中断する場合に役立ちます。

async function fetchWithAbort(url) {
  const controller = new AbortController();
  const signal = controller.signal;

  // 一定時間後にリクエストをキャンセルする例
  const timeout = setTimeout(() => controller.abort(), 3000);

  try {
    const response = await fetch(url, { signal });
    clearTimeout(timeout);
    const data = await response.json();
    console.log('取得したデータ:', data);
  } catch (error) {
    if (error.name === 'AbortError') {
      console.error('リクエストは中断されました');
    } else {
      console.error('エラー:', error);
    }
  }
}

fetchWithAbort('https://api.example.com/data');

8. 非同期処理のデバッグとトラブルシューティング

8.1 開発者ツールでの非同期処理の追跡

ブラウザの開発者ツール(Chrome DevToolsなど)を使うと、非同期処理の実行状況を確認できます。

  • ネットワークタブ
    リクエストの送受信状況を確認。
  • Consoleタブ
    ログ出力やエラーをチェックし、非同期処理の流れを追跡する。

8.2 よくあるエラーと解決法

非同期処理で頻繁に発生するエラーとその解決策を以下に示します:

  • ネットワークエラー
    → fetchのレスポンスステータスを確認し、適切なエラーハンドリングを行う。
  • タイムアウトエラー
    → タイムアウトの設定やリトライ処理を実装する。
  • コールバックの未処理エラー
    → 全ての非同期処理に対してエラーハンドリングを実装する。

8.3 パフォーマンス最適化のヒント

非同期処理におけるパフォーマンス最適化のポイントは以下の通りです:

  • 並列処理の活用
    複数のリクエストを並行して実行する。
  • 不要な再レンダリングの回避
    データ取得後、必要な箇所のみ再描画する。
  • キャッシュの利用
    同じリクエストを繰り返さないためにキャッシュを利用する。

9. フレームワークでの非同期処理

9.1 ReactでのuseEffect活用法

Reactでは、useEffect フックを使って非同期処理を実装することが一般的です。以下は、APIからデータを取得し、コンポーネントの状態を更新する例です。

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error('エラー:', error);
      } finally {
        setLoading(false);
      }
    }
    fetchUser();
  }, [userId]);

  if (loading) return <p>読み込み中...</p>;
  if (!user) return <p>ユーザー情報がありません</p>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

9.2 Vue.jsでの非同期データ取得

Vue.jsでは、ライフサイクルフックやコンポーネント内のメソッドを使って非同期処理を行います。以下は、VueコンポーネントでAPIからデータを取得する例です。

<template>
  <div v-if="loading">
    読み込み中...
  </div>
  <div v-else-if="user">
    <h1>{{ user.name }}</h1>
    <p>{{ user.email }}</p>
  </div>
  <div v-else>
    ユーザー情報がありません
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: null,
      loading: true,
    };
  },
  async created() {
    try {
      const response = await fetch(`https://api.example.com/users/${this.$route.params.userId}`);
      this.user = await response.json();
    } catch (error) {
      console.error('エラー:', error);
    } finally {
      this.loading = false;
    }
  },
};
</script>

9.3 モダンフレームワークの非同期処理パターン

ReactやVue.js以外にも、AngularやSvelteなどのモダンなフレームワークは、独自の非同期処理の仕組みを持っています。各フレームワークの公式ドキュメントを参照し、自身のプロジェクトに最適な実装方法を選びましょう。

10. まとめと次のステップ

10.1 非同期処理のベストプラクティス

  • エラーハンドリングを徹底する
    常にtry/catchや.catch()を利用し、予期しないエラーに対応する。
  • 並列処理と逐次処理の使い分け
    必要に応じてPromise.all()を活用し、パフォーマンスを向上させる。
  • コードの可読性を意識する
    コールバック地獄を避けるために、Promiseやasync/awaitを積極的に利用する。

10.2 今日学んだことの復習

本記事では、JavaScriptにおける非同期処理の基本概念から、コールバック関数、Promise、async/awaitの利用方法、さらに各種実践的なパターンとフレームワークでの実装方法について解説しました。
初心者の方でも、実際のコードサンプルを通して非同期処理の流れを理解できるようになることを目指しました。

10.3 より深く学ぶためのリソースと参考資料

10.4 実践的な演習問題

以下の演習問題に挑戦してみましょう:

  1. API通信演習
    任意のAPI(例:JSONPlaceholder)を利用して、データの取得と表示を行うコンポーネントを作成してください。
  2. Promiseチェーンの書き換え
    コールバック関数をPromiseチェーンに書き換えるサンプルコードを実装し、エラーハンドリングを追加してください。
  3. async/awaitとtry/catchの練習
    いくつかの非同期処理を連続して実行し、途中でエラーが発生した場合にcatchブロックで処理を中断するサンプルコードを作成してください。
  4. AbortControllerの実装
    ユーザーの入力に応じて、APIリクエストをキャンセルする仕組みを実装し、キャンセル後の処理を検証してください。

結論

JavaScriptの非同期処理は、シングルスレッド環境においてもユーザー体験を損なわず、効率的なデータ取得や処理の実現に欠かせない技術です。
コールバック関数、Promise、そしてasync/awaitという3つの手法を学ぶことで、どのような状況にも柔軟に対応できるコードが書けるようになります。
この記事を通じて、非同期処理の基本から応用まで幅広く理解し、実際のプロジェクトに役立てていただければ幸いです。

今後は、各手法の違いや使い分けのコツをさらに掘り下げ、より複雑な非同期処理パターンにも挑戦してみてください。

非同期処理は一度理解すれば、より洗練されたアプリケーション開発の大きな武器となるでしょう。
ぜひこの記事で学んだ知識を実践し、より高品質なコードを書けるようになってください。

PR広告