处理全局未捕获的Promise错误事件unhandledrejection及延伸

unhandledrejection 完整指南

一、它是什么

Promise 被 reject 但没有被 .catch() / try-await 接住,事件会冒泡到 unhandledrejection。
浏览器只会在控制台打一行警告,不抛错,不上报,线上事故 80% 就是这样漏掉的。

❌  throw new Error('xxx')                    → 被 try-catch 接住,正常
❌  async fn() { throw new Error('xxx') }     → reject 没人接 → unhandledrejection
✅  async fn() { 
 try { throw ... } catch (e) { ... }     → 接住了,没事 }

二、三个监听点(一个都不能少)

错误来源 监听 关键对象
同步 throw(throw new Error) window.addEventListener('error', ...) ErrorEvent
异步 reject(async/Promise) window.addEventListener('unhandledrejection', ...) PromiseRejectionEvent
Vue 组件 render/watch app.config.errorHandler (err, vm, info)

⚠️ 三个都要监听,只监听 error 抓不到 async;只监听 errorHandler 抓不到全局同步 throw。

// 1. 同步
window.addEventListener('error', (e) => {
  console.error('JS错误', e.message, e.filename, e.lineno, e.colno)
}, true)  // ⭐ 第三参数 true = 捕获阶段,能抓到更早的源

// 2. 异步 reject
window.addEventListener('unhandledrejection', (e) => {
  e.preventDefault()  // ⭐ 必须调!否则浏览器控制台还会打警告
  console.error('Promise 错误', e.reason)
})

// 3. Vue
app.config.errorHandler = (err, vm, info) => {
  console.error('Vue 错误', err, info)  // info = 'mounted hook' / 'render function' 等
}

三、⭐ 拿到具体文件 + 行号

关键:真正的信息在 e.reason.stack 里,不是 e.reason 本身。

window.addEventListener('unhandledrejection', (e) => {
  e.preventDefault()
  const reason = e.reason                       // { message, stack, ... }
  const stack: string = reason?.stack ?? ''

  // stack 形如:
  //   "TypeError: Failed to fetch\n    at fetchUser (https://xxx.com/app.js:42:13)\n    at async handleClick (https://xxx.com/app.js:88:5)"

  const { filename, lineno, colno } = parseStack(stack)
  // → filename: 'https://xxx.com/app.js'
  // → lineno: 42
  // → colno: 13
})

function parseStack(stack: string) {
  if (!stack) return {}
  // 匹配第一个 at 行(最贴近错误的栈帧)
  const m = stack.match(/at\s+(?:.*?\s+\()?(.+?):(\d+):(\d+)\)?/)
  return m
    ? { filename: m[1], lineno: Number(m[2]), colno: Number(m[3]) }
    : {}
}
字段 来源 说明
filename e.reason.stack 正则 错误发生的脚本 URL
lineno e.reason.stack 正则 行号
colno e.reason.stack 正则 列号

⚠️ e.filename/lineno/colno 是 ErrorEvent 的字段,PromiseRejectionEvent 没有这三个。Promise 必须从 reason.stack 解析。

四、生产环境:堆栈指向压缩后的代码

生产 app.abc123.js 是混淆压缩的,lineno=12345 对开发没用。需要 source-map 反解:

// 方案 1:交给 Sentry(自动处理)
Sentry.init({
  dsn: '...',
  // 上传 sourcemap:vite 用 @sentry/vite-plugin
})

// 方案 2:自建 + source-map 反解
import { SourceMapConsumer } from 'source-map'

const consumer = await new SourceMapConsumer(loadSourceMap())
const pos = consumer.originalPositionFor({ line: 12345, column: 13 })
// → { source: 'src/api/user.ts', line: 42, column: 13, name: 'fetchUser' }
方案 优点 缺点
Sentry 自动聚合、告警、上下文、自动 source-map 第三方依赖
自建 + source-map 完全可控 要写上传 + 反解 + 告警

五、⭐ Vue 3 实战:useErrorMonitor composable

import { onScopeDispose, type App } from 'vue'

export interface ErrorReport {
  type: 'js' | 'promise' | 'vue' | 'fetch'
  message: string
  stack?: string
  filename?: string
  lineno?: number
  colno?: number
  /** Vue errorHandler 的 info:'mounted hook' / 'render function' 等 */
  info?: string
  /** 发生时间 */
  time: number
  /** 浏览器 / UA / URL 等 */
  context?: Record<string, unknown>
}

export function useErrorMonitor(upload: (r: ErrorReport) => void = () => {}) {
  // 同步 throw
  const onError = (e: ErrorEvent) => {
    upload({
      type: 'js',
      message: e.message,
      stack: e.error?.stack,
      filename: e.filename,
      lineno: e.lineno,
      colno: e.colno,
      time: Date.now(),
    })
  }

  // 异步 reject
  const onRejection = (e: PromiseRejectionEvent) => {
    e.preventDefault()
    const reason = e.reason ?? {}
    const stack = reason.stack ?? ''
    const parsed = parseStack(stack)
    upload({
      type: 'promise',
      message: String(reason.message ?? reason),
      stack,
      ...parsed,
      time: Date.now(),
    })
  }

  window.addEventListener('error', onError, true)
  window.addEventListener('unhandledrejection', onRejection)

  onScopeDispose(() => {
    window.removeEventListener('error', onError, true)
    window.removeEventListener('unhandledrejection', onRejection)
  })

  return {
    /** 装到 Vue 实例上 */
    install(app: App) {
      app.config.errorHandler = (err, _vm, info) => {
        upload({
          type: 'vue',
          message: String((err as Error)?.message ?? err),
          stack: (err as Error)?.stack,
          info,
          time: Date.now(),
        })
      }
    },
  }
}

