嗨,大家好!我已经数周没有写过关于 JavaScrip 的文章,现在该是继续谈论这个话题的时候了。
这次我们将讨论 error 和堆栈追踪以及如何使用它们。
有时候人们并不关注这些细节,但这方面的知识肯定有用,尤其是当你正在编写与测试或 errors 相关的库。例如这个星期我们的 chai 中出现了一个令人惊叹的 Pull Request ,它大大改进了我们处理堆栈跟踪的方式,并在用户断言失败时提供了更多的信息。
操作堆栈记录可以让你清理无用数据,并集中精力处理重要事项。此外,当你真正弄清楚 Error 及其属性,你将会更有信心地利用它。
在谈论 errors 之前我们必须明白堆栈调用如何工作。它非常简单,但对于我们将要深入的内容而言却是至关重要的。如果你已经知道这部分内容,请随时跳过本节。
这种数据结构的有趣之处在于 最后一个入栈的将会第一个从堆栈中移除 ,这也就是我们所熟悉的 LIFO(后进,先出) 特性。
这也就是说我们在函数
中调用函数
- x
, 那么对应的堆栈中的顺序为
- y
- x
。
- y
假设你有下面这样的代码:
- function c() {
- console.log('c');
- }
- function b() {
- console.log('b');
- c();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
在上面这里例子中,当执行
函数时,
- a
便会添加到堆栈的顶部,然后当
- a
函数在
- b
函数中被调用,
- a
也会被添加到堆栈的顶部,依次类推,在
- b
中调用
- b
也会发生同样的事情。
- c
当
执行时,堆栈中的函数的顺序为
- c
- a
- b
- c
执行完毕后便会从栈顶移除,这时控制流重新回到了
- c
中,
- b
执行完毕同样也会从栈顶移除,最后控制流又回到了
- b
中,最后
- a
执行完毕,
- a
也从堆栈中移除。
- a
我们可以利用
来更好的演示这种行为,它会在控制台打印出当前堆栈中的记录。此外,通常而言你应该从上到下读取堆栈记录。想想下面的每一行代码都是在哪调用的。
- console.trace()
- 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
如果我们现在在
中并且在
- b
执行完之后打印堆栈,我们将会发现
- c
已经从堆栈的顶部移除,只剩下了
- c
和
- a
。
- b
- function c() {
- console.log('c');
- }
- function b() {
- console.log('b');
- c();
- console.trace();
- }
- function a() {
- console.log('a');
- b();
- }
- a();
正如你看到的那样,堆栈中已经没有
,因为它已经完成运行,已经被弹出去了。
- c
- 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.prototype
- 实例原型的构造函数。
- constructor
- 错误信息
- message
- 错误名称
- name
以上都是标准属性,(但)有时候每个环境都有其特定的属性,在例如 Node,Firefox,Chorme,Edge,IE 10+,Opera 和 Safari 6+ 中,还有一个包含错误堆栈记录的
属性。 错误堆栈记录包含从(堆栈底部)它自己的构造函数到(堆栈顶部)所有的堆栈帧。
- stack
如果想了解更多关于
对象的具体属性,我强烈推荐 MDN 上的 这篇文章 。
- Error
抛出错误必须使用
关键字,你必须将可能抛出错误的代码包裹在
- throw
代码块内并紧跟着一个
- try
代码块来捕获抛出的错误。
- catch
正如 Java 中的错误处理,
代码块后紧跟着一个
- try/catch
代码块在 JavaScript 中也是同样允许的,无论
- finally
代码块内是否抛出异常,
- try
代码块内的代码都会执行。在完成处理之后,最佳实践是在
- finally
代码块中做一些清理的事情,(因为) 无论你的操作是否生效,都不会影响到它的执行。
- finally
(鉴于) 上面所谈到的所有事情对大多数人来讲都是小菜一碟,那么就让我们来谈一些不为人所知的细节。
代码块后面不必紧跟着
- try
, 但 (此种情况下) 其后必须紧跟着
- catch
。这意味着我们可以使用三种不同形式的
- finally
语句:
- try
- try...catch
- try...finally
- try...catch...finally
Try 语句可以像下面这样互相嵌套:
- try {
- try {
- throw new Error('Nested error.'); // The error thrown here will be caught by its own `catch` clause
- } catch (nestedErr) {
- console.log('Nested catch'); // This runs
- }
- } catch (err) {
- console.log('This will not run.');
- }
你甚至还可以在
和
- catch
代码块中嵌套
- finally
语句:
- try
- 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.');
- }
- }
还有很重要的一点值得注意,那就是我们甚至可以大可不必抛出
对象。尽管这看起来非常 cool 且非常自由,但实际并非如此,尤其是对开发第三方库的开发者来说,因为他们必须处理用户 (使用库的开发者) 的代码。由于缺乏标准,他们并不能把控用户的行为。你不能相信用户并简单的抛出一个
- Error
对象,因为他们不一定会那么做而是仅仅抛出一个字符串或者数字 (鬼知道用户会抛出什么)。这也使得处理必要的堆栈跟踪和其他有意义的元数据变得更加困难。
- Error
假设有以下代码:
- 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);
现在第二个
会打印出 the error's message is
- console.log
. 这么看来也没多大的事 (后果) 呀,但是如果您需要确保某些属性存在于
- undefined
对象上,或以另一种方式(例如 Chai 的
- Error
断言 does))处理
- throws
对象的特定属性,那么你做需要更多的工作,以确保它会正常工资。
- Error
此外,当抛出的值不是
对象时,你无法访问其他重要数据,例如
- Error
,在某些环境中它是
- stack
对象的一个属性。
- Error
Errors 也可以像其他任何对象一样使用,并不一定非得要抛出他们,这也是它们为什么多次被用作回调函数的第一个参数 (俗称 err first)。 在下面的
例子中就是这么用的。
- fs.readdir()
- const fs = require('fs');
- fs.readdir('/example/i-do-not-exist',
- function callback(err, dirs) {
- if (err instanceof Error) {
- // `readdir` will throw an error because that directory does not exist
- // We will now be able to use the error object passed by it in our callback function
- console.log('Error Message: ' + err.message);
- console.log('See? We can use Errors without using try statements.');
- } else {
- console.log(dirs);
- }
- });
最后,在 rejecting promises 时也可以使用
对象。这使得它更容易处理 promise rejections:
- Error
- 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);
- }
- });
上面啰嗦了那么多,压轴的重头戏来了,那就是如何操纵堆栈跟踪。
本章专门针对那些像 NodeJS 支 Error.captureStackTrace 的环境。
函数接受一个
- Error.captureStackTrace
作为第一个参数,第二个参数是可选的,接受一个函数。capture stack trace 捕获当前堆栈跟踪,并在目标对象中创建一个
- object
属性来存储它。如果提供了第二个参数,则传递的函数将被视为调用堆栈的终点,因此堆栈跟踪将仅显示调用该函数之前发生的调用。
- stack
让我们用例子来说明这一点。首先,我们将捕获当前堆栈跟踪并将其存储在公共对象中。
- const myObj = {};
- function c() {
- }
- function b() {
- // Here we will store the current stack trace into myObj
- Error.captureStackTrace(myObj);
- c();
- }
- function a() {
- b();
- }
- // First we will call these functions
- a();
- // Now let's see what is the stack trace stored into myObj.stack
- console.log(myObj.stack);
- // This will print the following stack to the console:
- // 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
- b
的顺序打印堆栈。
- a
现在让我们给
传递一个函数作为第二个参数,看看会发生什么:
- Error.captureStackTrace
- const myObj = {};
- function d() {
- // Here we will store the current stack trace into myObj
- // This time we will hide all the frames after `b` and `b` itself
- Error.captureStackTrace(myObj, b);
- }
- function c() {
- d();
- }
- function b() {
- c();
- }
- function a() {
- b();
- }
- // First we will call these functions
- a();
- // Now let's see what is the stack trace stored into myObj.stack
- console.log(myObj.stack);
- // This will print the following stack to the console:
- // 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 中,我们使用它来避免向用户显示我们是如何实施检查和断言本身的不相关的细节。
正如我在上一节中提到的,Chai 使用堆栈操作技术使堆栈跟踪更加与我们的用户相关。下面将揭晓我们是如何做到的。
首先,让我们来看看当断言失败时抛出的
的构造函数:
- AssertionError
- // `ssfi` stands for "start stack function". It is the reference to the
- // starting point for removing irrelevant frames from the stack trace
- function AssertionError (message, _props, ssf) {
- var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
- , props = extend(_props || {});
- // Default values
- this.message = message || 'Unspecified AssertionError';
- this.showDiff = false;
- // Copy from properties
- for (var key in props) {
- this[key] = props[key];
- }
- // Here is what is relevant for us:
- // If a start stack function was provided we capture the current stack trace and pass
- // it to the `captureStackTrace` function so we can remove frames that come after it
- ssf = ssf || arguments.callee;
- if (ssf && Error.captureStackTrace) {
- Error.captureStackTrace(this, ssf);
- } else {
- // If no start stack function was provided we just use the original stack property
- try {
- throw new Error();
- } catch(e) {
- this.stack = e.stack;
- }
- }
- }
如你所见,我们使用
捕获堆栈追踪并将它存储在我们正在创建的
- Error.captureStackTrace
实例中(如果存在的话),然后我们将一个起始堆栈函数传递给它,以便从堆栈跟踪中删除不相关的调用帧,它只显示 Chai 的内部实现细节,最终使堆栈变得清晰明了。
- AssertError
现在让我们来看看 @meeber 在这个 令人惊叹的 PR 中提交的代码。
在你开始看下面的代码之前,我必须告诉你
方法是干啥的。它将传递给它的链式方法添加到断言上,它也用包含断言的方法标记断言本身,并将其保存在变量
- addChainableMethod
(启动堆栈函数指示符) 中。这也就意味着当前断言将会是堆栈中的最后一个调用帧,因此我们不会在堆栈中显示 Chai 中的任何进一步的内部方法。我没有添加整个代码,因为它做了很多事情,有点棘手,但如果你想读它, 点我阅读 。
- ssfi
下面的这个代码片段中,我们有一个
断言的逻辑,它检查一个对象是否有一定的
- lengOf
。我们希望用户可以像这样来使用它:
- length
。
- 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');
- // Pay close attention to this line
- new Assertion(obj, msg, ssfi, true).to.have.property('length');
- var len = obj.length;
- // This line is also relevant
- 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);
- // This is the relevant line for us
- throw new AssertionError(msg, {
- actual: actual,
- expected: expected,
- showDiff: showDiff
- },
- (config.includeStack) ? this.assert: flag(this, 'ssfi'));
- }
- };
方法负责检查断言布尔表达式是否通过。如果不通过,我们则实例化一个
- assert
。不知道你注意到没,在实例化
- AssertionError
时,我们也给它传递了一个堆栈追踪函数指示器 (
- AssertionError
),如果配置的
- ssfi
处于开启状态,我们通过将
- includeStack
本身传递给它来为用户显示整个堆栈跟踪。反之,我们则只显示
- this.assert
标记中存储的内容,隐藏掉堆栈跟踪中更多的内部实现细节。
- ssfi
现在让我们来讨论下一行和我们相关的代码吧:
- new Assertion(obj, msg, ssfi, true).to.have.property('length');`
As you can see here we are passing the content we've got from the
flag when creating our nested assertion. This means that when the new assertion gets created it will use this function as the starting point for removing unuseful frames from the stack trace. By the way, this is the
- ssfi
constructor: 如你所见,我们在创建嵌套断言时将从
- Assertion
标记中的内容传递给了它。这意味着新创建的断言会使用那个方法作为起始调用帧,从而可以从堆栈追踪中清除没有的调用栈。顺便也看下
- ssfi
的构造器吧:
- Assertion
- function Assertion (obj, msg, ssfi, lockSsfi) {
- // This is the line that matters to us
- flag(this, 'ssfi', ssfi || Assertion);
- flag(this, 'lockSsfi', lockSsfi);
- flag(this, 'object', obj);
- flag(this, 'message', msg);
- return util.proxify(this);
- }
不知道你是否还记的我先前说过的
方法,它使用自己的父级方法设置
- addChainableMethod
标志,这意味着它始终处于堆栈的底部,我们可以删除它之上的所有调用帧。
- ssfi
通过将
传递给嵌套断言,它只检查我们的对象是否具有长度属性,我们就可以避免重置我们将要用作起始指标器的调用帧,然后在堆栈中可以看到以前的
- ssfi
。
- addChainableMethod
这可能看起来有点复杂,所以让我们回顾一下我们想从栈中删除无用的调用帧时 Chai 中所发生的事情:
(起始函数指示器)传递给我们所创建的断言,以便它可以保存。
- ssfi
如果你想更深入的了解它, 我也强烈推荐你阅读 @米贝的评论
** 如果你有任何疑问,想法或者不认同我写的任何内容,你都可以在下面的评论中分享你的想法,或者在 twitter) 上和我交流。如果我犯了错误,我很乐意听到你要说的话,并做出任何改正。
来源: http://www.tuicool.com/articles/emA3637