第 28 章

错误边界与全局错误处理:onErrorCaptured 传播链与监控接入

第28章:错误边界与全局错误处理——onErrorCaptured 传播链与监控接入

Vue 3 的错误传播系统有一个反直觉的设计:onErrorCaptured 返回 false阻止传播,不返回(返回 undefined)是继续传播——这与 DOM 事件的 stopPropagation 逻辑截然相反,是错误处理代码中最常见的一类 bug。

本章核心问题:当组件树中某处发生错误时,Vue 如何将错误沿组件树向上传递,以及如何在不同层次拦截和处理这些错误?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

1.1 错误从哪里来:Vue 捕获的错误类型

Vue 3 能自动捕获以下位置发生的错误:

同步错误(Vue 直接捕获):

异步错误(有条件捕获):

不能捕获的错误(需要手动处理):

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 参数:精确定位错误位置

onErrorCapturedapp.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        │
│  全局兜底,必须在这里记录       │
└─────────────────────────────────┘

关键细节

  1. onErrorCaptured 捕获的是子组件树中的错误,不是当前组件自身的错误
  2. 如果 onErrorCaptured 自身抛出了错误,这个新错误和原来的错误都会向上传播
  3. 错误传播是同步的,逐级向上,直到被拦截或到达全局处理器

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 的设计中:

// 正确写法:明确阻止传播
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);
  }
};

本章小结

  1. 返回 false 才能阻止传播onErrorCaptured 中不返回(返回 undefined)意味着错误继续向上传播,这与大多数事件系统的默认行为相反;必须显式返回 false 才能拦截错误。

  2. 三层防线各司其职onErrorCaptured 负责细粒度的组件级拦截;app.config.errorHandler 是全局兜底;window.addEventListener('unhandledrejection') 捕获 Vue 无法接触的独立 Promise 错误。

  3. info 参数是调试金矿'setup function''render function''watcher callback' 等值精确标识了错误发生的位置,在 Sentry 上报中加入这个信息能大幅降低排查时间。

  4. async/await 与独立 Promise 的捕获边界onMounted(async () => { ... }) 中的错误 Vue 能捕获,但 onMounted(() => { somePromise.then(...) }) 中未 await 的 Promise 错误 Vue 不能捕获——这是最常见的异步错误处理盲区。

  5. Sentry 接入要包裹 try-catchapp.config.errorHandler 是最后的防线,如果这里也抛出错误,原始错误信息将丢失,给调试带来困难;所有上报调用都应有 try-catch 兜底。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论