Vue 3 Composableエラーバウンダリ:レジリエントコンポーネントエラー処理の5つのコアパターン

前端工程

はじめに

このような場面に遭遇したことはありませんか?子コンポーネントが例外をスローしてアプリ全体が白画面クラッシュ、非同期リクエストのエラーがコンポーネントツリーでキャッチされない、ユーザーに表示されるエラー情報が晦渋なスタックトレースでフレンドリーではない、エラー発生後に自動リカバリできずページリフレッシュしかない。Vue 3はonErrorCapturedフックを提供していますが、完全なエラーバウンダリシステムの構築は依然として開発者の課題です。本記事では5つのコアパターンを通じて、レジリエントコンポーネントエラー処理ソリューションを構築します。

コア概念クイックリファレンス

概念 説明
Error Boundary エラーバウンダリ、子コンポーネントツリーのエラーをキャッチするコンポーネントパターン
onErrorCaptured Vue 3ライフサイクルフック、子孫コンポーネントのエラーをキャッチ
Composableエラー処理 コンポーザブル関数にエラーキャッチとリカバリロジックをカプセル化
グローバルエラーハンドラー app.config.errorHandler、アプリレベルの未処理エラーをキャッチ
グレースフルデグラデーション エラー発生時にクラッシュではなくフォールバックUIを表示
エラーレポート ランタイムエラーをモニタリングサービスに送信
リトライメカニズム エラー発生後に自動または手動で操作をリトライ
Suspenseエラー 非同期コンポーネント読み込み失敗時のエラー処理

問題分析:エラー処理の5つの課題

1. コンポーネントエラーによるアプリクラッシュ:Vueはデフォルトで未キャッチのエラーを上方に伝播し、最終的にアプリ全体が白画面に。

2. 非同期エラーのキャッチ不可onErrorCapturedsetTimeoutfetchなどの非同期コールバック内のエラーをキャッチできない。

3. フレンドリーでないエラーメッセージ:ネイティブのエラースタックはユーザーにとって無意味、理解可能なUIプロンプトに変換が必要。

4. エラーリカバリの困難さ:エラー発生後にリトライやリセットなどのリカバリメカニズムがなく、ユーザーはページリフレッシュしかできない。

5. エラートラッキングの欠如:プロダクション環境でエラー情報が消失、リモートモニタリングとトラブルシューティングが不可能。

パターン1:onErrorCapturedコンポーネントエラーキャッチ

import { onErrorCaptured, ref, type Component } from 'vue'

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
  errorInfo: string | null
}

export function useErrorCapture() {
  const state = ref<ErrorBoundaryState>({
    hasError: false,
    error: null,
    errorInfo: null,
  })

  onErrorCaptured((err, instance, info) => {
    state.value = {
      hasError: true,
      error: err as Error,
      errorInfo: info,
    }
    console.error('[ErrorBoundary]', err, info)
    return false
  })

  const reset = () => {
    state.value = { hasError: false, error: null, errorInfo: null }
  }

  return { ...toRefs(state.value), reset }
}

// コンポーネントでの使用
export const ErrorBoundaryWrapper = defineComponent({
  name: 'ErrorBoundaryWrapper',
  setup(_, { slots }) {
    const { hasError, error, reset } = useErrorCapture()

    return () => {
      if (hasError.value) {
        return h('div', { class: 'error-fallback' }, [
          h('p', { class: 'error-message' }, `コンポーネントレンダーエラー:${error.value?.message}`),
          h('button', { class: 'retry-btn', onClick: reset }, 'リトライ'),
        ])
      }
      return slots.default?.()
    }
  },
})

パターン2:useErrorBoundary Composable

import { ref, readonly, type Ref } from 'vue'

interface ErrorBoundaryOptions {
  fallback?: (error: Error, reset: () => void) => VNode
  onError?: (error: Error, info: string) => void
  retryLimit?: number
}

interface ErrorBoundaryResult {
  hasError: Readonly<Ref<boolean>>
  error: Readonly<Ref<Error | null>>
  retryCount: Readonly<Ref<number>>
  reset: () => void
  retry: () => void
}

