请选择 进入手机版 | 继续访问电脑版

集云开发者论坛

 找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 1089|回复: 0

深入理解 JavaScript Errors 和 Stack Traces

[复制链接]

463

主题

477

帖子

1万

积分

管理员

Rank: 9Rank: 9Rank: 9

积分
13928
发表于 2017-3-24 11:43:32 | 显示全部楼层 |阅读模式
很久没给大家更新关于 JavaScript 的内容了,这篇文章我们来聊聊 JavaScript 。
这次我们聊聊 Errors 和 Stack traces 以及如何熟练地使用它们。
很多同学并不重视这些细节,但是这些知识在你写 Testing 和 Error 相关的 lib 的时候是非常有用的。使用 Stack traces 可以清理无用的数据,让你关注真正重要的问题。同时,你真正理解 Errors 和它们的属性到底是什么的时候,你将会更有信心的使用它们。
这篇文章在开始的时候看起来比较简单,但当你熟练运用 Stack trace 以后则会感到非常复杂。所以在看难的章节之前,请确保你理解了前面的内容。
Stack是如何工作的
在我们谈到 Errors 之前,我们必须理解 Stack 是如何工作的。它其实非常简单,但是在开始之前了解它也是非常必要的。如果你已经知道了这些,可以略过这一章节。
每当有一个函数调用,就会将其压入栈顶。在调用结束的时候再将其从栈顶移出。
这种有趣的数据结构叫做“最后一个进入的,将会第一个出去”。这就是广为所知的 LIFO(后进先出)。
举个例子,在函数 x 的内部调用了函数 y,这时栈中就有个顺序先 x 后 y。我再举另外一个例子,看下面代码:
function c() {    console.log('c');}function b() {    console.log('b');    c();}function a() {    console.log('a');    b();}a();
上面的这段代码,当运行 a 的时候,它会被压到栈顶。然后,当 b 在 a 中被调用的时候,它会被继续压入栈顶,当 c 在 b 中被调用的时候,也一样。
在运行 c 的时候,栈中包含了 a,b,c,并且其顺序也是 a,b,c。
当 c 调用完毕时,它会被从栈顶移出,随后控制流回到 b。当 b 执行完毕后也会从栈顶移出,控制流交还到 a。最后,当 a 执行完毕后也会从栈中移出。
为了更好的展示这样一种行为,我们用console.trace()来将 Stack trace 打印到控制台上来。通常我们读 Stack traces 信息的时候是从上往下读的。
function c() {    console.log('c');    console.trace();}function b() {    console.log('b');    c();}function a() {    console.log('a');    b();}a();
当我们在Node REPL服务端执行的时候,会返回如下:
Trace    at c (repl:3:9)    at b (repl:3:1)    at a (repl:3:1)    at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals    at realRunInThisContextScript (vm.js:22:35)    at sigintHandlersWrap (vm.js:98:12)    at ContextifyScript.Script.runInThisContext (vm.js:24:12)    at REPLServer.defaultEval (repl.js:313:29)    at bound (domain.js:280:14)    at REPLServer.runBound [as eval] (domain.js:293:12)
从上面我们可以看到,当栈信息从 c 中打印出来的时候,我看到了 a,b 和 c。现在,如果在 c 执行完毕以后,在 b 中把 Stack trace 打印出来,我们可以看到 c 已经从栈中移出了,栈中只有 a 和 b。
function c() {    console.log('c');}function b() {    console.log('b');    c();    console.trace();}function a() {    console.log('a');    b();}a();
下面可以看到,c 已经不在栈中了,在其执行完以后,从栈中 pop 出去了。
Trace    at b (repl:4:9)    at a (repl:3:1)    at repl:1:1  // <-- For now feel free to ignore anything below this point, these are Node's internals    at realRunInThisContextScript (vm.js:22:35)    at sigintHandlersWrap (vm.js:98:12)    at ContextifyScript.Script.runInThisContext (vm.js:24:12)    at REPLServer.defaultEval (repl.js:313:29)    at bound (domain.js:280:14)    at REPLServer.runBound [as eval] (domain.js:293:12)    at REPLServer.onLine (repl.js:513:10)
概括一下:当调用时,压入栈顶。当它执行完毕时,被弹出栈,就是这么简单。
Error 对象和 Error 处理
当Error发生的时候,通常会抛出一个Error对象。Error对象也可以被看做一个Error原型,用户可以扩展其含义,以创建自己的 Error 对象。
Error.prototype对象通常包含下面属性:
  • constructor - 一个错误实例原型的构造函数
  • message - 错误信息
  • name - 错误名称
