JavaScript 异步错误捕获机制与最佳实践

作者:青柠

时间:2026-10-23

浏览:84

本文深入解析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...

好了,以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对本站的支持。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章