如何阅读 JavaScript 堆栈跟踪
阅读 JavaScript 堆栈跟踪:识别首个有用帧,理解 async 间隙、压缩代码、source map 和 Error.cause。
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 帧。
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