Vue 3 Composable錯誤邊界實戰:構建彈性元件錯誤處理的5個核心模式
開篇引入
你是否遇到過這些場景:一個子元件拋出異常導致整個應用白屏崩潰,非同步請求的錯誤無法被元件樹捕獲,使用者看到的錯誤資訊是晦澀的堆疊而非友善提示,錯誤發生後無法自動恢復只能重新整理頁面?Vue 3雖然提供了onErrorCaptured鉤子,但如何構建完整的錯誤邊界體系仍然是開發者的痛點。本文將透過5個核心模式,帶你構建彈性元件錯誤處理方案。
核心概念速查
| 概念 | 說明 |
|---|---|
| Error Boundary | 錯誤邊界,捕獲子元件樹錯誤的元件模式 |
| onErrorCaptured | Vue 3生命週期鉤子,捕獲後代元件錯誤 |
| Composable錯誤處理 | 在組合式函式中封裝錯誤捕獲與恢復邏輯 |
| 全域錯誤處理器 | app.config.errorHandler,捕獲應用級未處理錯誤 |
| 優雅降級 | 錯誤發生時展示備用UI而非崩潰 |
| 錯誤上報 | 將執行時錯誤傳送到監控服務 |
| 重試機制 | 錯誤發生後自動或手動重試操作 |
| Suspense錯誤 | 非同步元件載入失敗時的錯誤處理 |
問題分析:錯誤處理的5大挑戰
1. 元件錯誤導致應用崩潰:Vue預設會將未捕獲的錯誤向上傳播,最終導致整個應用白屏。
2. 非同步錯誤無法捕獲:onErrorCaptured無法捕獲setTimeout、fetch等非同步回呼中的錯誤。
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. ❌ 錯誤邊界元件自身也出錯 → ✅ 錯誤邊界元件應保持極簡,避免在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 | ⚠️ 手動 | ✅ 重試邏輯 |
| 巢狀支援 | ✅ 原生支援 | ✅ 原生支援 | ⚠️ 手動 | ✅ 程式碼塊巢狀 |
| 學習曲線 | 低 | 低 | 低 | 最低 |
線上工具推薦
- JSON格式化工具 — 格式化檢視錯誤上報的JSON資料,快速定位錯誤詳情
- 雜湊編碼工具 — 為錯誤指紋生成唯一雜湊,實現錯誤去重
- cURL轉程式碼工具 — 將錯誤上報API請求轉為程式碼
總結與展望
Vue 3 Composable錯誤邊界是構建彈性應用的關鍵基礎設施。透過5個核心模式——onErrorCaptured元件錯誤捕獲、useErrorBoundary Composable、非同步錯誤與Suspense處理、全域錯誤處理與上報、生產級錯誤恢復框架——你可以讓應用在面對異常時優雅降級而非崩潰。2026年,Vue 3.5+的改進將讓錯誤邊界與Suspense的整合更加緊密,Vapor Mode下的錯誤處理機制也將更加高效。
延伸閱讀
- Vue 3 Error Handling — Vue 3 onErrorCaptured官方文件
- React Error Boundaries — React錯誤邊界參考
- Sentry Vue Integration — Sentry Vue錯誤監控整合
- Web Error Reporting API — 瀏覽器錯誤報告API
- Source Map Specification — SourceMap規範與反解原理
本站提供瀏覽器本地工具,免註冊即可試用 →