当前位置: 首页 > 科技观察

你所不知道的JavaScript错误和调用栈常识

时间:2023-03-16 14:19:14 科技观察

大多数工程师可能不会关注JS中的错误对象和错误栈的细节,即使在日常工作中面对大量的错误报告,一些同学们甚至在控制台的错误面前一头雾水,不知道从何入手去排查。如果你对本文讲解的内容有一个系统的了解,你会冷静很多。错误堆栈清理可以让您有效地去除噪音信息并专注于真正重要的事情。另外,如果了解了Error的各种属性是什么,就可以更好的利用它。接下来,让我们进入正题。调用栈的工作机制在讨论JS中的错误之前,我们必须了解调用栈(CallStack)的工作机制。其实这个机制很简单。如果你已经清楚这一点,可以直接跳过这部分。简单来说:当一个函数被调用时,它会被添加到调用栈的顶部。执行后,该函数将从调用栈的顶部移除。这种数据结构的关键是后进先出,即后进先出。例如,当我们在函数y中调用函数x时,调用栈从下到上的顺序是y->x。让我们再举一个代码示例:functionc(){console.log('c');}functionb(){console.log('b');c();}functiona(){console.log('a');b();}a();这段代码运行时,首先a会被添加到调用栈的顶部,然后,因为a在内部调用了b,所以b会被添加到调用栈的顶部,b在内部调用c时也是如此。在调用c的时候,我们的调用栈从下往上会是这样的顺序:a->b->c。c执行完后,c从调用栈中移除,控制流返回b,调用栈会变成:a->b,然后b执行完后,调用栈会变成:a,当a是执行完成后,也会从调用栈中移除。为了更好的说明调用栈的工作机制,我们对上面的代码稍作改动,使用console.trace将当前的调用栈输出到控制台。你可以认为console.trace打印的调用栈的每一行出现的原因都是它下面的那行call引起的。函数c(){console.log('c');console.trace();}functionb(){console.log('b');c();}functiona(){console.log('a');b();}a();当我们在Node.js的REPL中运行这段代码时,我们会得到如下结果:Traceatc(repl:3:9)atb(repl:3:1)ata(repl:3:1)atrepl:1:1//<--这一行往下的内容可以忽略,因为这些是Node的内部内容atrealRunInThisContextScript(vm.js:22:35)atsigintHandlersWrap(vm.js:98:12)atContextifyScript.Script.runInThisContext(vm.js:24:12)在REPLServer.defaultEval(repl.js:313:29)在绑定(domain.js:280:14)在REPLServer.runBound[aseval](domain.js:293:12)显然,当我们在c内部调用console.trace时,调用栈的结构从下往上是:a->b->c。代码稍微改动一下,就是在cinb执行完之后调用,如下:C();console.trace();}functiona(){console.log('a');b();}a();从输出结果可以看出,打印出来的调用栈从下往上是:a->b,没有c了,因为c在执行完后就从调用栈中移除了。Traceatb(repl:4:9)ata(repl:3:1)atrepl:1:1//<--这一行的内容可以忽略,因为这些是Node内部的东西atrealRunInThisContextScript(vm.js:22:35)在sigintHandlersWrap(vm.js:98:12)在ContextifyScript.Script.runInThisContext(vm.js:24:12)在REPLServer.defaultEval(repl.js:313:29)在绑定(domain.js:280:14)atREPLServer.runBound[aseval](domain.js:293:12)atREPLServer.onLine(repl.js:513:10)然后总结调用栈的工作机制:调用function,它会被压入调用栈的顶部,执行完后,会从调用栈中移除。错误对象和错误处理当代码中出现错误时,我们通常会抛出一个错误对象。Error对象可用作扩展和创建自定义错误类型的原型。Error对象的原型具有以下属性:constructor——负责实例的原型构造器;message-错误信息;name-错误的名称;以上都是标准属性,有些JS运行环境还提供了标准属性以外的属性,比如Node.js、Firefox、Chrome、Edge、IE10、Opera、Safari6+都会有一个stack属性,其中包含错误代码的调用堆栈,我们将其称为错误堆栈。错误堆栈包含了错误产生时完整的调用堆栈信息。如果您想了解有关Error对象的非标准属性的更多信息,我强烈建议您阅读这篇MDN文章。抛出错误时,必须使用throw关键字。为了捕获抛出的错误,必须使用trycatch语句将可能出错的代码块包裹起来。当你catch的时候,你可以接收到一个参数,这个参数就是抛出的错误。和Java类似,JS也可以在trycatch语句之后有finally。无论前面的代码是否抛出错误,finally中的代码都会执行。这种语言的常见用法是:在finally中做一些清理工作。另外,你可以使用不带catch的try语句,但是它后面必须跟一个finally,这意味着我们可以使用三种不同形式的try语句:try...catchtry...finallytry...catch...finallytry语句也可以嵌套在try语句中,例如:try{try{thrownewError('Nestederror.');//这里的错误会被下一个catch捕获}catch(nestedErr){console.log('Nestedcatch');//这里会运行}}catch(err){console.log('这不会运行。');//这里不会运行}try语句也可以嵌套在catch和finally语句中,比如下面两个例子:try{thrownewError('Firsterror');}catch(err){抓跑');try{thrownewError('第二个错误');}catch(nestedErr){控制台。日志('第二个捕获运行。');}}try{console.log('Thetryblockisrunning...');}finally{try{thrownewError('Errorinsidefinally.');}catch(err){console.log('在finally块中发现错误。');}}另请注意,您可以抛出不是Error对象的任意值。这可能看起来很酷,但在工程中强烈反对。如果您碰巧需要处理错误堆栈信息和其他有意义的元数据,抛出不是Error对象的错误会让您陷入非常尴尬的境地。假设我们有以下代码:functionrunWithoutThrowing(func){try{func();}catch(e){console.log('有一个错误,但我不会抛出它。');console.log('Theerror\'smessagewas:'+e.message)}}functionfuncThatThrowsError(){thrownewTypeError('IamaTypeError.');}runWithoutThrowing(funcThatThrowsError);如果runWithoutThrowing的调用者传入的函数是CanthrowError对象,这段代码不会有问题,如果他们抛出字符串,就会有问题,例如:functionrunWithoutThrowing(func){try{func();}抓住(e){控制台。log('有一个错误,但我不会抛出它。');console.log('Theerror\'smessagewas:'+e.message)}}functionfuncThatThrowsString(){throw'IamaString.';}runWithoutThrowing(funcThatThrowsString);当这段代码运行时,runWithoutThrowing中的第二个console.log将抛出错误,因为e.message未定义。这可能看起来没什么大不了的,但是如果您的代码需要使用Error对象的某些属性,那么您需要做很多额外的工作以确保一切正常。如果你抛出的值不是Error对象,你将得不到错误相关的重要信息,比如堆栈,虽然这个属性只在某些JS运行环境中可用。Error对象也可以像其他对象一样使用。您可以将错误传递出去,而不是抛出错误。Node.js中的错误优先回调就是这种方法的典型示例,例如Node.js中的fs.readdir函数:constfs=require('fs');fs.readdir('/example/i-do-not-exist',functioncallback(err,dirs){if(err){//`readdir`将抛出错误,因为该目录不存在//我们现在将能够在我们的回调函数中使用它传递的错误对象{console.log(dirs);}});另外,在Promise.reject时也可以使用Error对象,这样更容易处理Promise失败,比如下面的例子:newPromise(function(resolve,reject){reject(newError('Thepromisewasrejected.'));}).then(function(){console.log('我出错了。');}).catch(function(err){if(errinstanceofError){console.log('Thepromise因错误而被拒绝。');console.log('ErrorMessage:'+err.message);}});只有Node.js支持错误堆栈的裁剪它是由Error.captureStackTrace实现的。Error.captureStackTrace接收一个对象作为第一个参数,一个可选函数作为第二个参数。它的作用是捕获当前的调用堆栈并进行切割。捕获到的调用栈会记录在第一个参数的stack属性上,裁剪的参考点是第二个参数,也就是说这个函数之前的调用会记录在调用栈上,但是后面的不会的。我们用代码来说明,首先,捕获当前的调用栈放到myObj上:constmyObj={};functionc(){}functionb(){//将当前调用栈写入myObjError.captureStackTrace(myObj);c();}functiona(){b();}//调用函数aa();//打印myObj.stackconsole.log(myObj.stack);//输出将像这样//在b(repl:3:7)<--因为它是在B内部调用的,所以B调用是堆栈中的最后一个条目//在(repl:2:1)//在repl:1:1<--节点内部在此行下方//在realRunInThisContextScript(vm.js:22:35)//在sigintHandlersWrap(vm.js:98:12)//在ContextifyScript.Script.runInThisContext(vm.js:24:12)//在REPLServer.defaultEval(repl.js:313:29)//atbound(domain.js:280:14)//atREPLServer.runBound[aseval](domain.js:293:12)//atREPLServer.onLine(repl.js:513:10)在上面的调用栈中只有a->b,因为我们在b调用c之前捕获了调用栈。现在稍微修改一下上面的代码,看看会发生什么:constmyObj={};functiond(){//我们将当前调用堆栈存储在myObj上,但删除b和b之后的错误。captureStackTrace(myObj,b);}functionc(){d();}functionb(){c();}functiona(){b();}//执行代码a();//打印myObj.stackconsole.log(myObj.stack);//输出如下//在a(repl:2:1)<--如你所见,我们只在repl调用`b`之前获取帧//:1:1<--此行下方的节点内部//atrealRunInThisContextScript(vm.js:22:35)//atsigintHandlersWrap(vm.js:98:12)//atContextifyScript.Script.runInThisContext(vm.js):24:12)//atREPLServer.defaultEval(repl.js:313:29)//atbound(domain.js:280:14)//atREPLServer.runBound[aseval](domain.js:293:12)//atREPLServer.onLine(repl.js:513:10)//atemitOne(events.js:101:20)在这段代码中,因为我们在调用Error.captureStackTrace的时候传入了b,b之后的调用栈将被隐藏。现在你可能会问,知道这一切有什么用?如果你想对用户隐藏与他的业务无关的错误堆栈(比如某个库的内部实现),你可以试试这个技巧。小结通过本文的描述,相信你对JS中的调用栈、Error对象、错误栈有了清晰的认识,遇到错误也不会慌张。如果您对文章的内容有任何疑问,请在下方评论。还有一件事想知道这个人将来还会写什么?请关注本专栏,或关注作者本人,或扫描文章封面二维码订阅前端周刊微信公众号。脚注:本文基于http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html进行了大量修改。英语好的同学可以直接看原文,因为考虑到***部分离大部分工程师的实际工作比较远,所以没有翻译。