JS异步错误捕获的一些事小结
引入
我们都知道trycatch无法捕获setTimeout异步任务中的错误,那其中的原因是什么。以及异步代码在js中是特别常见的,我们该怎么做才比较?
无法捕获的情况
functionmain(){
try{
setTimeout(()=>{
thrownewError('asyncerror')
},1000)
}catch(e){
console.log(e,'err')
console.log('continue...')
}
}
main();
这段代码中,setTimeout的回调函数抛出一个错误,并不会在catch中捕获,会导致程序直接报错崩掉。
所以说在js中trycatch并不是说写上一个就可以高枕无忧了。难道每个函数都要写吗,
那什么情况下trycatch无法捕获error呢?
异步任务
- 宏任务的回调函数中的错误无法捕获
上面的栗子稍微改一下,主任务中写一段trycatch,然后调用异步任务task,task会在一秒之后抛出一个错误。
//异步任务
consttask=()=>{
setTimeout(()=>{
thrownewError('asyncerror')
},1000)
}
//主任务
functionmain(){
try{
task();
}catch(e){
console.log(e,'err')
console.log('continue...')
}
}
这种情况下main是无法catcherror的,这跟浏览器的执行机制有关。异步任务由eventloop加入任务队列,并取出入栈(js主进程)执行,而当task取出执行的时候,main的栈已经退出了,也就是上下文环境已经改变,所以main无法捕获task的错误。
事件回调,请求回调同属tasks,所以道理是一样的。eventloop复习可以看这篇文章
- 微任务(promise)的回调
//返回一个promise对象
constpromiseFetch=()=>
newPromise((reslove)=>{
reslove();
})
functionmain(){
try{
//回调函数里抛出错误
promiseFetch().then(()=>{
thrownewError('err')
})
}catch(e){
console.log(e,'eeee');
console.log('continue');
}
}
promise的任务,也就是then里面的回调函数,抛出错误同样也无法catch。因为微任务队列是在两个task之间清空的,所以then入栈的时候,main函数也已经出栈了。
并不是回调函数无法trycatch
很多人可能有一个误解,因为大部分遇到无法catch的情况,都发生在回调函数,就认为回调函数不能catch。
不全对,看一个最普通的栗子。
//定义一个fn,参数是函数。
constfn=(cb:()=>void)=>{
cb();
};
functionmain(){
try{
//传入callback,fn执行会调用,并抛出错误。
fn(()=>{
thrownewError('123');
})
}catch(e){
console.log('error');
}
}
main();
结果当然是可以catch的。因为callback执行的时候,跟main还在同一次事件循环中,即一个eventlooptick。所以上下文没有变化,错误是可以catch的。
根本原因还是同步代码,并没有遇到异步任务。
promise的异常捕获
构造函数
先看两段代码:
functionmain1(){
try{
newPromise(()=>{
thrownewError('promise1error')
})
}catch(e){
console.log(e.message);
}
}
functionmain2(){
try{
Promise.reject('promise2error');
}catch(e){
console.log(e.message);
}
}
以上两个trycatch都不能捕获到error,因为promise内部的错误不会冒泡出来,而是被promise吃掉了,只有通过promise.catch才可以捕获,所以用Promise一定要写catch啊。
然后我们再来看一下使用promise.catch的两段代码:
//reject
constp1=newPromise((reslove,reject)=>{
if(1){
reject();
}
});
p1.catch((e)=>console.log('p1error'));
//thrownewError
constp2=newPromise((reslove,reject)=>{
if(1){
thrownewError('p2error')
}
});
p2.catch((e)=>console.log('p2error'));
promise内部的无论是reject或者thrownewError,都可以通过catch回调捕获。
这里要跟我们最开始微任务的栗子区分,promise的微任务指的是then的回调,而此处是Promise构造函数传入的第一个参数,newPromise是同步执行的。
then
那then之后的错误如何捕获呢。
functionmain3(){
Promise.resolve(true).then(()=>{
try{
thrownewError('then');
}catch(e){
returne;
}
}).then(e=>console.log(e.message));
}
只能是在回调函数内部catch错误,并把错误信息返回,error会传递到下一个then的回调。
用Promise捕获异步错误
constp3=()=>newPromise((reslove,reject)=>{
setTimeout(()=>{
reject('asyncerror');
})
});
functionmain3(){
p3().catch(e=>console.log(e));
}
main3();
把异步操作用Promise包装,通过内部判断,把错误reject,在外面通过promise.catch捕获。
async/await的异常捕获
首先我们模拟一个请求失败的函数fetchFailure,fetch函数通常都是返回一个promise。
main函数改成async,catch去捕获fetchFailurereject抛出的错误。能不能获取到呢。
constfetchFailure=()=>newPromise((resolve,reject)=>{
setTimeout(()=>{//模拟请求
if(1)reject('fetchfailure...');
})
})
asyncfunctionmain(){
try{
constres=awaitfetchFailure();
console.log(res,'res');
}catch(e){
console.log(e,'e.message');
}
}
main();
async函数会被编译成好几段,根据await关键字,以及catch等,比如main函数就是拆成三段。
1.fetchFailure2.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函数处理异步流程是利器,但是它也不会自动去catch错误,需要我们自己写trycatch,如果每个函数都写一个,也挺麻烦的,比较业务中异步函数会很多。
首先想到的是把trycatch,以及catch后的逻辑抽取出来。
consthandle=async(fn:any)=>{
try{
returnawaitfn();
}catch(e){
//dosth
console.log(e,'e.messagee');
}
}
asyncfunctionmain(){
constres=awaithandle(fetchFailure);
console.log(res,'res');
}
写一个高阶函数包裹fetchFailure,高阶函数复用逻辑,比如此处的trycatch,然后执行传入的参数-函数即可。
然后,加上回调函数的参数传递,以及返回值遵守first-error,向node/go的语法看齐。如下:
consthandleTryCatch=(fn:(...args:any[])=>Promise<{}>)=>async(...args:any[])=>{
try{
return[null,awaitfn(...args)];
}catch(e){
console.log(e,'e.messagee');
return[e];
}
}
asyncfunctionmain(){
const[err,res]=awaithandleTryCatch(fetchFailure)('');
if(err){
console.log(err,'err');
return;
}
console.log(res,'res');
}
但是还有几个问题,一个是catch后的逻辑,这块还不支持自定义,再就是返回值总要判断一下,是否有error,也可以抽象一下。
所以我们可以在高阶函数的catch处做一下文章,比如加入一些错误处理的回调函数支持不同的逻辑,然后一个项目中错误处理可以简单分几类,做不同的处理,就可以尽可能的复用代码了。
//1.三阶函数。第一次传入错误处理的handle,第二次是传入要修饰的async函数,最后返回一个新的function。
consthandleTryCatch=(handle:(e:Error)=>void=errorHandle)=>
(fn:(...args:any[])=>Promise<{}>)=>async(...args:any[])=>{
try{
return[null,awaitfn(...args)];
}catch(e){
return[handle(e)];
}
}
//2.定义各种各样的错误类型
//我们可以把错误信息格式化,成为代码里可以处理的样式,比如包含错误码和错误信息
classDbErrorextendsError{
publicerrmsg:string;
publicerrno:number;
constructor(msg:string,code:number){
super(msg);
this.errmsg=msg||'db_error_msg';
this.errno=code||20010;
}
}
classValidatedErrorextendsError{
publicerrmsg:string;
publicerrno:number;
constructor(msg:string,code:number){
super(msg);
this.errmsg=msg||'validated_error_msg';
this.errno=code||20010;
}
}
//3.错误处理的逻辑,这可能只是其中一类。通常错误处理都是按功能需求来划分
//比如请求失败(200但是返回值有错误信息),比如node中写db失败等。
consterrorHandle=(e:Error)=>{
//dosomething
if(einstanceofValidatedError||einstanceofDbError){
//dosth
returne;
}
return{
code:101,
errmsg:'unKnown'
};
}
constusualHandleTryCatch=handleTryCatch(errorHandle);
//以上的代码都是多个模块复用的,那实际的业务代码可能只需要这样。
asyncfunctionmain(){
const[error,res]=awaitusualHandleTryCatch(fetchFail)(false);
if(error){
//因为catch已经做了拦截,甚至可以加入一些通用逻辑,这里甚至不用判断iferror
console.log(error,'error');
return;
}
console.log(res,'res');
}
解决了一些错误逻辑的复用问题之后,即封装成不同的错误处理器即可。但是这些处理器在使用的时候,因为都是高阶函数,可以使用es6的装饰器写法。
不过装饰器只能用于类和类的方法,所以如果是函数的形式,就不能使用了。不过在日常开发中,比如React的组件,或者Mobx的store,都是以class的形式存在的,所以使用场景挺多的。
比如改成类装饰器:
constasyncErrorWrapper=(errorHandler:(e:Error)=>void=errorHandle)=>(target:Function)=>{
constprops=Object.getOwnPropertyNames(target.prototype);
props.forEach((prop)=>{
varvalue=target.prototype[prop];
if(Object.prototype.toString.call(value)==='[objectAsyncFunction]'){
target.prototype[prop]=async(...args:any[])=>{
try{
returnawaitvalue.apply(this,args);
}catch(err){
returnerrorHandler(err);
}
}
}
});
}
@asyncErrorWrapper(errorHandle)
classStore{
asyncgetList(){
returnPromise.reject('类装饰:失败了');
}
}
conststore=newStore();
asyncfunctionmain(){
consto=awaitstore.getList();
}
main();
这种class装饰器的写法是看到黄子毅这么写过,感谢灵感。
koa的错误处理
如果对koa不熟悉,可以选择跳过不看。
koa中当然也可以用上面async的做法,不过通常我们用koa写server的时候,都是处理请求,一次http事务会掉起响应的中间件,所以koa的错误处理很好的利用了中间件的特性。
比如我的做法是,第一个中间件为捕获error,因为洋葱模型的缘故,第一个中间件最后仍会执行,而当某个中间件抛出错误后,我期待能在此捕获并处理。
//第一个中间件
consterrorCatch=async(ctx,next)=>{
try{
awaitnext();
}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');
awaitnext();
})
//router的某个中间件
router.get('/error',async(ctx,next)=>{
if(1){
thrownewError('错误测试')
}
awaitnext();
})
为什么在第一个中间件写上trycatch,就可以捕获前面中间件throw出的错误呢。首先我们前面async/await的地方解释过,async中awaithandle(),handle函数内部的thrownewError或者Promise.reject()是可以被async的catch捕获的。所以只需要next函数能够拿到错误,并抛出就可以了,那看看next函数。
//compose是传入中间件的数组,最终形成中间件链的,next控制游标。
compose(middlewares){
return(context)=>{
letindex=0;
//为了每个中间件都可以是异步调用,即`awaitnext()`这种写法,每个next都要返回一个promise对象
functionnext(index){
constfunc=middlewares[index];
try{
//在此处写trycatch,因为是写到Promise构造体中的,所以抛出的错误能被catch
returnnewPromise((resolve,reject)=>{
if(index>=middlewares.length)returnreject('nextisinexistence');
resolve(func(context,()=>next(index+1)));
});
}catch(err){
//捕获到错误,返回错误
returnPromise.reject(err);
}
}
returnnext(index);
}
}
next函数根据index,取出当前的中间件执行。中间件函数如果是async函数,同样的转化为generator执行,内部的异步代码顺序由它自己控制,而我们知道async函数的错误是可以通过trycatch捕获的,所以在next函数中加上trycatch捕获中间件函数的错误,再return抛出去即可。所以我们才可以在第一个中间件捕获。详细代码可以看下简版koa
然后koa还提供了ctx.throw和全局的app.on来捕获错误。
如果你没有写错误处理的中间件,那可以使用ctx.throw返回前端,不至于让代码错误。
但是thrownewError也是有优势的,因为某个中间件的代码逻辑中,一旦出现我们不想让后面的中间件执行,直接给前端返回,直接抛出错误即可,让通用的中间件处理,反正都是错误信息。
//定义不同的错误类型,在此可以捕获,并处理。
consterrorCatch=async(ctx,next)=>{
try{
awaitnext();
}catch(err){
const{errmsg,errno,status=500,redirect}=err;
if(errinstanceofValidatedError||errinstanceofDbError||errinstanceofAuthError||errinstanceofRequestError){
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');
awaitnext();
})
//通过ctx.throw
app.use(async(ctx,next)=>{
//willNOTlogtheerrorandwillreturn`ErrorMessage`astheresponsebodywithstatus400
ctx.throw(400,'ErrorMessage');
});
//router的某个中间件
router.get('/error',async(ctx,next)=>{
if(1){
thrownewError('错误测试')
}
awaitnext();
})
//最后的兜底
app.on('error',(err,ctx)=>{
/*centralizederrorhandling:
*console.logerror
*writeerrortologfile
*saveerrorandrequestinformationtodatabaseifctx.requestmatchcondition
*...
*/
});
最后
本文的代码都存放于此
总的来说,目前async结合promise去处理js的异步错误会是比较方便的。另外,成熟的框架(react、koa)对于错误处理都有不错的方式,尽可能去看一下官方是如何处理的。
这只是我对js中处理异步错误的一些理解。不过前端的需要捕获异常的地方有很多,比如前端的代码错误,cors跨域错误,iframe的错误,甚至react和vue的错误我们都需要处理,以及异常的监控和上报,以帮助我们及时的解决问题以及分析稳定性。采取多种方案应用到我们的项目中,让我们不担心页面挂了,或者又报bug了,才能安安稳稳的去度假休息