TechHub

エンジニアの成長をサポートする技術情報サイト

← 記事一覧に戻る

JavaScriptの非同期処理とは?Promiseとasync/awaitの使い方

公開日: 2024年2月10日 著者: mogura
JavaScriptの非同期処理とは?Promiseとasync/awaitの使い方

疑問

JavaScriptの非同期処理とは何で、Promiseやasync/awaitはどのように使用するのでしょうか?コールバックから始めて、非同期処理の基本を一緒に学んでいきましょう。

導入

JavaScriptの非同期処理は、Webアプリケーション開発において不可欠な概念です。API呼び出し、ファイル読み込み、タイマー処理など、多くの場面で非同期処理が使用されます。

本記事では、コールバックから始めて、Promise、async/awaitまで、段階的に非同期処理を学んでいきます。実践的なコード例とともに詳しく解説します。

JavaScript非同期処理のイメージ

解説

1. 非同期処理とは

非同期処理は、コードの実行をブロックせずに、バックグラウンドで処理を実行する方法です。JavaScriptはシングルスレッドで動作するため、非同期処理により、UIの応答性を保ちながら時間のかかる処理を実行できます。

同期処理との違い

同期処理は、コードを順番に実行し、1つの処理が完了するまで次の処理を待ちます。非同期処理は、処理を開始した後、完了を待たずに次のコードを実行します。非同期処理の完了時には、コールバック関数やPromiseの`then()`メソッドが呼び出されます。

非同期処理が必要な理由

非同期処理により、API呼び出しやファイル読み込みなどの時間のかかる処理を実行しても、UIがフリーズしません。ユーザーエクスペリエンスを向上させ、複数の処理を並行して実行できます。

同期処理と非同期処理の例

同期処理と非同期処理の違いを示す例です。

// 同期処理の例
console.log('1');
console.log('2');
console.log('3');
// 出力: 1, 2, 3(順番通り)

// 非同期処理の例
console.log('1');
setTimeout(() => {
  console.log('2');
}, 1000);
console.log('3');
// 出力: 1, 3, 2(1秒後に2が出力される)

2. コールバック関数

コールバック関数は、非同期処理の完了時に呼び出される関数です。Promiseが導入される前は、非同期処理の標準的な方法でした。しかし、ネストが深くなると読みにくくなる「コールバック地獄」という問題があります。

基本的なコールバック

コールバック関数は、非同期処理の引数として渡され、処理が完了したときに呼び出されます。エラーが発生した場合は、最初の引数にエラーオブジェクトが渡されます。

コールバック地獄

複数の非同期処理を順次実行する場合、コールバックがネストして読みにくくなります。これを「コールバック地獄」と呼び、Promiseやasync/awaitで解決できます。

基本的なコールバックの例

setTimeoutを使用したコールバックの例です。

// 基本的なコールバック
setTimeout(() => {
  console.log('1秒後に実行');
}, 1000);

// エラーハンドリング付きコールバック(Node.jsのfs.readFileの例)
// fs.readFile('file.txt', (err, data) => {
//   if (err) {
//     console.error('エラー:', err);
//     return;
//   }
//   console.log('ファイル内容:', data);
// });

コールバック地獄の例

ネストが深くなるコールバック地獄の例です。