这几个都是标准属性,有时不同编译的环境会有其独特的属性。在一些环境中,例如 Node 和 Firefox,甚至还有stack属性,这里面包含了错误的 Stack trace。一个Error的堆栈追踪包含了从其构造函数开始的所有堆栈帧。
如果你想要学习一个Error对象的特殊属性,我强烈建议你看一下在MDN上的这篇文章
要抛出一个Error,你必须使用throw关键字。为了catch一个抛出的Error,你必须把可能抛出Error的代码用try块包起来。然后紧跟着一个catch块,catch块中通常会接受一个包含了错误信息的参数。
和在 Java 中类似,不论在try中是否抛出Error, JavaScript 中都允许你在try/catch块后面紧跟着一个finally块。不论你在try中的操作是否生效,在你操作完以后,都用finally来清理对象,这是个编程的好习惯。
介绍到现在的知识,可能对于大部分人来说,都是已经掌握了的,那么现在我们就进行更深入一些的吧。
使用try块时,后面可以不跟着catch块,但是必须跟着finally块。所以我们就有三种不同形式的try语句:
  • try…catch
  • try…finally
  • try…catch…finally
Try语句也可以内嵌在一个try语句中,如:
try {    try {        // 这里抛出的Error,将被下面的catch获取到        throw new Error('Nested error.');     } catch (nestedErr) {        // 这里会打印出来        console.log('Nested catch');    }} catch (err) {    console.log('This will not run.');}
你也可以把try语句内嵌在catch和finally块中:
try {    throw new Error('First error');} catch (err) {    console.log('First catch running');    try {        throw new Error('Second error');    } catch (nestedErr) {        console.log('Second catch running.');    }}try {    console.log('The try block is running...');} finally {    try {        throw new Error('Error inside finally.');    } catch (err) {        console.log('Caught an error inside the finally block.');    }}
这里给出另外一个重要的提示:你可以抛出非Error对象的值。尽管这看起来很炫酷,很灵活,但实际上这个用法并不好,尤其在一个开发者改另一个开发者写的库的时候。因为这样代码没有一个标准,你不知道其他人会抛出什么信息。这样的话,你就不能简单的相信抛出的Error信息了,因为有可能它并不是Error信息,而是一个字符串或者一个数字。另外这也导致了如果你需要处理 Stack trace 或者其他有意义的元数据,也将变的很困难。
例如给你下面这段代码:
function runWithoutThrowing(func) {    try {        func();    } catch (e) {        console.log('There was an error, but I will not throw it.');        console.log('The error\'s message was: ' + e.message)    }}function funcThatThrowsError() {    throw new TypeError('I am a TypeError.');}runWithoutThrowing(funcThatThrowsError);
这段代码,如果其他人传递一个带有抛出Error对象的函数给runWithoutThrowing函数的话,将完美运行。然而,如果他抛出一个String类型的话,则情况就麻烦了。
function runWithoutThrowing(func) {    try {        func();    } catch (e) {        console.log('There was an error, but I will not throw it.');        console.log('The error\'s message was: ' + e.message)    }}function funcThatThrowsString() {    throw 'I am a String.';}runWithoutThrowing(funcThatThrowsString);
可以看到这段代码中,第二个console.log会告诉你这个 Error 信息是undefined。这现在看起来不是很重要,但是如果你需要确定是否这个Error中确实包含某个属性,或者用另一种方式处理Error的特殊属性,那你就需要多花很多的功夫了。
另外,当抛出一个非Error对象的值时,你没有访问Error对象的一些重要的数据,比如它的堆栈,而这在一些编译环境中是一个非常重要的Error对象属性。
Error 还可以当做其他普通对象一样使用,你并不需要抛出它。这就是为什么它通常作为回调函数的第一个参数,就像fs.readdir函数这样:
const fs = require('fs');fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {    if (err instanceof Error) {        // 'readdir'将会抛出一个异常,因为目录不存在        // 我们可以在我们的回调函数中使用 Error 对象        console.log('Error Message: ' + err.message);        console.log('See? We can use  Errors  without using try statements.');    } else {        console.log(dirs);    }});
最后,你也可以在 promise 被 reject 的时候使用Error对象,这使得处理 promise reject 变得很简单。
new Promise(function(resolve, reject) {    reject(new Error('The promise was rejected.'));}).then(function() {    console.log('I am an error.');}).catch(function(err) {    if (err instanceof Error) {        console.log('The promise was rejected with an error.');        console.log('Error Message: ' + err.message);    }});使用 Stack Trace
ok,那么现在,你们所期待的部分来了:如何使用堆栈追踪。
这一章专门讨论支持 Error.captureStackTrace 的环境,如:NodeJS。
Error.captureStackTrace函数的第一个参数是一个object对象,第二个参数是一个可选的function。捕获堆栈跟踪所做的是要捕获当前堆栈的路径(这是显而易见的),并且在 object 对象上创建一个stack属性来存储它。如果提供了第二个function 参数,那么这个被传递的函数将会被看成是本次堆栈调用的终点,本次堆栈跟踪只会展示到这个函数被调用之前。
我们来用几个例子来更清晰的解释下。我们将捕获当前堆栈路径并且将其存储到一个普通 object 对象中。
const myObj = {};function c() {}function b() {    // 这里存储当前的堆栈路径,保存到myObj中    Error.captureStackTrace(myObj);    c();}function a() {    b();}// 首先调用这些函数a();// 这里,我们看一下堆栈路径往 myObj.stack 中存储了什么console.log(myObj.stack);// 这里将会打印如下堆栈信息到控制台//    at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack//    at a (repl:2:1)//    at repl:1:1 <-- Node internals below this line//    at realRunInThisContextScript (vm.js:22:35)//    at sigintHandlersWrap (vm.js:98:12)//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)//    at REPLServer.defaultEval (repl.js:313:29)//    at bound (domain.js:280:14)//    at REPLServer.runBound [as eval] (domain.js:293:12)//    at REPLServer.onLine (repl.js:513:10)
我们从上面的例子中可以看到,我们首先调用了a(a被压入栈),然后从a的内部调用了b(b被压入栈,并且在a的上面)。在b中,我们捕获到了当前堆栈路径并且将其存储在了myObj中。这就是为什么打印在控制台上的只有a和b,而且是下面a上面b。
好的,那么现在,我们传递第二个参数到Error.captureStackTrace看看会发生什么?
const myObj = {};function d() {    // 这里存储当前的堆栈路径,保存到myObj中    // 这次我们隐藏包含b在内的b以后的所有堆栈帧    Error.captureStackTrace(myObj, b);}function c() {    d();}function b() {    c();}function a() {    b();}// 首先调用这些函数a();// 这里,我们看一下堆栈路径往 myObj.stack 中存储了什么console.log(myObj.stack);// 这里将会打印如下堆栈信息到控制台//    at a (repl:2:1) <-- As you can see here we only get frames before `b` was called//    at repl:1:1 <-- Node internals below this line//    at realRunInThisContextScript (vm.js:22:35)//    at sigintHandlersWrap (vm.js:98:12)//    at ContextifyScript.Script.runInThisContext (vm.js:24:12)//    at REPLServer.defaultEval (repl.js:313:29)//    at bound (domain.js:280:14)//    at REPLServer.runBound [as eval] (domain.js:293:12)//    at REPLServer.onLine (repl.js:513:10)//    at emitOne (events.js:101:20)
当我们传递b到Error.captureStackTraceFunction里时,它隐藏了b和在它以上的所有堆栈帧。这就是为什么堆栈路径里只有a的原因。
看到这,你可能会问这样一个问题:“为什么这是有用的呢?”。它之所以有用,是因为你可以隐藏所有的内部实现细节,而这些细节其他开发者调用的时候并不需要知道。例如,在 Chai 中,我们用这种方法对我们代码的调用者屏蔽了不相关的实现细节。
真实场景中的 Stack Trace 处理
正如我在上一节中提到的,Chai 用栈处理技术使得堆栈路径和调用者更加相关,这里是我们如何实现它的。
首先,让我们来看一下当一个 Assertion 失败的时候,AssertionError的构造函数做了什么。
// 'ssfi'代表"起始堆栈函数",它是移除其他不相关堆栈帧的起始标记function AssertionError (message, _props, ssf) {  var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')    , props = extend(_props || {});  // 默认值  this.message = message || 'Unspecified AssertionError';  this.showDiff = false;  // 从属性中copy  for (var key in props) {    this[key] = props[key];  }  // 这里是和我们相关的  // 如果提供了起始堆栈函数,那么我们从当前堆栈路径中获取到,  // 并且将其传递给'captureStackTrace',以保证移除其后的所有帧  ssf = ssf || arguments.callee;  if (ssf && Error.captureStackTrace) {    Error.captureStackTrace(this, ssf);  } else {    // 如果没有提供起始堆栈函数,那么使用原始堆栈    try {      throw new Error();    } catch(e) {      this.stack = e.stack;    }  }}
正如你在上面可以看到的,我们使用了Error.captureStackTrace来捕获堆栈路径,并且把它存储在我们所创建的一个AssertionError实例中。然后传递了一个起始堆栈函数进去(用if判断如果存在则传递),这样就从堆栈路径中移除掉了不相关的堆栈帧,不显示一些内部实现细节,保证了堆栈信息的“清洁”。
感兴趣的读者可以继续看一下最近 @meeber 在 这里 的代码。
在我们继续看下面的代码之前,我要先告诉你addChainableMethod都做了什么。它添加所传递的可以被链式调用的方法到 Assertion,并且用包含了 Assertion 的方法标记 Assertion 本身。用ssfi(表示起始堆栈函数指示器)这个名字记录。这意味着当前 Assertion 就是堆栈的最后一帧,就是说不会再多显示任何 Chai 项目中的内部实现细节了。我在这里就不多列出来其整个代码了,里面用了很多 trick 的方法,但是如果你想了解更多,可以从 这个链接 里获取到。
在下面的代码中,展示了lengthOf的 Assertion 的逻辑,它是用来检查一个对象的确定长度的。我们希望调用我们函数的开发者这样来使用:expect(['foo', 'bar']).to.have.lengthOf(2)。
function assertLength (n, msg) {    if (msg) flag(this, 'message', msg);    var obj = flag(this, 'object')        , ssfi = flag(this, 'ssfi');    // 密切关注这一行    new Assertion(obj, msg, ssfi, true).to.have.property('length');    var len = obj.length;    // 这一行也是相关的    this.assert(            len == n        , 'expected #{this} to have a length of #{exp} but got #{act}'        , 'expected #{this} to not have a length of #{act}'        , n        , len    );}Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);
在代码中,我着重对跟我们相关的代码进行了注释,我们从this.assert的调用开始。
下面是this.assert方法的代码:
Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) {    var ok = util.test(this, arguments);    if (false !== showDiff) showDiff = true;    if (undefined === expected && undefined === _actual) showDiff = false;    if (true !== config.showDiff) showDiff = false;    if (!ok) {        msg = util.getMessage(this, arguments);        var actual = util.getActual(this, arguments);        // 这是和我们相关的行        throw new AssertionError(msg, {                actual: actual            , expected: expected            , showDiff: showDiff        }, (config.includeStack) ? this.assert : flag(this, 'ssfi'));    }};
assert方法主要用来检查 Assertion 的布尔表达式是真还是假。如果是假,则我们必须实例化一个AssertionError。这里注意,当我们实例化一个AssertionError对象的时候,我们也传递了一个起始堆栈函数指示器(ssfi)。如果配置标记includeStack是打开的,我们通过传递一个this.assert给调用者,以向他展示整个堆栈路径。可是,如果includeStack配置是关闭的,我们则必须从堆栈路径中隐藏内部实现细节,这就需要用到存储在ssfi中的标记了。
ok,那么我们再来讨论一下其他和我们相关的代码:
new Assertion(obj, msg, ssfi, true).to.have.property('length');
可以看到,当创建这个内嵌Assertion的时候,我们传递了ssfi中已获取到的内容。这意味着,当创建一个新的Assertion 时,将使用这个函数来作为从堆栈路径中移除无用堆栈帧的起始点。顺便说一下,下面这段代码是Assertion的构造函数。
function Assertion (obj, msg, ssfi, lockSsfi) {    // 这是和我们相关的行    flag(this, 'ssfi', ssfi || Assertion);    flag(this, 'lockSsfi', lockSsfi);    flag(this, 'object', obj);    flag(this, 'message', msg);    return util.proxify(this);}
还记得我在讲述addChainableMethod时说的,它用包含他自己的方法设置的ssfi标记,这就意味着这是堆栈路径中最底层的内部帧,我们可以移除在它之上的所有帧。
回想上面的代码,内嵌 Assertion 用来判断对象是不是有合适的长度(Length)。传递ssfi到这个 Assertion 中,要避免重置我们要将其作为起始指示器的堆栈帧,并且使先前的addChainableMethod在堆栈中保持可见状态。
这看起来可能有点复杂,现在我们重新回顾一下,我们想要移除没有用的堆栈帧都做了什么工作:
  • 当我们运行一个 Assertion 时,我们设置它本身来作为我们移除其后面堆栈帧的标记。
  • 这个 Assertion 开始执行,如果判断失败,那么从刚才我们所存储的那个标记开始,移除其后面所有的内部帧。
  • 如果有内嵌 Assertion,那么我们必须要使用包含当前 Assertion 的方法作为移除后面堆栈帧的标记,即放到ssfi中。因此我们要传递当前ssfi(起始堆栈函数指示器)到我们即将要新创建的内嵌 Assertion 中来存储起来。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|Archiver|手机版|小黑屋|集云开发者论坛 ( 辽ICP备15012805号-2 )

GMT+8, 2019-6-24 19:36 , Processed in 0.054230 second(s), 21 queries .

Powered by Open Draft System

© 2007-2016 OpenDraft

快速回复 返回顶部 返回列表