12k
All articles

如何阅读 JavaScript 堆栈跟踪

阅读 JavaScript 堆栈跟踪:识别首个有用帧,理解 async 间隙、压缩代码、source map 和 Error.cause。

OpenReplay Team
OpenReplay Team
如何阅读 JavaScript 堆栈跟踪

JavaScript 堆栈跟踪是按时间倒序阅读的:最顶部的帧是抛出错误的那次调用,而下面的每一帧都是引向它的调用。

通常的做法是快速扫一眼第一行,把它粘贴到搜索框里,然后祈祷有结果。这招在顶部帧属于 React、属于 JSON.parse,或者属于一个所有函数都叫 o 的打包产物之前,都还算奏效。

本文会完整地梳理一条堆栈跟踪,并在每个小节中补充一种阅读方式:真正应该打开哪一帧、async 帧在告诉你什么、压缩后的堆栈跟踪长什么样,以及为什么 Error.cause 背后的堆栈永远不会出现在你打印出的堆栈里。

关键要点

  • 顶部帧是错误被抛出的位置,而不是 bug 被引入的位置;第一个指向你自己编写的文件的帧,才是排查的起点。
  • 标记为 async 的帧是由 V8 根据每个 await 暂停和恢复的位置重建出来的,因此 await、Promise.all() 和 Promise.any() 会被串联起来,而单纯的 .then() 链则会留下断层。
  • V8 默认只保留 10 帧,这个数字可以通过非标准的 Error.stackTraceLimit 修改。
  • new Error(message, { cause }) 会保留原始错误,但引擎不会合并两个堆栈:err.stack 只显示外层包装错误。

JavaScript 堆栈跟踪长什么样?

JavaScript 堆栈跟踪的第一行是错误名称和消息,随后每次调用一帧,最新的在最前面。下面是本文会反复回顾的那条跟踪:从磁盘读取一个 cart 文件、解析它,然后交给应用的其余部分。

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at loadCart (/app/src/cart.js:10:20)
    at main (/app/src/main.js:6:16)
    at Object.<anonymous> (/app/src/main.js:12:1)

按这个顺序读下来,调用链一目了然:JSON.parse 抛出了错误,parseCart 调用了它,loadCart 调用了 parseCart,依此类推。最底部的帧是这一条特定调用链的起点,但它并不总是程序的入口:通过点击处理器、定时器回调或已 settle 的 promise 到达的代码,会获得一条从回调开始的全新调用链。

单独一帧能给你四样信息:函数名、文件、行号和列号。在压缩后的代码中,如果没有 source map,只有列号还有点价值。有一个值得尽早知道的注意事项:Error.prototype.stack 并未被标准化。每个引擎都提供了它,每个引擎打印出的字符串都略有差异,而 TC39 上用于统一格式的工作仍未完成。这里的示例采用 V8 的格式,涵盖 Chrome、Edge 和 Node。

顶部帧通常不是你的代码

堆栈跟踪的顶部帧通常不是你的代码。它是那个发现了错误值的库、框架或内置函数,也就是说,它告诉你的是什么坏掉了,而不是为什么坏掉。在上面这条跟踪中,at JSON.parse (<anonymous>) 是内置解析器在报告:传给它的字符串不是合法的 JSON。那里没什么可修的。

你想要的那一帧,是第一个指向你自己编写的文件的帧。也就是 /app/src/cart.js:5:15 处的 parseCart。打开那一行,你会看到对 JSON.parse 的调用,这说明有问题的字符串是作为参数传进来的。所以这个值来自下面那一帧:第 10 行的 loadCart,它读取了文件。那才是真正的起点,而它回答的问题是:文件内容从哪里来,为什么没有任何东西对它做过校验。

这种阅读顺序具有普适性。向下扫过 node_modules 路径、<anonymous> 和 native 帧,直到遇到你自己的文件,然后继续沿着提供了这个值的那些帧向下追查。

堆栈跟踪记录的是程序走向失败的路径,而绝不是用户走过的路径,这就是为什么两份帧完全相同的报告,一份可能五分钟就能修好,另一份却是无法复现的幽灵。会话回放弥补了缺失的那一半:你通过帧来读懂它在哪里坏掉,再通过会话来看应用是如何进入那个足以导致故障的状态的。

Async 帧与 await 边界

把 loadCart 标记为 async 后,堆栈跟踪就会跨越 await,重建出的帧带有 async 前缀:

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at async loadCart (/app/src/cart.js:10:20)
    at async main (/app/src/main.js:6:16)

这些带 async 前缀的帧,并不是像同步帧那样被捕获的。V8 是从 await 的位置重建它们的,而且这件事的成本为零,因为一个 await 会在它暂停的那个位置原地恢复执行。

这种重建是有限度的,而这些限度正是堆栈跟踪变得稀薄的地方。这种串联只能覆盖 await 位置、Promise.all() 和 Promise.any(),其他一概不管,所以一个返回了却没有被 await 的 promise,或者一条 .then() 链,会恰好在原本调用上下文所在的位置留下一个空洞。如果帧在某个异步边界处突然中断,那就去上一层找找有没有漏掉的 await。没有什么开关需要打开:--async-stack-traces 从 V8 v7.3 起就已默认启用,因此 async 帧会出现在 Chrome、所有仍在维护的 Node 版本,以及其他当前的 V8 运行时中。

帧丢失的另一个原因是上限。V8 只保留 10 帧,其余丢弃,而 Error.stackTraceLimit 就是调节这个数字的旋钮:新设的值只对设置之后创建的错误生效,而任何非数字的值、或小于零的值,都会让你一帧都拿不到。

if (process.env.NODE_ENV !== 'production') {
  Error.stackTraceLimit = Infinity;
}