// コールバック地獄の例
setTimeout(() => {
  console.log('1秒後');
  setTimeout(() => {
    console.log('2秒後');
    setTimeout(() => {
      console.log('3秒後');
      setTimeout(() => {
        console.log('4秒後');
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

// 読みにくく、保守が困難

3. Promise

Promiseは、非同期処理の結果を表現するオブジェクトです。pending(待機中)、fulfilled(成功)、rejected(失敗)の3つの状態を持ちます。コールバック地獄を解決し、より読みやすいコードを書けるようになりました。

Promiseの基本

Promiseは、`new Promise()`で作成し、`resolve`と`reject`の2つの関数を受け取ります。処理が成功した場合は`resolve()`を、失敗した場合は`reject()`を呼び出します。`then()`メソッドで成功時の処理を、`catch()`メソッドでエラー処理を定義できます。

fetch APIの例

`fetch()`APIは、Promiseを返すため、`then()`と`catch()`で処理できます。`fetch()`はHTTPリクエストを送信し、レスポンスを取得します。

Promiseチェーン

複数のPromiseを連鎖させて、順次処理を実行できます。`then()`の戻り値が次の`then()`に渡されます。

Promise.all(並列実行)

`Promise.all()`は、複数のPromiseを並列に実行し、すべてが完了するまで待ちます。1つでも失敗すると、全体が失敗として扱われます。

Promise.race(最初に完了したものを取得)

`Promise.race()`は、複数のPromiseのうち、最初に完了したものの結果を返します。タイムアウト機能の実装などに使用されます。

Promiseの基本例

Promiseを作成して使用する基本的な例です。

// Promiseの作成
const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('処理が成功しました');
    } else {
      reject('エラーが発生しました');
    }
  }, 1000);
});

// Promiseの使用
myPromise
  .then(result => {
    console.log(result); // '処理が成功しました'
  })
  .catch(error => {
    console.error(error); // 'エラーが発生しました'
  });

fetch APIの例

fetch APIを使用したAPI呼び出しの例です。

// fetch APIの使用
fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error('ネットワークエラー');
    }
    return response.json();
  })
  .then(data => {
    console.log('データ:', data);
  })
  .catch(error => {
    console.error('エラー:', error);
  });

Promiseチェーンの例

複数のPromiseを連鎖させる例です。

// Promiseチェーン
fetch('https://api.example.com/user/1')
  .then(response => response.json())
  .then(user => {
    console.log('ユーザー:', user);
    return fetch(`https://api.example.com/posts/${user.id}`);
  })
  .then(response => response.json())
  .then(posts => {
    console.log('投稿:', posts);
  })
  .catch(error => {
    console.error('エラー:', error);
  });

Promise.allの例

複数のPromiseを並列実行する例です。

// Promise.all - すべてのPromiseが完了するまで待つ
const promise1 = fetch('https://api.example.com/data1').then(r => r.json());
const promise2 = fetch('https://api.example.com/data2').then(r => r.json());
const promise3 = fetch('https://api.example.com/data3').then(r => r.json());

Promise.all([promise1, promise2, promise3])
  .then(results => {
    console.log('すべての結果:', results);
    // resultsは配列 [data1, data2, data3]
  })
  .catch(error => {
    console.error('エラー:', error);
    // 1つでも失敗するとここに来る
  });

Promise.raceの例

最初に完了したPromiseの結果を取得する例です。

// Promise.race - 最初に完了したものを取得
const fastPromise = new Promise(resolve => setTimeout(() => resolve('速い'), 500));
const slowPromise = new Promise(resolve => setTimeout(() => resolve('遅い'), 2000));

Promise.race([fastPromise, slowPromise])
  .then(result => {
    console.log(result); // '速い'(500ms後に完了)
  });

// タイムアウト機能の実装例
function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('タイムアウト')), ms);
  });
}

Promise.race([fetch('https://api.example.com/data'), timeout(5000)])
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

4. async/await

async/awaitは、Promiseをより読みやすく書くための構文です。async関数内でawaitを使用することで、非同期処理を同期処理のように記述できます。ES2017で導入され、現在の非同期処理の標準的な書き方です。

基本的な使い方

`async`キーワードで関数を定義し、`await`キーワードでPromiseの完了を待ちます。`async`関数は常にPromiseを返します。

複数の非同期処理

複数の非同期処理を順次実行する場合は、`await`を連続して使用します。並列実行する場合は、`Promise.all()`を使用します。

エラーハンドリング

`try-catch`文を使用して、`async/await`のエラーハンドリングを行います。`await`でエラーが発生すると、`catch`ブロックで捕捉できます。