export function useErrorBoundary(options: ErrorBoundaryOptions = {}) {
  const { onError, retryLimit = 3 } = options

  const hasError = ref(false)
  const error = ref<Error | null>(null)
  const retryCount = ref(0)

  const reset = () => {
    hasError.value = false
    error.value = null
    retryCount.value = 0
  }

  const retry = () => {
    if (retryCount.value >= retryLimit) {
      console.warn(`[ErrorBoundary] Retry limit (${retryLimit}) reached`)
      return
    }
    retryCount.value++
    hasError.value = false
    error.value = null
  }

  onErrorCaptured((err, _instance, info) => {
    hasError.value = true
    error.value = err as Error
    onError?.(err as Error, info)
    return false
  })

  return {
    hasError: readonly(hasError),
    error: readonly(error),
    retryCount: readonly(retryCount),
    reset,
    retry,
  }
}

パターン3:非同期エラー処理とSuspense

import { ref, onUnmounted } from 'vue'

interface AsyncState<T> {
  data: T | null
  error: Error | null
  loading: boolean
}

export function useAsyncHandler<T>(
  fn: () => Promise<T>,
  options: {
    immediate?: boolean
    retryCount?: number
    retryDelay?: number
    onError?: (error: Error) => void
  } = {}
) {
  const { immediate = false, retryCount = 0, retryDelay = 1000, onError } = options

  const state = ref<AsyncState<T>>({
    data: null,
    error: null,
    loading: false,
  })

  let retriesLeft = retryCount
  let timeoutId: ReturnType<typeof setTimeout> | null = null

  const execute = async (): Promise<T | null> => {
    state.value.loading = true
    state.value.error = null

    try {
      const result = await fn()
      state.value.data = result
      state.value.loading = false
      retriesLeft = retryCount
      return result
    } catch (err) {
      const error = err as Error
      state.value.error = error
      state.value.loading = false

      if (retriesLeft > 0) {
        retriesLeft--
        timeoutId = setTimeout(() => execute(), retryDelay)
      } else {
        onError?.(error)
      }
      return null
    }
  }

  if (immediate) {
    execute()
  }

  onUnmounted(() => {
    if (timeoutId) clearTimeout(timeoutId)
  })

  return {
    ...toRefs(state.value),
    execute,
    reset: () => {
      state.value = { data: null, error: null, loading: false }
      retriesLeft = retryCount
    },
  }
}

// Suspenseエラー処理
export const AsyncErrorBoundary = defineComponent({
  name: 'AsyncErrorBoundary',
  setup(_, { slots }) {
    const { hasError, error, retry } = useErrorBoundary({
      retryLimit: 3,
      onError: (err) => {
        reportError(err)
      },
    })

    return () => {
      if (hasError.value) {
        return h('div', { class: 'async-error-fallback' }, [
          h('p', null, '非同期読み込みに失敗しました'),
          h('button', { onClick: retry }, '再読み込み'),
        ])
      }
      return slots.default?.()
    }
  },
})

パターン4:グローバルエラー処理とレポート

import type { App } from 'vue'

interface ErrorReporterConfig {
  endpoint: string
  appVersion: string
  userId?: string
  sampleRate?: number
  maxQueueSize?: number
}

interface ErrorReport {
  message: string
  stack?: string
  component?: string
  info?: string
  url: string
  timestamp: number
  appVersion: string
  userId?: string
  userAgent: string
}

export class ErrorReporter {
  private queue: ErrorReport[] = []
  private config: ErrorReporterConfig
  private flushTimer: ReturnType<typeof setInterval> | null = null

  constructor(config: ErrorReporterConfig) {
    this.config = {
      sampleRate: 1,
      maxQueueSize: 50,
      ...config,
    }
  }

  report(error: Error, info?: { component?: string; info?: string }) {
    if (Math.random() > this.config.sampleRate) return

    const report: ErrorReport = {
      message: error.message,
      stack: error.stack,
      component: info?.component,
      info: info?.info,
      url: typeof window !== 'undefined' ? window.location.href : '',
      timestamp: Date.now(),
      appVersion: this.config.appVersion,
      userId: this.config.userId,
      userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
    }

    this.queue.push(report)

    if (this.queue.length >= (this.config.maxQueueSize ?? 50)) {
      this.flush()
    }
  }

  async flush() {
    if (this.queue.length === 0) return

    const batch = [...this.queue]
    this.queue = []

    try {
      await fetch(this.config.endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ errors: batch }),
        keepalive: true,
      })
    } catch {
      this.queue.unshift(...batch)
    }
  }

  start() {
    this.flushTimer = setInterval(() => this.flush(), 10000)
  }

  stop() {
    if (this.flushTimer) clearInterval(this.flushTimer)
    this.flush()
  }
}