把它限制在开发环境。很深的堆栈跟踪会消耗内存,会把有价值的帧埋进噪声里,还会把文件路径和函数名推送到可能被转发到别处的日志中。这个属性是非标准的:它出自 V8,JavaScriptCore 为了兼容性照搬了它,所以设置它在任何地方都不会出问题,但默认值和细节表现取决于引擎。

如何阅读压缩后的堆栈跟踪?

在生产环境的打包产物上,同样的故障会产生这样的帧。请把这个形态当作打包输出的示意,而不是某个具体工具的确切格式:

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at o (/assets/index-4f1c8a2b.js:1:20874)
    at async s (/assets/index-4f1c8a2b.js:1:21036)

特征非常明显:单字母函数名、同一个文件名、行号为 1、以及五到六位数的列号。行号 1 加上一个巨大的列号,意味着整个模块图都在一行上,所以列号是唯一携带信息的坐标。parseCart 和 loadCart 仍然存在于那个列偏移中,但这串字符里没有任何东西能告诉你它们的名字。

要把它们还原出来,需要一份工具链能够访问到的 source map,在构建时生成,并上传到能用于解析堆栈跟踪的位置。我们关于source map 如何工作的指南介绍了它的格式和构建配置。

Error.cause 不会合并堆栈

用 new Error(message, { cause: originalError }) 包装一个错误,会保留原始错误对象,连同它的类型和自己的堆栈一起,但引擎不会合并这两个堆栈。err.stack 只显示外层包装错误,原始错误只能通过 err.cause.stack 访问。

export async function loadCart(path) {
  const raw = await readFile(path, 'utf8');
  try {
    return parseCart(raw);
  } catch (err) {
    throw new Error(`Cart file ${path} is not valid JSON`, { cause: err });
  }
}

现在调用方会得到一条点明了文件名的消息,而且 err.cause instanceof SyntaxError 仍然成立。他们得不到的是 JSON.parse 那一帧:包装错误的堆栈从 loadCart 内部的 throw 开始。Error.cause 是在 ES2022 中引入的,在当前各主流浏览器以及所有仍在维护的 Node 版本中都可用,最早可追溯到 Node 16.9.0。它作为一个不可枚举的自有属性存在,因此不会出现在 Object.keys()、for...in 以及对错误对象直接调用 JSON.stringify() 的结果中。

控制台会打印出多长的错误链,取决于运行时和具体的控制台实现,所以可移植的做法是自己遍历它:

function printChain(error) {
  let current = error;
  while (current instanceof Error) {
    console.error(current.stack);
    current = current.cause;
  }
}

这会先打印包装错误的帧,然后是解析器的帧,顺序与它们被抛出的顺序一致。

两个会毁掉线索的习惯

捕获一个错误后抛出一个新错误、却不传递 cause,会把原始的堆栈跟踪从程序中彻底删除。那些本能告诉你有问题的值是从哪里进来的帧,从此不复存在于任何地方,再怎么翻日志也找不回来:

catch (err) {
  throw new Error('Could not load cart');   // JSON.parse frame is gone
}

把错误吞进一行日志里,造成的破坏一样大,只是更安静:

catch (err) {
  console.log('cart load failed');          // message, no stack, no type
  return [];
}

这两种写法都只差一个关键字就能变得没问题。重新抛出时传上 { cause: err },并且记录 err 本身,而不是一句描述它的话。

下次一条堆栈跟踪摆在你面前时,不要从第一行开始。往下滚到第一个带有你自己文件名的帧,打开那一行,然后问:它接收到的是什么值,是谁传给它的。如果帧在某个 async 边界或某个单字母函数处中断了,你看到的是串联断层或缺失的 source map,而不是故事的全部。

常见问题

为什么我的错误处理器只报告 'Script error.' 而没有堆栈?

浏览器会屏蔽跨域脚本抛出的异常细节,因此 window.onerror 收到的是通用的 'Script error.' 文本,没有有用的 URL、行号或堆栈。要拿到真实的消息和帧,请在加载脚本时把 crossorigin 属性设为 anonymous,并确保托管该脚本的服务器返回覆盖你所在源的 Access-Control-Allow-Origin 响应头。大多数公共 CDN 已经会发送这个头。

Error.captureStackTrace 在 Chrome 和 Node 之外能用吗?

它已经不再是 V8 独有了。Error.captureStackTrace 最初作为 V8 非标准堆栈跟踪 API 的一部分出现,之后其他引擎也陆续跟进:JavaScriptCore 在 2023 年 12 月 11 日发布的 Safari 17.2 中提供了它,SpiderMonkey 则在 2025 年 4 月 29 日发布的 Firefox 138 中提供。调用它会把一个堆栈字符串写到你传入的任何对象上。由于它仍未标准化,在共享库代码中使用前,请用 typeof Error.captureStackTrace 检查来做一层保护。

为什么 Firefox 中的堆栈跟踪和 Chrome 中看起来不一样?

Error.prototype.stack 不在任何规范的范围内,所以每个引擎都按自己的喜好打印它,内容各不相同。V8 把每一帧写在以 'at' 开头的行上,而 Firefox 使用 functionName@file:line:column 的形式,没有这个前缀。请把堆栈字符串当作供人阅读的输出,而不是可解析的 API,并且永远不要仅凭手写的正则表达式来构建错误分组逻辑。

我能不抛出错误也捕获堆栈跟踪吗?

可以。在大多数当前引擎中,堆栈是在你构造 Error 时填充的,而不是在你抛出它时,所以 const { stack } = new Error() 会当场把调用堆栈交给你,无需 throw,也无需 catch。顶部帧是创建该错误的那一行,而通常的帧数上限依然适用:除非提高 Error.stackTraceLimit,V8 只保留 10 帧。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.