JavaScript 异步错误捕获机制与最佳实践
本文深入解析JavaScript中try-catch无法捕获异步错误的根本原因,包括宏任务与微任务的执行机制差异。详细对比了Promise构造函数、then链及async/await三种异步模式的错误捕获方式,并探讨了通过高阶函数和装饰器封装通用错误处理逻辑的方法,最后结合Koa框架中间件机制阐述服务端错误处理最佳实践。
JavaScript 异步错误捕获机制与最佳实践
引言
众所周知,传统的 try-catch 语句无法捕获 setTimeout 等异步任务中抛出的错误。理解其背后的原因至关重要,鉴于异步代码在 JavaScript 开发中的普遍性,我们需要探讨更有效的错误处理策略。
try-catch 无法捕获异步错误的原因
无法捕获的情况
function main() {
try {
setTimeout(() => {
throw new Error('async error')
}, 1000)
} catch(e) {
console.log(e, 'err')
console.log('continue...')
}
}
main();
在以下代码示例中,setTimeout 的回调函数内抛出一个错误,该错误并不会被外层的 catch 块捕获,而是会导致程序直接报错并崩溃。
这表明在 JavaScript 中,仅仅添加 try-catch 并不能保证万无一失。那么,究竟哪些情况下的错误无法被 try-catch 捕获呢?
异步任务
- 宏任务的回调函数中的错误无法被捕获。
对上述示例稍作修改:在主任务中编写一段 try-catch 代码,并调用异步任务 task,该 task 会在 1 秒后抛出一个错误。
// 异步任务
const task = () => {
setTimeout(() => {
throw new Error('async error')
}, 1000)
}
// 主任务
function main() {
try {
task();
} catch(e) {
console.log(e, 'err')
console.log('continue...')
}
}
在这种情况下,main 函数无法 catch 到 error,这与浏览器的执行机制密切相关。异步任务由 event loop 加入任务队列,随后取出并压入 JavaScript 主进程执行栈。当 task 被取出执行时,main 函数的调用栈已经退出,即上下文环境已发生改变,因此 main 无法捕获 task 的错误。
事件回调和请求回调同属宏任务(tasks),原理相同。如需复习 event loop 机制,可参考相关文章。
- 微任务(Promise)的回调
// 返回一个 promise 对象
const promiseFetch = () =>
new Promise((reslove) => {
reslove();
})
function main() {
try {
// 回调函数里抛出错误
promiseFetch().then(() => {
throw new Error('err')
})
} catch(e) {
console.log(e, 'eeee');
console.log('continue');
}
}
Promise 的任务,即 then 中的回调函数,若抛出错误同样无法被外层的 try-catch 捕获。这是因为微任务队列是在两个宏任务之间清空的,当 then 回调入栈执行时,main 函数也已经出栈。
并非所有回调函数都无法被 try-catch 捕获
许多人可能存在一个误解,认为既然大部分无法 catch 的情况都发生在回调函数中,那么回调函数就不能被 catch。
这种观点并不完全正确,请看以下普通示例。
// 定义一个 fn,参数是函数。
const fn = (cb: () => void) => {
cb();
};
function main() {
try {
// 传入 callback,fn 执行会调用,并抛出错误。
fn(() => {
throw new Error('123');
})
} catch(e) {
console.log('error');
}
}
main();
结果是可以被 catch 的。因为 callback 执行时,与 main 函数仍处于同一次事件循环(event loop tick)中,上下文未发生变化,因此错误可以被正常捕获。
根本原因在于这是同步代码,并未遇到异步任务。
Promise 的异常捕获机制
Promise 的异常捕获
构造函数
先看两段代码示例。
function main1() {
try {
new Promise(() => {
throw new Error('promise1 error')
})
} catch(e) {
console.log(e.message);
}
}
function main2() {
try {
Promise.reject('promise2 error');
} catch(e) {
console.log(e.message);
}
}
以上两个 try-catch 均无法捕获到 error,因为 Promise 内部的错误不会冒泡出来,而是被 Promise 内部消化。只有通过 Promise.catch 才能捕获,因此使用 Promise 时务必编写 catch 处理。
接下来我们查看使用 Promise.catch 的两段代码。
// reject
const p1 = new Promise((reslove, reject) => {
if(1) {
reject();
}
});
p1.catch((e) => console.log('p1 error'));
// throw new Error
const p2 = new Promise((reslove, reject) => {
if(1) {
throw new Error('p2 error')
}
});
p2.catch((e) => console.log('p2 error'));
无论是 Promise 内部的 reject 还是 throw new Error,都可以通过 catch 回调进行捕获。
这里需要与前述微任务示例区分:Promise 的微任务指的是 then 的回调,而此处是 Promise 构造函数传入的第一个参数。new Promise 是同步执行的。
then
那么 then 之后的错误该如何捕获呢?
function main3() {
Promise.resolve(true).then(() => {
try {
throw new Error('then');
} catch(e) {
return e;
}
}).then(e => console.log(e.message));
}
只能在回调函数内部 catch 错误,并将错误信息返回,error 会传递到下一个 then 的回调中。
使用 Promise 捕获异步错误
const p3 = () => new Promise((reslove, reject) => {
setTimeout(() => {
reject('async error');
})
});
function main3() {
p3().catch(e => console.log(e));
}
main3();
将异步操作封装为 Promise,通过内部判断将错误 reject,并在外部通过 promise.catch 进行捕获。
async/await 的异常捕获与封装
async/await 的异常捕获
首先模拟一个请求失败的函数 fetchFailure,fetch 函数通常返回一个 Promise。
将 main 函数改为 async,并使用 catch 去捕获 fetchFailure reject 抛出的错误。能否获取到呢?
const fetchFailure = () => new Promise((resolve, reject) => {
setTimeout(() => {// 模拟请求
if(1) reject('fetch failure...');
})
})
async function main () {
try {
const res = await fetchFailure();
console.log(res, 'res');
} catch(e) {
console.log(e, 'e.message');
}
}
main();
async 函数会被编译成多段代码,根据 await 关键字及 catch 等进行拆分,例如 main 函数会被拆成三段。
1. fetchFailure 2. console.log(res) 3. catch
通过 step 控制迭代进度,例如 'next' 表示向下执行一步,从 1 到 2。异步操作由 Promise.then() 控制,可以理解为就是一个 Promise 链,感兴趣者可深入研究。关键在于生成器也有一个 'throw' 状态,当 Promise 状态为 reject 后,错误会向上冒泡,直到 step('throw') 执行,进而执行 catch 中的代码 console.log(e, 'e.message')。
显然,async/await 的错误处理更加优雅,这也是内部配合使用了 Promise 的结果。
更进一步
async 函数处理异步流程是利器,但它不会自动捕获错误,需要我们手动编写 try-catch。如果每个函数都写一个,较为繁琐,尤其是业务中异步函数众多。
首先想到的是将 try-catch 及 catch 后的逻辑抽取出来。
const handle = async (fn: any) => {
try {
return await fn();
} catch(e) {
// do sth
console.log(e, 'e.messagee');
}
}
async function main () {
const res = await handle(fetchFailure);
console.log(res, 'res');
}
编写一个高阶函数包裹 fetchFailure,通过复用逻辑(如 try-catch)并执行传入的参数函数即可。
然后,加上回调函数的参数传递,以及返回值遵循 first-error 原则,向 Node/Go 的语法风格看齐。如下示例。
const handleTryCatch = (fn: (...args: any[]) => Promise<{}>) => async (...args: any[]) => {
try {
return [null, await fn(...args)];
} catch(e) {
console.log(e, 'e.messagee');
return [e];
}
}
async function main () {
const [err, res] = await handleTryCatch(fetchFailure)('');
if(err) {
console.log(err, 'err');
return;
}
console.log(res, 'res');
}
但还有几个问题:一是 catch 后的逻辑尚不支持自定义;二是返回值总需判断是否有 error,也可以进一步抽象。
因此,我们可以在高阶函数的 catch 处做文章,例如加入错误处理的回调函数以支持不同逻辑。在一个项目中,错误处理可简单分为几类并进行不同处理,从而尽可能复用代码。
// 1. 三阶函数。第一次传入错误处理的 handle,第二次是传入要修饰的 async 函数,最后返回一个新的 function。
const handleTryCatch = (handle: (e: Error) => void = errorHandle) =>
(fn: (...args: any[]) => Promise<{}>) => async(...args: any[]) => {
try {
return [null, await fn(...args)];
} catch(e) {
return [handle(e)];
}
}
// 2. 定义各种各样的错误类型
// 我们可以把错误信息格式化,成为代码里可以处理的样式,比如包含错误码和错误信息
class DbError extends Error {
public errmsg: string;
public errno: number;
constructor(msg: string, code: number) {
super(msg);
this.errmsg = msg || 'db_error_msg';
this.errno = code || 20010;
}
}
class ValidatedError extends Error {
public errmsg: string;
public errno: number;
constructor(msg: string, code: number) {
super(msg);
this.errmsg = msg || 'validated_error_msg';
this.errno = code || 20010;
}
}
// 3. 错误处理的逻辑,这可能只是其中一类。通常错误处理都是按功能需求来划分
// 比如请求失败(200 但是返回值有错误信息),比如 node 中写 db 失败等。
const errorHandle = (e: Error) => {
// do something
if(e instanceof ValidatedError || e instanceof DbError) {
// do sth
return e;
}
return {
code: 101,
errmsg: 'unKnown'
};
}
const usualHandleTryCatch = handleTryCatch(errorHandle);
// 以上的代码都是多个模块复用的,那实际的业务代码可能只需要这样。
async function main () {
const [error, res] = await usualHandleTryCatch(fetchFail)(false);
if(error) {
// 因为 catch 已经做了拦截,甚至可以加入一些通用逻辑,这里甚至不用判断 if error
console.log(error, 'error');
return;
}
console.log(res, 'res');
}
解决了一些错误逻辑的复用问题后,即封装成不同的错误处理器。这些处理器在使用时,由于都是高阶函数,可以使用 ES6 的装饰器写法。
不过装饰器仅适用于类和类的方法,如果是普通函数形式则无法使用。但在日常开发中,例如 React 组件或 Mobx 的 store,多以 class 形式存在,因此使用场景较多。
例如改为类装饰器。
const asyncErrorWrapper = (errorHandler: (e: Error) => void = errorHandle) => (target: Function) => {
const props = Object.getOwnPropertyNames(target.prototype);
props.forEach((prop) => {
var value = target.prototype[prop];
if(Object.prototype.toString.call(value) === '[object AsyncFunction]'){
target.prototype[prop] = async (...args: any[]) => {
try{
return await value.apply(this,args);
}catch(err){
return errorHandler(err);
}
}
}
});
}
@asyncErrorWrapper(errorHandle)
class Store {
async getList (){
return Promise.reject('类装饰:失败了');
}
}
const store = new Store();
async function main() {
const o = await store.getList();
}
main();
这种 class 装饰器的写法源自黄子毅,感谢其灵感。
Koa 框架中的错误处理策略
Koa 的错误处理
如果不熟悉 Koa,可选择跳过此部分。
Koa 中当然也可以使用上述 async 的做法,但通常我们用 Koa 编写服务器时主要处理请求。一次 HTTP 事务会唤起响应的中间件,因此 Koa 的错误处理很好地利用了中间件的特性。
例如我的做法是,将第一个中间件设为捕获 error。由于洋葱模型的原因,第一个中间件最后仍会执行,而当某个中间件抛出错误后,我期望能在此处捕获并处理。
// 第一个中间件
const errorCatch = async(ctx, next) => {
try {
await next();
} catch(e) {
// 在此捕获 error 路由,throw 出的 Error
console.log(e, e.message, 'error');
ctx.body = 'error';
}
}
app.use(errorCatch);
// logger
app.use(async (ctx, next) => {
console.log(ctx.req.body, 'body');
await next();
})
// router 的某个中间件
router.get('/error', async (ctx, next) => {
if(1) {
throw new Error('错误测试')
}
await next();
})
为什么在第一个中间件上写 try-catch 就能捕获前面中间件 throw 出的错误?首先我们在前文 async/await 部分解释过,async 中 await handle(),handle 函数内部的 throw new Error 或 Promise.reject() 可以被 async 的 catch 捕获。因此只需要 next 函数能够拿到错误并抛出即可,那看看 next 函数。
// compose 是传入中间件的数组,最终形成中间件链的,next 控制游标。
compose(middlewares) {
return (context) => {
let index = 0;
// 为了每个中间件都可以是异步调用,即 `await next()` 这种写法,每个 next 都要返回一个 promise 对象
function next(index) {
const func = middlewares[index];
try {
// 在此处写 try catch,因为是写到 Promise 构造体中的,所以抛出的错误能被 catch
return new Promise((resolve, reject) => {
if (index >= middlewares.length) return reject('next is inexistence');
resolve(func(context, () => next(index + 1)));
});
} catch(err) {
// 捕获到错误,返回错误
return Promise.reject(err);
}
}
return next(index);
}
}
next 函数根据 index 取出当前中间件执行。中间件函数如果是 async 函数,同样转化为 generator 执行,内部的异步代码顺序由它自己控制。而我们知道 async 函数的错误可以通过 try-catch 捕获,因此在 next 函数中加上 try-catch 捕获中间件函数的错误,再 return 抛出去即可。所以我们才可以在第一个中间件捕获。详细代码可参考简版 Koa。
此外,Koa 还提供了 ctx.throw 和全局的 app.on 来捕获错误。
如果你没有编写错误处理的中间件,可以使用 ctx.throw 返回前端,不至于让代码错误。
但是 throw new Error 也有其优势,因为在某个中间件的代码逻辑中,一旦出现我们不想让后面的中间件执行的情况,直接给前端返回并抛出错误即可,让通用的中间件处理,反正都是错误信息。
// 定义不同的错误类型,在此可以捕获,并处理。
const errorCatch = async(ctx, next) => {
try {
await next();
} catch (err) {
const { errmsg, errno, status = 500, redirect } = err;
if (err instanceof ValidatedError || err instanceof DbError || err instanceof AuthError || err instanceof RequestError) {
ctx.status = 200;
ctx.body = {
errmsg,
errno,
};
return;
}
ctx.status = status;
if (status === 302 && redirect) {
console.log(redirect);
ctx.redirect(redirect);
}
if (status === 500) {
ctx.body = {
errmsg: err.message,
errno: 90001,
};
ctx.app.emit('error', err, ctx);
}
}
}
app.use(errorCatch);
// logger
app.use(async (ctx, next) => {
console.log(ctx.req.body, 'body');
await next();
})
// 通过 ctx.throw
app.use(async (ctx, next) => {
//will NOT log the error and will return `Error Message` as the response body with status 400
ctx.throw(400,'Error Message');
});
// router 的某个中间件
router.get('/error', async (ctx, next) => {
if(1) {
throw new Error('错误测试')
}
await next();
})
// 最后的兜底
app.on('error', (err, ctx) => {
/* centralized error handling:
* console.log error
* write error to log file
* save error and request information to database if ctx.request match condition
* ...
*/
});
最后
本文的代码均存放于此。
总的来说,目前 async 结合 Promise 去处理 JavaScript 的异步错误是比较方便的。另外,成熟的框架(如 React、Koa)对于错误处理都有不错的方式,建议尽可能查看官方文档是如何处理的。
这仅是我对 JavaScript 中处理异步错误的一些理解。不过前端需要捕获异常的地方有很多,例如前端代码错误、CORS 跨域错误、iframe 的错误,甚至 React 和 Vue 的错误都需要处理,以及异常的监控和上报,以帮助我们及时解决问题并分析稳定性。采取多种方案应用到项目中,让我们不担心页面挂了或又报 bug 了,才能安稳地去度假休息。
最后的最后,博客地址:https://github.com/sunyongjia...
好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对本站的支持。
ES6模块化中js文件怎么用import和export交互
2026-11-05 17:00:48
JS读取本地文件时报错或无响应的常见原因
2026-11-05 16:32:31
let、const与var的区别:JavaScript变量声明最佳实践
2026-11-05 14:16:38
JS中直接定义静态二维数组的字面量写法
2026-11-05 14:16:08
JS 怎么查找字符串中所有匹配项的位置
2026-11-05 14:15:53
