首发于前端大哈
深入理解 JavaScript Errors 和 Stack Traces

深入理解 JavaScript Errors 和 Stack Traces

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

作者:lucasfcosta
编译:胡子大哈

翻译原文:huziketang.com/blog/pos
英文原文:JavaScript Errors and Stack Traces in Depth

转载请注明出处,保留原文链接以及作者信息

很久没给大家更新关于 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 在堆栈中保持可见状态。

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

  1. 当我们运行一个 Assertion 时,我们设置它本身来作为我们移除其后面堆栈帧的标记。
  2. 这个 Assertion 开始执行,如果判断失败,那么从刚才我们所存储的那个标记开始,移除其后面所有的内部帧。
  3. 如果有内嵌 Assertion,那么我们必须要使用包含当前 Assertion 的方法作为移除后面堆栈帧的标记,即放到 ssfi 中。因此我们要传递当前 ssfi(起始堆栈函数指示器)到我们即将要新创建的内嵌 Assertion 中来存储起来。

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

点击《深入理解 JavaScript Errors 和 Stack Traces》查看原文。

编辑于 2017-03-30

文章被以下专栏收录