MechaToraのブログ

記事のアイキャッチ画像
技術・開発

JavaScriptエラーハンドリングの実践 - 堅牢なコードを書く方法

「動いたから完成!」と思ってリリースしたら、ユーザーから「エラーが出て使えない」と報告される——そんな経験はありませんか?エラーハンドリング(エラー処理)は、堅牢なWebアプリを作る上で不可欠なスキルです。

この記事では、MechaToraで15個のツールを運用する中で学んだ、JavaScriptのエラーハンドリングとデバッグの実践テクニックを紹介します。基本的なtry-catchから、非同期処理のエラー対処、本番環境でのエラー監視まで、実務で使える内容をお伝えします。

エラーハンドリングが重要な理由

放置すると起こる問題

私がエラーハンドリングを軽視していた頃、以下のような問題が発生しました:

適切なエラーハンドリングの効果

基本:try-catch文

セクション画像

基本的な使い方

最も基本的なエラーハンドリングがtry-catch文です:

try {
  // エラーが発生する可能性のあるコード
  const data = JSON.parse(jsonString);
  console.log(data);
} catch (error) {
  // エラーが発生したときの処理
  console.error('JSONのパースに失敗:', error.message);
  alert('データの読み込みに失敗しました');
}

finally句の活用

エラーの有無に関わらず実行したい処理は、finally句に書きます:

function loadData() {
  showLoading(); // ローディング表示

  try {
    const data = fetchDataFromAPI();
    displayData(data);
  } catch (error) {
    showError('データ取得に失敗しました');
  } finally {
    hideLoading(); // 必ずローディングを消す
  }
}

実際の活用例:LocalStorageの読み込み

MechaToraの各ツールで使っている実装:

function loadSettings() {
  try {
    const settingsJson = localStorage.getItem('app_settings');
    if (!settingsJson) {
      return getDefaultSettings(); // デフォルト値を返す
    }

    const settings = JSON.parse(settingsJson);
    return settings;

  } catch (error) {
    console.error('設定の読み込みエラー:', error);
    // エラー時もデフォルト値で動作を継続
    return getDefaultSettings();
  }
}

function getDefaultSettings() {
  return {
    theme: 'light',
    language: 'ja',
    notifications: true
  };
}

非同期処理のエラーハンドリング

Promise の .catch()

Promiseベースの非同期処理では、.catch()でエラーを捕捉します:

fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    console.log('データ取得成功:', data);
  })
  .catch(error => {
    console.error('データ取得失敗:', error);
    showError('通信エラーが発生しました。時間をおいて再度お試しください。');
  });

async/await での try-catch

async/awaitを使うと、同期処理と同じようにtry-catchが使えます(推奨):

async function fetchUserData(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const data = await response.json();
    return data;

  } catch (error) {
    console.error('ユーザーデータ取得エラー:', error);

    // ネットワークエラーか HTTPエラーかで分岐
    if (error.message.includes('Failed to fetch')) {
      throw new Error('ネットワークに接続できません');
    } else {
      throw new Error('ユーザー情報の取得に失敗しました');
    }
  }
}

実際の活用例:楽天API呼び出し

日本酒検索アプリでの実装:

async function searchSake(keyword) {
  const API_KEY = 'YOUR_API_KEY';
  const url = `https://app.rakuten.co.jp/services/api/IchibaItem/Search/20220601?
    applicationId=${API_KEY}&
    keyword=${encodeURIComponent(keyword + ' 日本酒')}&
    hits=30`;

  try {
    showLoading();

    const response = await fetch(url);

    // HTTPエラーチェック
    if (!response.ok) {
      if (response.status === 429) {
        throw new Error('API利用制限に達しました。しばらく経ってから再度お試しください。');
      } else if (response.status === 404) {
        throw new Error('APIエンドポイントが見つかりません。');
      } else {
        throw new Error(`APIエラー: ${response.status}`);
      }
    }

    const data = await response.json();

    // データの検証
    if (!data.Items || data.Items.length === 0) {
      showMessage('該当する商品が見つかりませんでした');
      return [];
    }

    return data.Items;

  } catch (error) {
    console.error('検索エラー:', error);

    // ユーザーフレンドリーなエラーメッセージ
    if (error.message.includes('Failed to fetch')) {
      showError('ネットワークエラーが発生しました。インターネット接続を確認してください。');
    } else {
      showError(error.message);
    }

    return [];

  } finally {
    hideLoading();
  }
}

カスタムエラークラス

セクション画像

独自のエラー型を定義

エラーの種類を明確にするため、カスタムエラークラスを作ります:

// ネットワークエラー
class NetworkError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NetworkError';
  }
}

// APIエラー
class APIError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = 'APIError';
    this.statusCode = statusCode;
  }
}

// バリデーションエラー
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

カスタムエラーの活用

async function fetchData(url) {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new APIError(
        `API request failed: ${response.statusText}`,
        response.status
      );
    }

    return await response.json();

  } catch (error) {
    if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
      throw new NetworkError('ネットワークに接続できません');
    }
    throw error;
  }
}

// 使用例
try {
  const data = await fetchData('https://api.example.com/data');
  console.log(data);
} catch (error) {
  if (error instanceof NetworkError) {
    showNetworkErrorDialog();
  } else if (error instanceof APIError) {
    showAPIErrorDialog(error.statusCode);
  } else {
    showGeneralErrorDialog();
  }
}

グローバルエラーハンドラー

window.onerror

キャッチされなかったエラーを一箇所で処理:

