解析 JavaScript Error 中的 stack 信息

需求描述

对于任意的 Error 对象,如果将其输出,我们可以看见这个异常的堆栈信息。例如:

1
2
3
4
5
6
7
8
9
10
Error
at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)
at Module._compile (internal/modules/cjs/loader.js:702:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:713:10)
at Module.load (internal/modules/cjs/loader.js:612:32)
at tryModuleLoad (internal/modules/cjs/loader.js:551:12)
at Function.Module._load (internal/modules/cjs/loader.js:543:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:744:10)
at startup (internal/bootstrap/node.js:238:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)

可以看到这个异常来自: test.js 的第 10 行。但现在的需求是要用 代码 捕捉报错的来源文件以及行数。

解决方法

方法一: RegExp

因为堆栈信息是字符串形式,所以正则表达式在这个场合就非常合适。

at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12) 为例,我们首先需要捕捉的是后面括号内的信息。那么 /at\s+(.*)\s+\((.*)\)/i,中第一个的第一个匹配项为行数的方法,第二个匹配项为括号的信息。

对上述例子执行改正则,可以得到:

1
2
3
4
5
6
7
8
9
10
const info = `at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)`
const res = /at\s+(.*)\s+\((.*)\)/i.exec(info)
console.log(res)
[ 'at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)',
'Object.<anonymous>',
'/Users/lazzzis/Documents/Projects/Test/test.js:10:12',
index: 0,
input: 'at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)',
groups: undefined ]
// res[1] 为函数信息,res[2] 为括号的内的信息

而括号的内信息以 : 为分隔符,那么通过 : 又可以的到报错的文件地址,行数,列数。可以用正则表达式: /at\s+(.*)\s+\((.*):(\d*):(\d*)\)/i

执行可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
const info = `at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)`
const res = /at\s+(.*)\s+\((.*):(\d*):(\d*)\)/i.exec(info)
console.log(res)
[ 'at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)',
'Object.<anonymous>',
'/Users/lazzzis/Documents/Projects/Test/test.js',
'10',
'12',
index: 0,
input: 'at Object.<anonymous> (/Users/lazzzis/Documents/Projects/Test/test.js:10:12)',
groups: undefined ]
// res[1] 和 res [2] 已再之前讲过,不重复
// res[3] 为 行数,res[4] 为列数。

通过上述方法,已经可以拿到该有的基本信息。

方法二: Error.prepareStackTrace 方法

node 是基于 v8 实现的,而 v8 给 Error 暴露了一个 prepareStackTrace 方法,因此可以借由这个方法实现我们所需的功能。

在 v8 中,堆栈信息并不是一开始就是字符串。在变成字符串前,其中一个状态是以一个数组形式存储的,而数组的每个元素是一个 CallSite 对象。简单理解,CallSite 包含了函数名,行数,错误类型等信息,可以简单把每一个 CallSite 对象看作堆栈信息中的每一行。

在每次将 Error 对象输出到 console 时,Error 会用 prepareStackTrace 方法将其变成字符串。如果我们覆盖此方法,就可以改变其默认行为,让它输出成其它东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Error.prepareStackTrace = (error, structuredStackTrace) => {
return structuredStackTrace
.map((item) => item.getFileName() + ', ' + item.getFunctionName())
.join('\n')
}

console.log(new Error('test'))
// output:
// [/Users/lazzzis/Documents/Projects/Test/test.js, null
// internal/modules/cjs/loader.js, Module._compile
// internal/modules/cjs/loader.js, Module._extensions..js
// internal/modules/cjs/loader.js, Module.load
// internal/modules/cjs/loader.js, tryModuleLoad
// internal/modules/cjs/loader.js, Module._load
// internal/modules/cjs/loader.js, Module.runMain
// internal/bootstrap/node.js, startup
// internal/bootstrap/node.js, bootstrapNodeJSCore]

可以看到我们通过了另一种方法拿到了堆栈信息中每一个文件和每一个函数名。

除了 getFileNamegetFunctionName 方法外,还有 isToplevel 等更多方法。参考 Stack Trace API 可以看到更多。

应用

那么解析这个堆栈信息可以干嘛呢?目前我所了解到的,主要有两种使用场景: logger 和 test

logger

有些 logger,比如 baryon/tracer 在输出 logger 信息的同时还能告诉你发出这条信息的文件地址和函数。

比如:

1
2
3
4
const logger = require('tracer').console()

logger.log('hello')
// output: 2018-05-26T22:59:36-0400 <log> test.js:3 (Object.<anonymous>) hello

而这个 log 方法的实现其实就基于 Error 信息的堆栈信息捕捉。可以从项目源码看出,tracer 在 log 方法调用的时声明一个异常(注意,并不是抛出了一个异常)。通过这个异常,使用正则表达式的方式对其捕捉,可以得到报错的函数及方法。

如果我们自己实现一个 log 方法,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// test.js
const path = require('path')

function log (message) {
const info = new Error().stack.split('\n')[2]
const res = /at\s+(.*)\s+\((.*):(\d*):(\d*)\)/i.exec(info)
const [
functionName,
filepath,
linenum,
colnum
] = res.slice(1)
const filename = path.basename(filepath)
return console.log(`${new Date().toLocaleString()} <log> ${filename}:${linenum}:${colnum} (${functionName}) ${message}`)
}

function pleaseLogThis () {
log('this is a message')
}

pleaseLogThis()
// output: 5/26/2018, 11:14:39 PM <log> test.js:17:3 (pleaseLogThis) this is a message

其中 log 方法就是 tracer 的 log 的简化版了。其中代码中的 new Error().stack.split('\n')[2] 之所以取第三个元素,是因为第一个元素是错误的类型,例如是 Error 还是 TypeError 之类的,第二个元素是 log 方法本身的信息,也就是 Error 被声明的那一行的信息,这是我们不关心的内容。而第三个元素才是 log 方法被调用那一行的信息。

tracer 使用的正则表达式,而 klauscfhq/signale 使用的则是 Error.prepareStackTrace 方法。同样如果我们自己实现一个 demo 的话,可以这么写:

1
2
3
4
5
6
7
8
9
10
function log (message) {
const orig = Error.prepareStackTrace
Error.prepareStackTrace = (_, stack) => stack
const { stack } = new Error()
Error.prepareStackTrace = orig
const info = stack[1]
return console.log(
`${new Date().toLocaleString()} <log> ${path.basename(info.getFileName())}:${info.getLineNumber()}:${info.getColumnNumber()} (${info.getFunctionName()}) ${message}`
)
}

其它不变,即可得到同样的效果。其中 const orig = Error.prepareStackTrace 是将原先的方法保存下来,使用后复原,避免对其它可能抛出异常的代码产生负面影响。

test

ava 为例,它能在测试报告中表明具体测试代码中不通过的那一个测试所在的行,并高亮。

通过源代码 lib/beautify-stack.js 可以看到,ava 知道具体哪一行和哪一个文件也是通过对 Error 对象的堆栈信息进行解析,而且使用的是正则表达式匹配的方法。实现基本和 tracer 相似,因此就不多讲了。

对比

Error.prepareStackTrace 相比于 正则表达式方法有一个致命缺点,就是它仅对 v8 有效。如果说只在 node 环境使用,那没什么问题。但如果也需要在浏览器环境使用的话,那么这个方法也仅在 chrome 上有效,在 Firefox 上会抛出异常。

因此如果不需要考虑浏览器环境,那么请随意选择一个喜欢的。反之,只能考虑借助正则表达式。

参考

  1. Stack Trace API
  2. avajs/ava
  3. klauscfhq/signale
  4. baryon/tracer