基本的な使い方

async/awaitの基本的な使用例です。

// async関数の定義
async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return data;
}

// async関数の呼び出し
fetchData()
  .then(data => console.log(data))
  .catch(error => console.error(error));

// または、別のasync関数内で
async function main() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

複数の非同期処理

順次実行と並列実行の例です。

// 順次実行(1つずつ実行)
async function sequential() {
  const user = await fetch('/api/user/1').then(r => r.json());
  const posts = await fetch(`/api/posts/${user.id}`).then(r => r.json());
  const comments = await fetch(`/api/comments/${posts[0].id}`).then(r => r.json());
  return { user, posts, comments };
}

// 並列実行(同時に実行)
async function parallel() {
  const [user, posts, comments] = await Promise.all([
    fetch('/api/user/1').then(r => r.json()),
    fetch('/api/posts').then(r => r.json()),
    fetch('/api/comments').then(r => r.json())
  ]);
  return { user, posts, comments };
}

エラーハンドリング

try-catchを使用したエラーハンドリングの例です。

async function fetchUserData(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('ユーザーデータの取得に失敗:', error);
    throw error; // エラーを再スロー
  }
}

// 使用例
async function main() {
  try {
    const user = await fetchUserData(1);
    console.log('ユーザー:', user);
  } catch (error) {
    console.error('エラーが発生しました:', error.message);
  }
}

5. 実践的な例

実践的なプロジェクトで使用できる、API呼び出しのラッパー関数、リトライ機能、タイムアウト機能などの例を紹介します。これらのパターンを理解することで、より堅牢な非同期処理を実装できます。

API呼び出しのラッパー関数

API呼び出しを統一的なインターフェースでラップすることで、エラーハンドリングやログ出力を一元管理できます。プロジェクト全体で一貫したAPI呼び出しが可能になります。

リトライ機能付きAPI呼び出し

ネットワークエラーなどで失敗した場合に、自動的にリトライする機能を実装します。指数バックオフなどの戦略を使用することで、サーバーへの負荷を軽減できます。

タイムアウト機能

一定時間内に完了しない処理をタイムアウトとして扱う機能を実装します。`Promise.race()`を使用して、タイムアウト用のPromiseと処理用のPromiseを競わせます。

API呼び出しのラッパー関数

統一的なAPI呼び出しのラッパー関数の例です。

// API呼び出しのラッパー関数
async function apiCall(url, options = {}) {
  try {
    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      },
      ...options
    });
    
    if (!response.ok) {
      throw new Error(`API error: ${response.status} ${response.statusText}`);
    }
    
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    console.error('API呼び出しエラー:', error);
    return { success: false, error: error.message };
  }
}

// 使用例
const result = await apiCall('https://api.example.com/data');
if (result.success) {
  console.log('データ:', result.data);
} else {
  console.error('エラー:', result.error);
}

リトライ機能付きAPI呼び出し

自動リトライ機能付きのAPI呼び出しの例です。

// リトライ機能付きAPI呼び出し
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (response.ok) {
        return await response.json();
      }
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      if (i === maxRetries - 1) {
        throw error;
      }
      // 指数バックオフ: 1秒、2秒、4秒...
      const delay = Math.pow(2, i) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
      console.log(`リトライ ${i + 1}/${maxRetries}`);
    }
  }
}

// 使用例
try {
  const data = await fetchWithRetry('https://api.example.com/data');
  console.log('データ:', data);
} catch (error) {
  console.error('最終的に失敗:', error);
}

タイムアウト機能

タイムアウト機能付きのAPI呼び出しの例です。

// タイムアウト機能付きAPI呼び出し
function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('タイムアウト')), ms);
  });
}

async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  try {
    const response = await Promise.race([
      fetch(url, options),
      timeout(timeoutMs)
    ]);
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    if (error.message === 'タイムアウト') {
      throw new Error(`リクエストが${timeoutMs}ms以内に完了しませんでした`);
    }
    throw error;
  }
}

