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

浙公网安备 33010602011771号