深刻理解 JavaScript Errors 和 Stack Traces

封面图片

译者注:本文做者是著名 JavaScript BDD 测试框架 Chai.js 源码贡献者之一,Chai.js 中会遇到不少异常处理的状况。跟随做者思路,从 JavaScript 基本的 Errors 原理,到如何实际使用 Stack Traces,深刻学习和理解 JavaScript Errors 和 Stack Traces。文章贴出的源码连接也很是值得学习。html

做者:lucasfcosta <br/>
编译:胡子大哈 node

翻译原文:[http://huziketang.com/blog/po...
](http://huziketang.com/blog/po... <br/>
英文原文:JavaScript Errors and Stack Traces in Depth react

转载请注明出处,保留原文连接以及做者信息git


好久没给你们更新关于 JavaScript 的内容了,这篇文章咱们来聊聊 JavaScript 。github

此次咱们聊聊 Errors 和 Stack traces 以及如何熟练地使用它们。编程

不少同窗并不重视这些细节,可是这些知识在你写 Testing 和 Error 相关的 lib 的时候是很是有用的。使用 Stack traces 能够清理无用的数据,让你关注真正重要的问题。同时,你真正理解 Errors 和它们的属性究竟是什么的时候,你将会更有信心的使用它们。api

这篇文章在开始的时候看起来比较简单,但当你熟练运用 Stack trace 之后则会感到很是复杂。因此在看难的章节以前,请确保你理解了前面的内容。promise

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语句内嵌在catchfinally块中:

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中。这就是为何打印在控制台上的只有ab,并且是下面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)

当咱们传递bError.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在堆栈中保持可见状态。

这看起来可能有点复杂,如今咱们从新回顾一下,咱们想要移除没有用的堆栈帧都作了什么工做:

  1. 当咱们运行一个 Assertion 时,咱们设置它自己来做为咱们移除其后面堆栈帧的标记。

  2. 这个 Assertion 开始执行,若是判断失败,那么从刚才咱们所存储的那个标记开始,移除其后面全部的内部帧。

  3. 若是有内嵌 Assertion,那么咱们必需要使用包含当前 Assertion 的方法做为移除后面堆栈帧的标记,即放到ssfi中。所以咱们要传递当前ssfi(起始堆栈函数指示器)到咱们即将要新建立的内嵌 Assertion 中来存储起来。

最后我仍是强烈建议来阅读一下 @meeber的评论 来加深对它的理解。


我最近正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,欢迎指点

相关文章
相关标签/搜索