错误边界与全局错误处理:onErrorCaptured 传播链与监控接入
第28章:错误边界与全局错误处理——onErrorCaptured 传播链与监控接入
Vue 3 的错误传播系统有一个反直觉的设计:
onErrorCaptured返回false是阻止传播,不返回(返回undefined)是继续传播——这与 DOM 事件的stopPropagation逻辑截然相反,是错误处理代码中最常见的一类 bug。
本章核心问题:当组件树中某处发生错误时,Vue 如何将错误沿组件树向上传递,以及如何在不同层次拦截和处理这些错误?
读完本章你将理解:
onErrorCaptured的传播链机制,以及返回值如何控制错误是否继续传播info参数的全部可能取值,用于精确定位错误发生在哪个生命周期- 如何实现类 React ErrorBoundary 的降级 UI 组件,以及异步错误的捕获盲区
Level 1 · 你需要知道的(1-3年经验)
1.1 错误从哪里来:Vue 捕获的错误类型
Vue 3 能自动捕获以下位置发生的错误:
同步错误(Vue 直接捕获):
setup()函数中的同步错误- 生命周期钩子(
onMounted、onUpdated等)中的错误 - 渲染函数/模板中的错误
watch/watchEffect的回调中的错误v-on事件处理器中的错误
异步错误(有条件捕获):
setup()中的async/await错误(Vue 会捕获)onMounted(async () => { ... })中的错误(Vue 会捕获)
不能捕获的错误(需要手动处理):
- 独立的 Promise(未被
await的):setTimeout(() => { throw new Error() }) setInterval回调中的错误- 第三方库的异步回调
1.2 三层错误处理机制
Vue 3 提供了三种层级的错误处理,形成一个从细粒度到全局的错误拦截体系:
第一层:onErrorCaptured(组件级)
<!-- ErrorBoundary.vue -->
<script setup>
import { onErrorCaptured, ref } from 'vue';
const error = ref(null);
onErrorCaptured((err, instance, info) => {
error.value = err;
console.log('捕获到错误:', err.message);
console.log('发生错误的组件:', instance);
console.log('错误发生的位置:', info); // 'setup function', 'render function' 等
return false; // 阻止错误继续向上传播
});
</script>
<template>
<div v-if="error" class="error-ui">
出错了:{{ error.message }}
</div>
<slot v-else />
</template>
第二层:app.config.errorHandler(应用级全局兜底)
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
// 全局捕获所有未被 onErrorCaptured 拦截的错误
console.error('全局错误处理器:', err);
// 上报到监控系统
reportToMonitoring(err, instance, info);
};
第三层:window.addEventListener('unhandledrejection')(捕获 Vue 无法处理的异步错误)
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的 Promise rejection:', event.reason);
reportToMonitoring(event.reason);
});
1.3 info 参数:精确定位错误位置
onErrorCaptured 和 app.config.errorHandler 的第三个参数 info 是一个字符串,描述错误发生在组件的哪个阶段:
| info 值 | 含义 |
|---|---|
'setup function' |
setup() 函数中 |
'render function' |
渲染函数/模板渲染中 |
'component mounted hook' |
onMounted 钩子中 |
'component updated hook' |
onUpdated钩子中 |
'component unmounted hook' |
onUnmounted 钩子中 |
'watcher callback' |
watch/watchEffect 回调中 |
'native event handler' |
原生 DOM 事件处理器中 |
'component event handler' |
组件自定义事件处理器中 |
'directive hook' |
自定义指令的钩子函数中 |
'app unmount cleanup function' |
app.unmount() 期间 |
在生产环境错误监控中,info 是非常有价值的诊断信息:
app.config.errorHandler = (err, instance, info) => {
Sentry.captureException(err, {
extra: {
componentName: instance?.type?.name || 'Unknown',
errorInfo: info, // 'setup function', 'render function' 等
propsData: instance?.props,
}
});
};
1.4 warnHandler:开发模式的警告处理
除了 errorHandler,Vue 还提供了 warnHandler 用于拦截开发模式下的 Vue 警告:
app.config.warnHandler = (msg, instance, trace) => {
// msg: 警告信息字符串
// trace: 组件层级追踪
// 只在特定条件下显示警告(用于过滤第三方组件库的噪音)
if (!msg.includes('Some Known Warning')) {
console.warn(msg, trace);
}
};
注意:warnHandler 仅在开发构建(NODE_ENV !== 'production')中生效,生产构建中 Vue 不生成警告信息。
1.5 实现错误边界组件
类似于 React 的 ErrorBoundary,Vue 可以用 onErrorCaptured 实现降级 UI:
<!-- components/ErrorBoundary.vue -->
<script setup>
import { onErrorCaptured, ref } from 'vue';
const props = defineProps({
fallback: String, // 降级提示文本
});
const hasError = ref(false);
const errorMessage = ref('');
onErrorCaptured((err, instance, info) => {
hasError.value = true;
errorMessage.value = err.message;
// 返回 false 阻止错误继续传播到父组件
return false;
});
function retry() {
hasError.value = false;
errorMessage.value = '';
}
</script>
<template>
<div v-if="hasError" class="error-boundary">
<p>{{ props.fallback || '该模块暂时不可用' }}</p>
<p class="error-detail">{{ errorMessage }}</p>
<button @click="retry">重试</button>
</div>
<slot v-else />
</template>
使用方式:
<ErrorBoundary fallback="评论模块加载失败">
<CommentSection />
</ErrorBoundary>
<ErrorBoundary fallback="推荐列表暂时不可用">
<RecommendList />
</ErrorBoundary>
Level 2 · 它是怎么运行的(3-5年经验)
2.1 错误传播链的完整路径
当一个组件内发生错误时,Vue 的处理流程如下:
组件 C(发生错误)
│
│ Vue 捕获错误(通过 try-catch 包裹的钩子/渲染函数)
▼
┌─────────────────────────────────┐
│ 调用 C 的 onErrorCaptured │
│ 返回 false → 停止传播 │
│ 返回其他 → 继续向上 │
└────────────────┬────────────────┘
│ 继续传播
▼
┌─────────────────────────────────┐
│ 调用父组件 B 的 onErrorCaptured│
│ 返回 false → 停止传播 │
│ 返回其他 → 继续向上 │
└────────────────┬────────────────┘
│ 继续传播
▼
┌─────────────────────────────────┐
│ 调用祖先组件 A 的 onErrorCaptured│
│ 返回 false → 停止传播 │
│ 返回其他 → 继续向上 │
└────────────────┬────────────────┘
│ 到达根组件,仍未被拦截
▼
┌─────────────────────────────────┐
│ app.config.errorHandler │
│ 全局兜底,必须在这里记录 │
└─────────────────────────────────┘
关键细节:
onErrorCaptured捕获的是子组件树中的错误,不是当前组件自身的错误- 如果
onErrorCaptured自身抛出了错误,这个新错误和原来的错误都会向上传播 - 错误传播是同步的,逐级向上,直到被拦截或到达全局处理器
2.2 同一组件的多个 onErrorCaptured 钩子
一个组件可以注册多个 onErrorCaptured 钩子(在不同的 composable 中使用时很常见):
// 在 composable A 中注册
onErrorCaptured((err) => {
logToService(err);
// 没有返回 false,继续传播
});
// 在 composable B 中注册(同一个组件)
onErrorCaptured((err) => {
showNotification(err.message);
return false; // 这个钩子阻止了传播
});
多个 onErrorCaptured 钩子的执行顺序是注册顺序(先注册先执行)。只要其中任意一个返回 false,错误就不再继续传播。
2.3 异步错误的捕获边界
Vue 能捕获异步错误的前提是:这个 Promise 必须被 Vue 的钩子系统 await。
<script setup>
// 情况1:Vue 能捕获 ✓
// setup 顶层的 async/await 被 Vue 内部 try-catch 包裹
const data = await fetch('/api/data').then(r => r.json());
// 情况2:Vue 能捕获 ✓
// onMounted 的 async 回调被 Vue 内部 try-catch 包裹
onMounted(async () => {
const result = await fetch('/api/data');
// 这里的错误会被 onErrorCaptured 捕获
});
// 情况3:Vue 不能捕获 ✗
// 独立的 Promise 链,Vue 不知道它存在
setTimeout(async () => {
const result = await fetch('/api/data'); // 这里的错误不会被 onErrorCaptured 捕获
}, 1000);
// 情况4:Vue 不能捕获 ✗
// 没有 await 的 Promise
fetch('/api/data').then(r => {
if (!r.ok) throw new Error('请求失败'); // 不会被 onErrorCaptured 捕获
});
</script>
情况3和4的解决方案:
// 全局捕获 unhandledrejection
window.addEventListener('unhandledrejection', (event) => {
// event.promise: 被拒绝的 Promise
// event.reason: 拒绝原因(错误对象)
Sentry.captureException(event.reason, {
extra: { type: 'unhandledrejection' }
});
// 阻止默认的控制台警告输出
event.preventDefault();
});
2.4 Sentry 接入的完整实现
生产环境中接入 Sentry 的标准模式:
// main.js
import { createApp } from 'vue';
import * as Sentry from '@sentry/vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
// Sentry 初始化(必须在 app.use(router) 之前)
Sentry.init({
app,
dsn: 'https://[email protected]/project-id',
// 与 Vue Router 集成,记录路由导航性能
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
}),
],
// 采样率:生产环境不要 100%
tracesSampleRate: 0.1,
// 环境标识
environment: import.meta.env.MODE,
});
// 自定义错误处理器(Sentry.init 会自动设置 errorHandler,但可以扩展)
const originalErrorHandler = app.config.errorHandler;
app.config.errorHandler = (err, instance, info) => {
// 自定义逻辑:记录组件信息
Sentry.withScope((scope) => {
scope.setTag('error_info', info);
scope.setContext('vue_component', {
name: instance?.type?.name,
props: instance?.props,
});
Sentry.captureException(err);
});
// 调用原始处理器(如果存在)
if (originalErrorHandler) {
originalErrorHandler(err, instance, info);
}
};
app.use(router);
app.mount('#app');
Vue Router 导航守卫中的错误处理:
// router/index.js
router.onError((error, to, from) => {
// 路由组件懒加载失败时触发
if (error.message.includes('Failed to fetch dynamically imported module')) {
// 网络错误导致的动态 import 失败,刷新页面
window.location.href = to.fullPath;
} else {
Sentry.captureException(error, {
extra: { to: to.fullPath, from: from.fullPath }
});
}
});
2.5 不同场景的错误上报策略
// 错误分级上报策略
app.config.errorHandler = (err, instance, info) => {
const severity = classifyError(err, info);
switch (severity) {
case 'critical':
// 渲染错误、数据丢失风险:立即上报 + 通知用户
Sentry.captureException(err, { level: 'fatal' });
showGlobalErrorAlert();
break;
case 'warning':
// 非关键功能失败:上报但不打扰用户
Sentry.captureException(err, { level: 'warning' });
break;
case 'info':
// 预期内的错误(如网络超时):仅记录日志
console.warn('Expected error:', err.message);
break;
}
};
function classifyError(err, info) {
if (info === 'render function') return 'critical';
if (err instanceof NetworkError) return 'info';
return 'warning';
}
2.6 错误恢复与重试机制
真正实用的错误边界组件需要支持重试:
<!-- components/SmartErrorBoundary.vue -->
<script setup>
import { onErrorCaptured, ref, provide } from 'vue';
const error = ref(null);
const retryKey = ref(0); // 改变 key 强制重新渲染子组件
onErrorCaptured((err, instance, info) => {
error.value = { err, info };
return false;
});
function retry() {
error.value = null;
retryKey.value++; // 递增 key,触发子树重新挂载
}
// 向子组件提供错误上报方法
provide('reportError', (err) => {
error.value = { err, info: 'manual report' };
});
</script>
<template>
<div v-if="error" class="error-boundary">
<slot name="error" :error="error.err" :retry="retry">
<!-- 默认降级 UI -->
<div class="default-error">
<p>加载失败</p>
<button @click="retry">重试</button>
</div>
</slot>
</div>
<template v-else>
<!-- 用 :key 强制重新挂载 -->
<slot :key="retryKey" />
</template>
</template>
Level 3 · 设计文档与源码(资深开发者)
3.1 Vue 错误处理的源码实现
Vue 3 的错误处理核心在 packages/runtime-core/src/errorHandling.ts:
// 核心函数:callWithErrorHandling
// Vue 内部所有可能出错的调用都通过这个函数
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
) {
let res;
try {
res = args ? fn(...args) : fn();
} catch (err) {
handleError(err, instance, type);
}
return res;
}
// 异步版本
export function callWithAsyncErrorHandling(
fn: Function | Function[],
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
): any[] {
// ...
const res = callWithErrorHandling(fn, instance, type, args);
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type);
});
}
// ...
}
handleError 函数(错误传播的核心):
export function handleError(
err: unknown,
instance: ComponentInternalInstance | null,
info: string,
throwInDev = true
) {
const contextVNode = instance ? instance.vnode : null;
if (instance) {
let cur = instance.parent;
// 遍历组件树,逐级查找 errorCapturedHooks
while (cur) {
const errorCapturedHooks = cur.ec; // errorCaptured hooks 数组
if (errorCapturedHooks) {
for (let i = 0; i < errorCapturedHooks.length; i++) {
if (errorCapturedHooks[i](err, instance, info) === false) {
// 返回 false → 停止传播
return;
}
}
}
cur = cur.parent;
}
// 检查 app.config.errorHandler(应用级别的处理器)
const appContext = instance.appContext;
const globalErrorHandler = appContext.config.errorHandler;
if (globalErrorHandler) {
callWithErrorHandling(
globalErrorHandler,
null,
ErrorCodes.APP_ERROR_HANDLER,
[err, instance.proxy, info]
);
return;
}
}
// 没有任何处理器,抛出到浏览器控制台
logError(err, info, contextVNode, throwInDev);
}
ErrorTypes 枚举(对应 info 参数的可能值):
// packages/runtime-core/src/errorHandling.ts
export const enum ErrorCodes {
SETUP_FUNCTION, // 'setup function'
RENDER_FUNCTION, // 'render function'
WATCH_GETTER, // 'watcher getter'
WATCH_CALLBACK, // 'watcher callback'
WATCH_CLEANUP, // 'watcher cleanup function'
NATIVE_EVENT_HANDLER, // 'native event handler'
COMPONENT_EVENT_HANDLER, // 'component event handler'
VNODE_HOOK, // 'vnode hook'
DIRECTIVE_HOOK, // 'directive hook'
TRANSITION_HOOK, // 'transition hook'
APP_ERROR_HANDLER, // 'app errorHandler'
APP_WARN_HANDLER, // 'app warnHandler'
FUNCTION_REF, // 'ref function'
ASYNC_COMPONENT_LOADER, // 'async component loader'
SCHEDULER, // 'scheduler flush'
}
3.2 生命周期钩子的错误包装
所有生命周期钩子在调用时都被 callWithErrorHandling 包裹:
// packages/runtime-core/src/componentOptions.ts(简化)
function callHook(
hook: Function | Function[],
instance: ComponentInternalInstance,
type: LifecycleHooks,
arg?: any
) {
const hooks = isArray(hook) ? hook : [hook];
for (let i = 0; i < hooks.length; i++) {
callWithAsyncErrorHandling(hooks[i], instance, type, arg ? [arg] : undefined);
}
}
这解释了为什么 onMounted(async () => { ... }) 中的错误能被捕获:callWithAsyncErrorHandling 会对返回的 Promise 调用 .catch()。
3.3 defineAsyncComponent 的错误处理
异步组件加载失败是生产环境中常见的错误场景:
import { defineAsyncComponent } from 'vue';
const AsyncComp = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
// 加载中显示的组件
loadingComponent: LoadingSpinner,
// 加载失败显示的组件
errorComponent: ErrorDisplay,
// 在显示 loadingComponent 之前的延迟(毫秒)
delay: 200,
// 超时时间(毫秒),超时后显示 errorComponent
timeout: 3000,
// 自定义加载失败处理
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts <= 3) {
// 网络错误,最多重试3次
retry();
} else {
fail();
}
}
});
3.4 在测试中验证错误处理
使用 Vitest 测试错误边界组件:
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, onErrorCaptured, ref } from 'vue';
import ErrorBoundary from '@/components/ErrorBoundary.vue';
// 创建一个必然报错的子组件
const BrokenChild = defineComponent({
setup() {
throw new Error('Test error from child');
},
template: '<div>Not rendered</div>'
});
describe('ErrorBoundary', () => {
it('captures errors from child components', async () => {
// 阻止 console.error 污染测试输出
vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mount(ErrorBoundary, {
slots: {
default: BrokenChild
}
});
// 错误边界应显示降级 UI
expect(wrapper.find('.error-boundary').exists()).toBe(true);
expect(wrapper.text()).toContain('加载失败');
});
it('supports retry', async () => {
// ...
await wrapper.find('button').trigger('click'); // 点击重试
// 验证子组件重新挂载
});
});
Level 4 · 边界与陷阱(全体适用)
陷阱 1:onErrorCaptured 返回值的反直觉设计
错误理解:很多开发者认为"不返回(返回 undefined)"等于"拦截错误",类似于 DOM 事件的默认行为:
// 错误写法:以为不返回就能阻止传播
onErrorCaptured((err) => {
logError(err);
// 没有 return 语句,以为错误被处理了
// 实际上:错误继续向上传播!
});
正确理解:在 Vue 的设计中:
- 返回
false= 阻止传播(主动拦截) - 不返回 / 返回
true/ 返回任何非false值 = 继续传播(透传)
// 正确写法:明确阻止传播
onErrorCaptured((err) => {
logError(err);
return false; // 明确返回 false
});
可复现场景:
<!-- Parent.vue -->
<script setup>
import { onErrorCaptured } from 'vue';
// 父组件也有 onErrorCaptured
onErrorCaptured((err) => {
console.log('父组件捕获到错误!'); // 这会被执行,因为子组件没有 return false
});
</script>
<template>
<Child />
</template>
<!-- Child.vue -->
<script setup>
import { onErrorCaptured } from 'vue';
onErrorCaptured((err) => {
console.log('子组件捕获到错误'); // 先执行
// 没有 return false → 错误继续传播到父组件
});
</script>
<template>
<GrandChild /> <!-- 孙组件抛出错误 -->
</template>
陷阱 2:独立 Promise 的错误不会被 onErrorCaptured 捕获
错误代码:
<script setup>
import { onErrorCaptured, onMounted } from 'vue';
onErrorCaptured((err) => {
console.log('捕获到错误');
return false;
});
onMounted(() => {
// 错误!这个 Promise 没有被 await,Vue 不知道它的存在
fetch('/api/data')
.then(r => r.json())
.then(data => {
// 这里如果抛出错误...
processData(data); // 假设这里抛出了错误
});
// 没有 .catch()!
});
</script>
现象:onErrorCaptured 不会触发,控制台出现 "Uncaught (in promise) Error" 警告。
正确代码:
onMounted(async () => {
// 方案1:使用 async/await(Vue 能捕获)
const r = await fetch('/api/data');
const data = await r.json();
processData(data); // 这里的错误会被 Vue 捕获
});
// 或者:方案2:全局 unhandledrejection 处理
window.addEventListener('unhandledrejection', (event) => {
handlePromiseError(event.reason);
});
陷阱 3:在 onErrorCaptured 中修改响应式数据导致无限循环
错误代码:
<script setup>
import { onErrorCaptured, ref, watch } from 'vue';
const count = ref(0);
const error = ref(null);
// watch 监听 count,当 count 变化时可能触发错误
watch(count, () => {
if (count.value > 10) {
throw new Error('Count too high'); // 在 watcher 中抛出错误
}
});
onErrorCaptured((err) => {
error.value = err;
count.value = 0; // 错误!修改 count 会再次触发 watcher,可能再次抛出错误
return false;
});
</script>
正确做法:在 onErrorCaptured 中避免修改可能触发被监听数据的状态:
onErrorCaptured((err) => {
// 只修改与错误展示相关的状态
error.value = err.message;
// 不要修改可能触发错误的数据
return false;
});
// 单独处理重置逻辑
function reset() {
error.value = null;
count.value = 0;
}
陷阱 4:异步组件加载失败后错误不传播到父组件
错误场景:
<script setup>
import { defineAsyncComponent, onErrorCaptured, ref } from 'vue';
const error = ref(null);
// 期望捕获异步组件加载失败的错误
onErrorCaptured((err) => {
error.value = err;
return false;
});
const AsyncComp = defineAsyncComponent(() => import('./NonExistent.vue'));
</script>
<template>
<!-- onErrorCaptured 可能无法捕获这里的错误! -->
<AsyncComp />
</template>
原因:defineAsyncComponent 在加载失败时,默认会渲染 errorComponent(如果配置了的话),错误不一定会传播到父组件的 onErrorCaptured。
正确做法:使用 onError 配置处理加载失败:
const AsyncComp = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
onError(error, retry, fail, attempts) {
// 在这里处理加载失败
if (attempts <= 3) {
retry();
} else {
fail();
// 如果需要,手动向上传播
throw error;
}
}
});
陷阱 5:app.config.errorHandler 中抛出错误导致原始错误丢失
错误代码:
app.config.errorHandler = (err, instance, info) => {
// 如果 Sentry.captureException 本身抛出了错误...
Sentry.captureException(err); // 假设 Sentry 未初始化,这里抛出错误
// 原始错误 err 被新错误覆盖,调试时找不到根本原因
};
正确做法:在全局错误处理器中包裹 try-catch:
app.config.errorHandler = (err, instance, info) => {
try {
Sentry.captureException(err, {
extra: { info, component: instance?.type?.name }
});
} catch (reportingError) {
// 上报失败时的兜底:至少记录到控制台
console.error('Error reporting failed:', reportingError);
console.error('Original error:', err);
}
};
本章小结
-
返回 false 才能阻止传播:
onErrorCaptured中不返回(返回undefined)意味着错误继续向上传播,这与大多数事件系统的默认行为相反;必须显式返回false才能拦截错误。 -
三层防线各司其职:
onErrorCaptured负责细粒度的组件级拦截;app.config.errorHandler是全局兜底;window.addEventListener('unhandledrejection')捕获 Vue 无法接触的独立 Promise 错误。 -
info 参数是调试金矿:
'setup function'、'render function'、'watcher callback'等值精确标识了错误发生的位置,在 Sentry 上报中加入这个信息能大幅降低排查时间。 -
async/await 与独立 Promise 的捕获边界:
onMounted(async () => { ... })中的错误 Vue 能捕获,但onMounted(() => { somePromise.then(...) })中未 await 的 Promise 错误 Vue 不能捕获——这是最常见的异步错误处理盲区。 -
Sentry 接入要包裹 try-catch:
app.config.errorHandler是最后的防线,如果这里也抛出错误,原始错误信息将丢失,给调试带来困难;所有上报调用都应有 try-catch 兜底。