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了,才能安安稳稳的去度假休息