// 使用例
try {
  const data = await fetchWithTimeout('https://api.example.com/data', {}, 3000);
  console.log('データ:', data);
} catch (error) {
  console.error('エラー:', error.message);
}

6. 並列処理と順次処理

非同期処理を順次実行するか、並列実行するかは、パフォーマンスと要件によって選択します。制限付き並列処理により、リソースを効率的に使用できます。大量のリクエストを処理する場合、同時実行数を制限することで、サーバーへの負荷を軽減できます。

順次処理(1つずつ実行)

順次処理は、1つの処理が完了してから次の処理を開始します。依存関係がある場合や、リソースを節約したい場合に適しています。`await`を連続して使用することで実現できます。

並列処理(同時に実行)

並列処理は、複数の処理を同時に実行します。`Promise.all()`を使用することで実現できます。処理時間を短縮できますが、リソースを多く消費します。

制限付き並列処理

制限付き並列処理は、同時実行数を制限して並列処理を行います。大量のリクエストを効率的に処理できます。バッチ処理やキューイングの実装に使用されます。

順次処理の例

1つずつ順番に処理を実行する例です。

// 順次処理(1つずつ実行)
async function sequentialProcessing(items) {
  const results = [];
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }
  return results;
}

// 使用例
const items = [1, 2, 3, 4, 5];
const results = await sequentialProcessing(items);

並列処理の例

すべての処理を同時に実行する例です。

// 並列処理(同時に実行)
async function parallelProcessing(items) {
  const promises = items.map(item => processItem(item));
  const results = await Promise.all(promises);
  return results;
}

// 使用例
const items = [1, 2, 3, 4, 5];
const results = await parallelProcessing(items);

制限付き並列処理の例

同時実行数を制限して並列処理を行う例です。

// 制限付き並列処理(同時実行数を制限)
async function limitedParallelProcessing(items, concurrency = 3) {
  const results = [];
  
  for (let i = 0; i < items.length; i += concurrency) {
    const batch = items.slice(i, i + concurrency);
    const batchPromises = batch.map(item => processItem(item));
    const batchResults = await Promise.all(batchPromises);
    results.push(...batchResults);
  }
  
  return results;
}

// 使用例
const items = Array.from({ length: 100 }, (_, i) => i);
const results = await limitedParallelProcessing(items, 5); // 5つずつ処理

7. よくある間違いとベストプラクティス

非同期処理でよくある間違いと、正しい使い方を紹介します。適切なエラーハンドリングとパフォーマンスの考慮が重要です。

❌ 間違った使い方

よくある間違いとして、`await`を忘れる、エラーハンドリングをしない、不要な順次処理などがあります。これらの間違いは、バグやパフォーマンスの問題を引き起こします。

✅ 正しい使い方

正しい使い方として、適切なエラーハンドリング、並列処理の活用、明確なコード構造などがあります。これらのベストプラクティスを守ることで、堅牢で保守しやすいコードを書けます。

よくある間違い

非同期処理でよくある間違いの例です。

// ❌ 間違い: awaitを忘れる
async function fetchData() {
  const data = fetch('/api/data'); // Promiseが返される
  console.log(data); // Promiseオブジェクトが表示される
}

// ✅ 正しい: awaitを使用
async function fetchData() {
  const response = await fetch('/api/data');
  const data = await response.json();
  console.log(data); // 実際のデータが表示される
}

// ❌ 間違い: エラーハンドリングがない
async function fetchData() {
  const data = await fetch('/api/data').then(r => r.json());
  return data; // エラーが発生すると未処理のPromise rejection
}

// ✅ 正しい: try-catchでエラーハンドリング
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('エラー:', error);
    throw error;
  }
}