export function setupGlobalErrorHandler(app: App, reporter: ErrorReporter) {
  app.config.errorHandler = (err, instance, info) => {
    const error = err as Error
    reporter.report(error, {
      component: instance?.$options?.name,
      info,
    })
    console.error('[GlobalErrorHandler]', error, info)
  }

  app.config.warnHandler = (msg, _instance, trace) => {
    if (import.meta.env.PROD) return
    console.warn('[VueWarn]', msg, trace)
  }

  if (typeof window !== 'undefined') {
    window.addEventListener('unhandledrejection', (event) => {
      reporter.report(new Error(event.reason), { info: 'unhandledrejection' })
    })

    window.addEventListener('error', (event) => {
      reporter.report(event.error, { info: 'window.error' })
    })
  }
}

パターン5:プロダクション級エラーリカバリフレームワーク

import { ref, readonly, type Ref } from 'vue'

interface RecoveryStrategy {
  type: 'retry' | 'fallback' | 'reset' | 'redirect'
  maxAttempts?: number
  delay?: number
  fallbackData?: unknown
  redirectUrl?: string
}

interface ErrorRecoveryState {
  hasError: boolean
  error: Error | null
  attempts: number
  recovering: boolean
  strategy: RecoveryStrategy | null
}

export function useErrorRecovery(
  strategies: RecoveryStrategy[] = [
    { type: 'retry', maxAttempts: 3, delay: 1000 },
    { type: 'fallback', fallbackData: null },
    { type: 'reset' },
  ]
) {
  const state = ref<ErrorRecoveryState>({
    hasError: false,
    error: null,
    attempts: 0,
    recovering: false,
    strategy: null,
  })

  let currentStrategyIndex = 0
  let timeoutId: ReturnType<typeof setTimeout> | null = null

  const handleError = (error: Error) => {
    state.value.hasError = true
    state.value.error = error
    currentStrategyIndex = 0
    attemptRecovery()
  }

  const attemptRecovery = () => {
    if (currentStrategyIndex >= strategies.length) {
      state.value.recovering = false
      return
    }

    const strategy = strategies[currentStrategyIndex]
    state.value.strategy = strategy
    state.value.recovering = true

    switch (strategy.type) {
      case 'retry':
        if (state.value.attempts < (strategy.maxAttempts ?? 3)) {
          timeoutId = setTimeout(() => {
            state.value.attempts++
            state.value.hasError = false
            state.value.error = null
          }, strategy.delay ?? 1000)
        } else {
          currentStrategyIndex++
          attemptRecovery()
        }
        break
      case 'fallback':
        state.value.recovering = false
        state.value.hasError = false
        break
      case 'reset':
        state.value = {
          hasError: false,
          error: null,
          attempts: 0,
          recovering: false,
          strategy: null,
        }
        break
      case 'redirect':
        if (strategy.redirectUrl && typeof window !== 'undefined') {
          window.location.href = strategy.redirectUrl
        }
        break
    }
  }

  const reset = () => {
    if (timeoutId) clearTimeout(timeoutId)
    state.value = {
      hasError: false,
      error: null,
      attempts: 0,
      recovering: false,
      strategy: null,
    }
    currentStrategyIndex = 0
  }

  onErrorCaptured((err) => {
    handleError(err as Error)
    return false
  })

  onUnmounted(() => {
    if (timeoutId) clearTimeout(timeoutId)
  })

  return {
    state: readonly(state),
    reset,
    retry: attemptRecovery,
  }
}

落とし穴ガイド:5つのよくある罠

1. ❌ onErrorCapturedでfalseを返さない → ✅ falseを返してエラーの上方伝播を停止、そうしないとアプリがクラッシュ

2. ❌ 非同期コールバックのエラーをキャッチしようとする → ✅ onErrorCapturedは同期エラーのみキャッチ、非同期エラーはtry-catchまたはwindow.unhandledrejectionが必要

3. ❌ エラーバウンダリコンポーネント自体もエラーをスロー → ✅ エラーバウンダリコンポーネントは最小限に、フォールバックUIに複雑なロジックを避ける

4. ❌ グローバルerrorHandlerがすべてのエラーを飲み込む → ✅ グローバルハンドラーはレポート+記録すべき、黙って無視するとデバッグが困難に

5. ❌ Promiseリジェクションを無視 → ✅ unhandledrejectionイベントをリッスンして未処理のPromiseリジェクションをキャッチ

エラートラブルシューティング:10のよくあるエラー

