Vue 3 Composable錯誤邊界實戰:構建彈性元件錯誤處理的5個核心模式

前端工程

開篇引入

你是否遇到過這些場景:一個子元件拋出異常導致整個應用白屏崩潰,非同步請求的錯誤無法被元件樹捕獲,使用者看到的錯誤資訊是晦澀的堆疊而非友善提示,錯誤發生後無法自動恢復只能重新整理頁面?Vue 3雖然提供了onErrorCaptured鉤子,但如何構建完整的錯誤邊界體系仍然是開發者的痛點。本文將透過5個核心模式,帶你構建彈性元件錯誤處理方案。

核心概念速查

概念 說明
Error Boundary 錯誤邊界,捕獲子元件樹錯誤的元件模式
onErrorCaptured Vue 3生命週期鉤子,捕獲後代元件錯誤
Composable錯誤處理 在組合式函式中封裝錯誤捕獲與恢復邏輯
全域錯誤處理器 app.config.errorHandler,捕獲應用級未處理錯誤
優雅降級 錯誤發生時展示備用UI而非崩潰
錯誤上報 將執行時錯誤傳送到監控服務
重試機制 錯誤發生後自動或手動重試操作
Suspense錯誤 非同步元件載入失敗時的錯誤處理

問題分析:錯誤處理的5大挑戰

1. 元件錯誤導致應用崩潰:Vue預設會將未捕獲的錯誤向上傳播,最終導致整個應用白屏。

2. 非同步錯誤無法捕獲onErrorCaptured無法捕獲setTimeoutfetch等非同步回呼中的錯誤。

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-catchwindow.unhandledrejection

3. ❌ 錯誤邊界元件自身也出錯 → ✅ 錯誤邊界元件應保持極簡,避免在fallback UI中引入複雜邏輯

4. ❌ 全域errorHandler吞掉所有錯誤 → ✅ 全域處理器應上報+記錄,而非靜默忽略,否則除錯困難

5. ❌ 忽略Promise rejection → ✅ 監聽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 fallback元件也拋出異常 保持fallback極簡,避免複雜邏輯

進階技巧

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 ✅ 自訂 ✅ 自訂 ✅ 自訂 ⚠️ 有限
錯誤恢復 ✅ 重置狀態 ✅ 重置state ⚠️ 手動 ✅ 重試邏輯
巢狀支援 ✅ 原生支援 ✅ 原生支援 ⚠️ 手動 ✅ 程式碼塊巢狀
學習曲線 最低

線上工具推薦

  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#前端工程