// ❌ 間違い: 不要な順次処理
async function fetchMultiple() {
  const data1 = await fetch('/api/data1').then(r => r.json());
  const data2 = await fetch('/api/data2').then(r => r.json());
  const data3 = await fetch('/api/data3').then(r => r.json());
  return [data1, data2, data3]; // 順次実行で遅い
}

// ✅ 正しい: 並列処理を使用
async function fetchMultiple() {
  const [data1, data2, data3] = await Promise.all([
    fetch('/api/data1').then(r => r.json()),
    fetch('/api/data2').then(r => r.json()),
    fetch('/api/data3').then(r => r.json())
  ]);
  return [data1, data2, data3]; // 並列実行で速い
}

ベストプラクティス

非同期処理のベストプラクティスの例です。

// ✅ 適切なエラーハンドリング
async function robustApiCall(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('API呼び出しエラー:', error);
    throw error;
  }
}

// ✅ 並列処理の活用(依存関係がない場合)
async function fetchUserData(userId) {
  const [user, posts, comments] = await Promise.all([
    fetch(`/api/users/${userId}`).then(r => r.json()),
    fetch(`/api/posts?userId=${userId}`).then(r => r.json()),
    fetch(`/api/comments?userId=${userId}`).then(r => r.json())
  ]);
  return { user, posts, comments };
}

// ✅ 明確なコード構造
async function processUserData(userId) {
  try {
    const user = await fetchUser(userId);
    if (!user) {
      throw new Error('ユーザーが見つかりません');
    }
    
    const posts = await fetchUserPosts(userId);
    const comments = await fetchUserComments(userId);
    
    return {
      user,
      posts,
      comments
    };
  } catch (error) {
    console.error('ユーザーデータの処理に失敗:', error);
    throw error;
  }
}

8. ベストプラクティス

非同期処理を効果的に使用するためのベストプラクティスをまとめます。エラーハンドリング、パフォーマンス、コードの可読性を考慮した実践的なアドバイスを提供します。

エラーハンドリング

すべての非同期処理で適切なエラーハンドリングを行います。`try-catch`文や`.catch()`メソッドを使用し、エラーメッセージを明確にします。

パフォーマンス

依存関係がない処理は並列実行し、リソースを効率的に使用します。大量のリクエストを処理する場合は、制限付き並列処理を検討します。

コードの可読性

async/awaitを使用して、コードを読みやすくします。長いPromiseチェーンは避け、適切に関数を分割します。

ベストプラクティスのまとめ

非同期処理のベストプラクティスのまとめです。

// 1. 適切なエラーハンドリング
async function safeApiCall(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    console.error('エラー:', error);
    throw error;
  }
}

// 2. 並列処理の活用(依存関係がない場合)
async function fetchMultipleData() {
  const [data1, data2, data3] = await Promise.all([
    fetch('/api/data1').then(r => r.json()),
    fetch('/api/data2').then(r => r.json()),
    fetch('/api/data3').then(r => r.json())
  ]);
  return { data1, data2, data3 };
}

// 3. 明確な関数名と構造
async function getUserProfile(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchUserPosts(userId);
  return { user, posts };
}

// 4. リトライとタイムアウトの実装
async function robustFetch(url, retries = 3, timeout = 5000) {
  for (let i = 0; i < retries; i++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      const response = await fetch(url, { signal: controller.signal });
      clearTimeout(timeoutId);
      
      if (response.ok) return await response.json();
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}

まとめ

JavaScriptの非同期処理は、Webアプリケーション開発において不可欠な概念です。コールバックから始まり、Promise、そしてasync/awaitへと進化し、より読みやすく保守しやすいコードを書けるようになりました。

適切なエラーハンドリングと並列処理の活用により、効率的な非同期処理を実現できます。実践的なプロジェクトで非同期処理を使用し、経験を積むことで、より高度な非同期処理パターンを習得できます。

非同期処理を理解することで、API呼び出し、ファイル操作、タイマー処理など、様々な場面で効率的なコードを書けるようになります。

JavaScriptの配列操作でよく使うメソッドは?完全ガイド