function parseStack(stack: string) {
  if (!stack) return {}
  const m = stack.match(/at\s+(?:.*?\s+\()?(.+?):(\d+):(\d+)\)?/)
  return m
    ? { filename: m[1], lineno: Number(m[2]), colno: Number(m[3]) }
    : {}
}

在 main.ts 启用

import { createApp } from 'vue'
import { useErrorMonitor } from '@/utils/useErrorMonitor'

const app = createApp(...)

useErrorMonitor((report) => {
  // 1. 开发期看本地
  if (import.meta.env.DEV) console.error('[ErrorMonitor]', report)
  // 2. 上报
  fetch('/api/log/error', { method: 'POST', body: JSON.stringify(report) })
}).install(app)

六、axios 拦截器里的坑

常见错误:拦截器里 console.error(err) 后 return Promise.reject(err) —— 看似没问题,但 axios 的 err.stack 没有原始调用点,拿不到业务代码的行号。

// ❌ 错误:err 是 AxiosError,stack 指向 axios 内部
service.interceptors.response.use(
  res => res.data,
  err => {
    console.error(err)
    return Promise.reject(err)
  },
)

// ✅ 正确:包装成标准 Error 抛出,stack 包含业务调用点
service.interceptors.response.use(
  res => res.data,
  err => {
    const wrapped = new Error(err.message)
    wrapped.cause = err  // 保留 axios 原始错误
    Object.assign(wrapped, {
      code: err.code,
      status: err.response?.status,
      url: err.config?.url,
      method: err.config?.method,
    })
    return Promise.reject(wrapped)  // stack 会包含业务代码
  },
)

💡 另一种思路:业务调用处用 throw new Error('业务码描述') 显式抛出,捕获的堆栈直接是业务代码位置。

七、特殊场景

① 微任务里 throw 不会报警

queueMicrotask(() => { throw new Error('xxx') })  // ❌ 抓不到
queueMicrotask(() => { Promise.reject(new Error('xxx')) })  // ❌ 也抓不到
queueMicrotask(async () => { throw new Error('xxx') })  // ✅ async 包装才触发 unhandledrejection

② setTimeout 里 reject

setTimeout(() => Promise.reject(new Error('xxx')), 0)  // ✅ 能抓到

③微信 WebView /移动端 UA特殊

部分安卓 WebView 的 unhandledrejection 不触发。兜底:业务每个 catch 都手动 upload(report),不要依赖全局监听。

八、和现有 Sentry / 日志体系结合

// Sentry 接入
import * as Sentry from '@sentry/vue'

useErrorMonitor((report) => {
  Sentry.captureException(new Error(report.message), {
    extra: report,
  })
}).install(app)

这样你既有自研可控的上报(业务逻辑、上下文),又有 Sentry 的聚合/告警/反解。

九、自检题

  1. unhandledrejection 和 window.onerror 的监听对象有什么本质区别?为什么必须都监听?
  2. 如何从 unhandledrejection 事件里拿到具体的文件和行号?为什么 e.filename 是 undefined?
  3. 生产环境堆栈指向的是压缩后的代码(app.abc.js:12345),怎么映射回源码?
  4. Vue 3 的 app.config.errorHandler 能捕获 async/await 抛出的错吗?为什么?
  5. axios 拦截器里 console.error(err); return Promise.reject(err) 有什么副作用?
答案
  1. error 监听 ErrorEvent(同步 throw),PromiseRejectionEvent 没有这个事件。unhandledrejection 监听异步的 Promise reject。两者事件源不同,必须都监听,否则50% 错误漏报。
  2. PromiseRejectionEvent 只有 reason 字段(不带 filename/lineno)。具体位置要从 e.reason.stack 用正则解析,提取 at xxx (url:line:col) 第一帧。
  3. 需要 source-map 反解。生产构建时保留或上传 .map,运行时用 source-map 库的 SourceMapConsumer.originalPositionFor({line, column}) 反解。最简单的做法是接入 Sentry,它自动处理。
  4. 不能。errorHandler 只处理组件渲染和 watch 回调里同步 throw。async 函数 / await 之后的 reject 会逃出 errorHandler,必须靠 unhandledrejection。所以三件套(error / unhandledrejection / errorHandler)缺一不可。
  5. axios 的 AxiosError.stack 指向 axios 内部,不是业务调用点。如果只在拦截器里 console.error 然后透传,业务代码里 catch(e) { e.stack } 看到的是 axios 内部行号。应该把 axios error 包装成新 Error 抛出,新 Error 的 stack 自动包含业务调用点;或者把业务上下文作为 extra字段挂在 error 上。
posted @ 2026-10-20 14:36  寅信  阅读(18)  评论(0)    收藏  举报