エラーメッセージ 原因 解決策
Uncaught TypeError コンポーネント内の未キャッチランタイムエラー onErrorCapturedまたはグローバルerrorHandlerを使用
Unhandled Promise Rejection async関数でtry-catchなし useAsyncHandlerで非同期操作をラップ
Maximum call stack size exceeded エラーリカバリロジックによる無限ループ リトライ回数を制限、上限を追加
Cannot read properties of undefined レンダリング時に未初期化のリアクティブデータにアクセス オプショナルチェーンまたはデフォルト値を追加
onErrorCaptured not triggered エラーが非同期コールバックで発生 非同期エラーは手動でtry-catchする必要がある
Component rendering error テンプレートでnull/undefinedを参照 v-ifガードまたはデフォルト値を使用
Network request failed APIリクエストのエラーステータス未処理 fetch/axiosにエラー処理を追加
ChunkLoadError デプロイ後の古いchunkファイルが存在しない バージョン検出を追加、自動ページリフレッシュ
Hydration mismatch SSRエラーバウンダリがクライアントと不一致 SSRとCSRでエラーバウンダリの動作が一致することを確認
Error boundary itself crashed フォールバックコンポーネントも例外をスロー フォールバックを最小限に、複雑なロジックを回避

高度なテクニック

1. エラー分類と重大度:エラータイプ(ネットワーク/レンダー/ロジック)と重大度(致命的/警告/情報)に基づいて異なるリカバリ戦略を策定。

2. エラー重複排除と集約:同じエラーを重複排除し、短時間に同じエラーの重複レポートを回避。

3. ユーザーフィードバック統合:エラー発生時にフィードバックフォームを表示、ユーザーの操作手順を収集して問題再現を支援。

4. エラーバウンダリネスト:コンポーネントツリーの異なるレベルにエラーバウンダリを設定、グローバルクラッシュではなく局所的デグラデーションを実現。

5. SourceMap逆解決:プロダクション環境でSourceMapをモニタリングプラットフォームにアップロード、ミニファイされたエラースタックをソースコード位置に復元。

比較分析:Vue onErrorCaptured vs React ErrorBoundary vs Svelteバウンダリ vs グローバルtry-catch

特徴 Vue onErrorCaptured React ErrorBoundary Svelteバウンダリ グローバルtry-catch
キャッチ範囲 子孫コンポーネントエラー 子孫コンポーネントエラー 手動実装が必要 任意のコードブロック
非同期エラー ❌ 非対応 ❌ 非対応 ❌ 非対応 ✅ 対応
イベントハンドラエラー ✅ 対応 ✅ 対応 ⚠️ 手動 ✅ 対応
フォールバックUI ✅ カスタム ✅ カスタム ✅ カスタム ⚠️ 限定
エラーリカバリ ✅ 状態リセット ✅ 状態リセット ⚠️ 手動 ✅ リトライロジック
ネスト対応 ✅ ネイティブ ✅ ネイティブ ⚠️ 手動 ✅ コードブロックネスト
学習曲線 最低

オンラインツールおすすめ

  1. JSONフォーマッター — エラーレポートのJSONデータをフォーマットして確認、エラー詳細を素早く特定
  2. ハッシュエンコードツール — エラーフィンガープリントにユニークハッシュを生成し、エラー重複排除を実現
  3. cURL→コード変換ツール — エラーレポートAPIリクエストをコードに素早く変換

まとめと展望

Vue 3 Composableエラーバウンダリは、レジリエントアプリケーションを構築するための重要なインフラです。5つのコアパターン——onErrorCapturedコンポーネントエラーキャッチ、useErrorBoundary Composable、非同期エラーとSuspense処理、グローバルエラー処理とレポート、プロダクション級エラーリカバリフレームワーク——を通じて、アプリは例外に直面した際にクラッシュではなくグレースフルにデグラデーションできます。2026年、Vue 3.5+の改善によりエラーバウンダリとSuspenseの統合はさらに密接になり、Vapor Mode下のエラー処理メカニズムもさらに効率的になるでしょう。

関連記事

  1. Vue 3 Error Handling — Vue 3 onErrorCaptured公式ドキュメント
  2. React Error Boundaries — Reactエラーバウンダリリファレンス
  3. Sentry Vue Integration — Sentry Vueエラーモニタリング統合
  4. Web Error Reporting API — ブラウザエラーレポートAPI
  5. Source Map Specification — SourceMap仕様と逆解決原理

ブラウザローカルツールを無料で試す →

#Vue3错误边界#Composable#错误处理#异常捕获#2026#前端工程