window.onerror = function(message, source, lineno, colno, error) {
  console.error('Global error:', {
    message,
    source,
    lineno,
    colno,
    error
  });

  // エラーをサーバーに送信(本番環境)
  if (location.hostname !== 'localhost') {
    logErrorToServer({
      message,
      source,
      lineno,
      colno,
      stack: error?.stack,
      userAgent: navigator.userAgent,
      url: window.location.href,
      timestamp: new Date().toISOString()
    });
  }

  // ユーザーに通知
  showErrorNotification('予期しないエラーが発生しました');

  // エラーの伝播を止める
  return true;
};

Promiseの未処理エラーを捕捉

window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled promise rejection:', event.reason);

  // エラーログ送信
  logErrorToServer({
    type: 'unhandledRejection',
    reason: event.reason,
    promise: event.promise,
    timestamp: new Date().toISOString()
  });

  // ユーザーに通知
  showErrorNotification('処理中にエラーが発生しました');

  // デフォルトの動作を防ぐ
  event.preventDefault();
});

デバッグテクニック

1. console.log の活用

基本ですが、使い分けが重要:

// 通常のログ
console.log('変数の値:', myVariable);

// 警告
console.warn('この機能は非推奨です');

// エラー
console.error('APIエラー:', error);

// テーブル形式(配列・オブジェクト)
console.table(users);

// グループ化
console.group('ユーザー情報');
console.log('名前:', user.name);
console.log('年齢:', user.age);
console.groupEnd();

// 実行時間の計測
console.time('データ取得');
await fetchData();
console.timeEnd('データ取得'); // "データ取得: 234.56ms"

2. デバッガーの使用

Chrome DevTools のデバッガーを活用:

function complexCalculation(data) {
  debugger; // この行で実行が一時停止

  let result = 0;
  for (let item of data) {
    result += item.value * item.quantity;
  }

  return result;
}

DevToolsの便利機能:

3. エラーの再現方法

意図的にエラーを発生させてテスト:

// ネットワークエラーをシミュレート
async function fetchDataWithSimulatedError(url, shouldFail = false) {
  if (shouldFail) {
    throw new Error('Simulated network error');
  }
  return await fetch(url);
}

// APIレート制限をシミュレート
function mockAPIResponse(shouldReturn429 = false) {
  if (shouldReturn429) {
    return new Response(null, { status: 429 });
  }
  return new Response(JSON.stringify({ data: 'success' }), { status: 200 });
}

本番環境でのエラー監視

シンプルなエラーログ送信

サーバーにエラーを送信して記録:

async function logErrorToServer(errorInfo) {
  try {
    await fetch('https://your-server.com/api/errors', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(errorInfo)
    });
  } catch (error) {
    // エラーログの送信が失敗しても、アプリは止めない
    console.error('Failed to log error:', error);
  }
}

// 使用例
try {
  // 何らかの処理
} catch (error) {
  logErrorToServer({
    message: error.message,
    stack: error.stack,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString()
  });
}

外部サービスの活用

本格的な監視には、以下のようなサービスが便利です:

Sentryの簡単な導入例:

// Sentryをインストール
// npm install @sentry/browser

import * as Sentry from "@sentry/browser";

Sentry.init({
  dsn: "YOUR_SENTRY_DSN",
  environment: "production",
  // サンプリングレート(エラーの10%のみ送信)
  sampleRate: 0.1
});

// エラーを手動で送信
try {
  // 何らかの処理
} catch (error) {
  Sentry.captureException(error);
}

実践的なエラーハンドリングパターン

パターン1:Fetch ラッパー関数

全てのAPI呼び出しで統一したエラーハンドリング:

async function apiFetch(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      }
    });

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new APIError(
        errorData.message || `HTTP ${response.status}`,
        response.status
      );
    }

    return await response.json();

  } catch (error) {
    if (error instanceof APIError) {
      throw error;
    }

    // ネットワークエラー
    throw new NetworkError('ネットワークエラーが発生しました');
  }
}

// 使用例
try {
  const data = await apiFetch('https://api.example.com/data');
  console.log(data);
} catch (error) {
  if (error instanceof NetworkError) {
    showError('インターネット接続を確認してください');
  } else if (error instanceof APIError && error.statusCode === 404) {
    showError('データが見つかりませんでした');
  } else {
    showError('エラーが発生しました');
  }
}

パターン2:リトライロジック付きFetch

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, options);

      if (response.ok) {
        return await response.json();
      }

      // 429エラー(Rate Limit)は再試行
      if (response.status === 429 && i < retries - 1) {
        const retryAfter = response.headers.get('Retry-After') || 2;
        await sleep(retryAfter * 1000);
        continue;
      }

      throw new APIError(`HTTP ${response.status}`, response.status);

    } catch (error) {
      // 最後の試行で失敗したらエラーを投げる
      if (i === retries - 1) {
        throw error;
      }

      // 指数バックオフ(1秒、2秒、4秒...)
      await sleep(Math.pow(2, i) * 1000);
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

まとめ

堅牢なJavaScriptアプリを作るためのエラーハンドリングのポイント:

基本原則

  1. 全ての非同期処理にエラーハンドリング:try-catch または .catch()
  2. ユーザーフレンドリーなメッセージ:技術的な詳細は隠す
  3. エラーをログに記録:デバッグのため
  4. グレースフルデグラデーション:エラー時も最低限の機能は維持

実装チェックリスト

MechaToraの15個のツールでは、これらのエラーハンドリングを実装することで、ユーザーからのエラー報告が劇的に減りました。最初は面倒に感じるかもしれませんが、後々のデバッグコストを考えると、必ず実装すべきです。

エラーハンドリングは、ユーザー体験を守るための「保険」です。ぜひ、今日から実装してみてください!