首页 / 文章

JavaScript调用栈详解:帧、溢出以及async/await的堆栈跟踪

JavaScript调用栈是如何逐帧工作的,为什么会出现栈溢出,闭包存在于何处,以及如何在DevTools和Node.js中查看async/await的栈跟踪信息。

发布者:REACTAPP.TOP阅读约 24 分钟5598 词

来源: Day 6 of Learning JavaScript: JavaScript’s Traffic Cop: How the Call Stack Controls Everything Your Code Does

一个经典的面试问题会展示几组嵌套的函数调用,并询问最终会输出什么内容以及输出顺序如何。每个函数本身都很简单,让人困惑的是调用顺序。这种顺序并非凭直觉就能判断,而是由调用栈这一机制决定的。一旦你理解了它的运作方式,输出相关问题就变成了机械性的练习,堆栈溢出也不再神秘,错误追踪则变成了清晰的路径图。本指南将详细介绍调用栈的工作原理,逐帧追踪程序执行过程,解释堆栈为何会溢出以及如何避免这种情况,最后还会展示如何利用开发者工具和堆栈追踪来调试实际代码。

为什么单线程语言需要调用栈

其他所有情况都源于一个设计决策:JavaScript在单线程上执行代码。这是有意为之,并非疏忽。引擎在任何时刻都只会执行一段代码,绝不会同时执行两段。

但这并不意味着没有并发操作。计时器、网络请求和文件操作由主机环境(浏览器的Web API或Node.js)处理,这些都在JavaScript引擎之外。浏览器会将这些任务交给原生线程池或操作系统的I/O机制,如epoll,有时还会通过进程间通信在不同进程间传递信息。当后台任务完成后,其回调会被放入任务队列中。虽然等待过程是并发的,但回调本身仍会在同一条单线程上依次执行。

如果这听起来很抽象,那就想象一家只有一名厨师的餐厅。这名厨师动作迅速且条理清晰,但无法同时切菜和炒菜。每项任务完成后才会开始下一项。JavaScript引擎的工作方式也是如此。

有趣的问题是,当一个任务启动另一个任务时会发生什么?考虑三个小型函数,每个函数都调用下一个函数:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

当 printSquare 调用 square 时,它无法直接继续执行,因为需要先得到计算结果。因此必须有某种机制来记录 printSquare 已暂停、暂停的位置,以及当 multiply 执行完毕后控制权应返回到 square 而非顶层脚本。

这种记录机制就是调用栈。它是引擎用来记录当前正在运行的代码以及等待恢复执行的代码的机制。没有调用栈,函数就无法返回到正确的位置,其返回值也无处可去。这并非某种后台优化手段,而是 JavaScript 执行函数调用的实际机制。

两种内存:栈、堆与闭包

初学者常问的一个问题是:如果一台机器拥有16或32GB的RAM,为什么JavaScript程序在仅有几千次嵌套调用后就会耗尽栈内存?答案是,像V8这样的引擎会将内存划分为两个截然不同的区域——调用栈和堆。

TWO SIDES OF JAVASCRIPT MEMORY:

CALL STACK (Execution & Order)   MEMORY HEAP (Storage & Data)
┌─────────────────────────────┐  ┌─────────────────────────────┐
│ [Frame: square(5)]          │  │ Object: { name: "Alice" }   │
│   n: 5                      │  │ Array: [10, 20, 30, 40]     │
│   ref: 0x8F3A ──────────────┼─>│ Function: multiply()        │
├─────────────────────────────┤  │ Object: { role: "admin" }   │
│ [Global Execution Context]  │  │ Large Strings & Closures    │
└─────────────────────────────┘  └─────────────────────────────┘
- Fast, ordered (LIFO)           - Large, unstructured
- Small, fixed memory limit      - Gigabytes dynamic storage
- Auto-cleared on return         - Cleaned by Garbage Collect

调用栈经过优化,旨在提升速度并保持严格的顺序。它是一个固定大小的小区域,用于存储执行帧、返回地址以及基本的局部变量。由于采用严格的后进先出机制,丢弃已处理完的帧几乎不需要成本:引擎只需移动栈指针即可。

堆则是一个规模更大、结构较为松散的内存区域。对象、数组、函数和闭包都存储在堆中,而栈中仅保存指向它们的轻量级引用,如图所示为类似 0x8F3A 这样的地址。堆内存是由垃圾回收器回收的,而非通过函数返回来释放的。

这种划分解释了这一悖论。堆栈溢出并不意味着计算机内存不足,而是用于跟踪正在执行的调用的专用小区域已满。可将该图视为一种思维模型:实际引擎有更多复杂之处,例如由闭包捕获的变量是存储在堆分配的上下文中,而非帧本身中。

堆栈的结构:后进先出

调用堆栈是一种后进先出结构。最近添加的内容会最先被移除。可以想象食堂里叠放的干净盘子——新盘子放在最上面,人们总是取最上面的那一个,而从不会去拿中间或底部的盘子。

函数调用也遵循完全相同的规则:

Every time you call a function   → a new "plate" (frame) is placed on TOP
Every time a function finishes   → its "plate" is removed from the TOP
JavaScript always runs the function whose plate is currently on TOP

栈帧包含的内容

每个栈帧都与规范中所说的执行上下文紧密相关。从概念上讲,它记录了五项内容:

  1. 函数及其参数,例如 a = 5, b = 5。
  2. 在函数体内部声明的局部变量。
  3. 返回地址,即该函数执行完毕后程序应继续执行的调用者处的指令地址。
  4. 作用域链的引用,即让函数能够从外部作用域读取变量的上层词法环境。
  5. 在函数调用时生效的this 绑定。

有一个细节很容易被忽略。在您的任何函数执行之前,引擎会先为脚本创建全局执行上下文并将其推入栈中。在您的顶层代码运行时,它便构成了栈的底部。

WHEN YOUR SCRIPT STARTS:

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Always here first
  └──────────────────────────────────┘

每个调用都堆叠在那个基础之上,有一条规则将它们全部联系在一起:引擎始终会执行最顶层的帧。一旦有新的帧被加入,其下方的帧就会暂停。

逐帧追踪程序执行过程

让我们通过堆栈来追踪 printSquare(5) 的执行过程。仔细操作一次通常就足以让这一过程自动化。以下是该程序:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

调用链为 printSquare,其内部是 square,再往里则是 multiply。

第一步:脚本启动

在 printSquare(5) 执行之前,引擎会开始解析并执行该文件,并创建全局执行上下文。

STEP 1: Script starts running

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Pushed first, always
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

引擎还会记录 multiply、square 和 printSquare 的定义。定义函数本身不会产生任何内容;只有调用函数时才会创建一个框架。

步骤2:调用 printSquare(5)

引擎执行到 printSquare(5) 时,会为其创建一个框架并将其置于最上层。

STEP 2: printSquare(5) is called

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare现在位于最上层,因此会先被执行。它的第一行语句const result = square(n)其实又是另一次调用,所以printSquare必须先交出控制权才能继续执行其他操作。

步骤3:printSquare调用square(5)

一个用于执行square(5)的执行框架会被置于printSquare之上。

STEP 3: square(5) is called inside printSquare

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting, paused
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare 已暂停,正在等待一个值。所有注意力都集中在 square 上,它那条唯一的语句 return multiply(n, n) 会触发另一次调用。

第4步:square调用multiply(5, 5)

又过了一帧时间,栈中的帧数达到四帧,这是该程序中栈深度的最大值。

STEP 4: multiply(5, 5) is called inside square -- Stack peak!

  CALL STACK:
  ┌──────────────────────────────────┐
  │  multiply(5, 5)                  │  <-- TOP - runs now
  ├──────────────────────────────────┤
  │  square(5)                       │  <-- Waiting
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

multiply不会调用其他任何函数。它计算5 * 5,返回25后便结束执行,因此其调用栈帧可以被丢弃。

第5步:multiply返回结果,其调用栈帧被弹出

当一个函数执行完毕时,其调用栈帧会从栈顶弹出。位于其正下方的栈帧,即这里的square,会再次成为栈顶并从上次暂停的位置继续执行。

STEP 5: multiply(5, 5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Still waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)

对于 square 函数,原本的 return multiply(n, n) 表达式现已变为 return 25。它返回该值后,便会依次被弹出。

第6步:square返回并打印,Square继续执行

STEP 6: square(5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)
  2. square(5)       --> returned 25 (stored as const result inside printSquare)

在 printSquare 函数中,赋值语句 const result = square(n) 执行完成后,result 的值为 25。接下来的语句 console.log(result) 会输出 25。

第7步:日志被输出,调用栈开始回溯

STEP 7: console.log(25) executes, then printSquare finishes -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Program done
  └──────────────────────────────────┘

  FINAL OUTPUT:
  1. multiply(5, 5) --> returned 25
  2. square(5)       --> returned 25
  3. console.log(25) --> PRINTED: 25
  4. printSquare(5)  --> finished (returns undefined implicitly)

printSquare会隐式返回undefined并被移除,仅留下全局上下文。最终出现在控制台中的唯一值是printSquare输出的25;multiply和square的返回值则被悄无声息地传递下去了。

完整执行过程一览

同样的七个步骤,为便于快速查看而进行了简化:

STEP 1: Script starts
  Stack: Global
  Output: (none)

STEP 2: printSquare(5) called
  Stack: printSquare -> Global
  Output: (none)

STEP 3: square(5) called
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 4: multiply(5, 5) called
  Stack: multiply -> square -> printSquare -> Global
  Output: (none)

STEP 5: multiply returns 25
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 6: square returns 25
  Stack: printSquare -> Global
  Output: (none)

STEP 7: console.log(25), printSquare done
  Stack: Global
  Output: 25

以上内容可归纳为四条规则:

  1. 每次调用都会在栈顶添加一个新帧。
  2. 每次返回都会移除栈顶的帧。
  3. 栈顶的帧即为当前正在执行的帧。没有例外。
  4. 调用者位于该帧的正下方。当前函数返回时,控制权就会转移到那里。

当栈空间耗尽时:栈溢出

该错误的实际含义

栈的大小是由引擎预先设定的固定值。每个帧都会占用部分栈空间,用于存储参数、局部变量、返回地址以及相关管理信息。如果不断添加帧而不将其移除,栈空间最终会被耗尽。此时引擎会抛出RangeError错误:

RangeError: Maximum call stack size exceeded

那就是栈溢出。那个著名的程序员问答网站实际上就是以这种错误命名的。常见的诱因是一个函数不断自我调用且没有停止机制,这就是递归出错的表现。

无限递归

最简单的例子就是一个唯一功能就是不断调用自身的函数:

// No base case -- this never stops
function forever() {
  return forever();
}

forever(); // RangeError: Maximum call stack size exceeded

从可视化角度来看,堆栈会不断增长,直到引擎无法继续处理:

STACK GROWING OUT OF CONTROL:

  ┌──────────────────────────────────┐
  │  forever()                       │  <-- frame #10,000+ ... crash!
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  ...                             │  (thousands more)
  ├──────────────────────────────────┤
  │  forever()                       │  <-- frame #1
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RESULT: RangeError: Maximum call stack size exceeded

每次调用 forever() 都会添加一个帧,而且由于该函数永远不会返回值,因此也永远不会弹出任何内容。最终预留的空间会被耗尽,从而引发 RangeError 异常。

递归本身并非问题,它是一种合法且常常十分优雅的编程技巧。问题在于没有退出机制的递归。每个递归函数都需要一个基准情况,即让函数停止自我调用并返回的条件。

永远无法达到的基准情况

有一种更为隐蔽的情况会困扰许多开发者。下面的函数虽然有基准情况,但其递归步骤的方向却是错误的:

function countDown(n) {
  if (n === 0) return; // Base case IS here, but never reached
  countDown(n + 1);    // Bug: going UP, not down -- n never reaches 0
}

countDown(1); // RangeError: Maximum call stack size exceeded

虽然有 if (n === 0) return 这一保护机制,但递归调用传递的却是 n + 1。从 1 开始,数值会依次变为 2、3、4,以此类推,因此永远无法达到零值,导致栈溢出。修正后的版本采用递减方式,并使用 n <= 0 作为防护机制,这样即使出现意外的负数或小数起始值,也能终止递归。

function countDown(n) {
  if (n <= 0) return; // Base case: stop when we reach or pass zero
  console.log(n);
  countDown(n - 1);   // Subtracting: we're actually getting closer to 0
}

countDown(5); // Prints: 5, 4, 3, 2, 1

间接递归

这种循环也可以跨越多个函数。此处 ping 调用 pong,而 pong 又反过来调用 ping:

function ping() {
  pong(); // ping calls pong
}

function pong() {
  ping(); // pong calls ping back: circular loop
}

ping(); // RangeError: Maximum call stack size exceeded

这两个函数都不会调用自身,但它们共同构成了一个无限循环的调用结构,最终结果都是溢出。在真实代码中,间接循环更难被发现,因为这两部分通常位于不同的模块中,比如某个事件处理程序触发了一次更新,而这次更新又会再次引发同一个事件。

栈能有多深?

最大深度取决于引擎以及每个帧所需的内存大小。以下数值仅为大致参考,并非固定值;它会因版本、平台及配置参数的不同而有所变化,如果该限制对您很重要,请在目标环境中进行实际测试。

CALL STACK LIMITS BY RUNTIME (APPROXIMATE):

  - Chrome & Node.js (V8 Engine):        ~10,000 to 12,000 frames
  - Safari (JavaScriptCore Engine):       ~40,000 to 45,000 frames
  - Firefox (SpiderMonkey Engine):        ~50,000+ frames

出现这种差异的原因是,限制实际上是指内存预算,而非调用次数。包含大量参数和局部变量的函数会生成更大的内存帧,因此在达到上限之前能容纳的函数数量就会减少。

实际解决栈溢出问题

当深度递归在真实代码中导致崩溃时,有一些可靠的解决方案。

用循环替代递归

当输入数据量较大时,可将递归改写为 for 或 while 循环。循环在单个内存帧内执行,因此即使进行数百万次迭代也不会增加栈深度。在下面的示例中,V8引擎中递归求和在约20,000层深度时就可能溢出,而迭代版本则能轻松处理一百万次迭代:

// Recursive version: crashes if n is 20,000
function sumRecursive(n) {
  if (n === 0) return 0;
  return n + sumRecursive(n - 1);
}

// Iterative version: uses 1 stack frame, handles any n safely
function sumIterative(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

console.log(sumIterative(1000000)); // 500000500000 (instant, zero stack growth)

弹跳机制

如果偏好递归风格,弹跳机制可以在不增加栈深度的情况下实现递归。递归函数不会调用自身,而是返回一个 thunk,即一个描述下一步操作的零参数函数。弹跳机制通过扁平的 while 循环依次执行这些 thunk;每个 thunk 在下一个开始之前就会返回,因此栈深度始终保持不变。

function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);
    while (typeof result === "function") {
      result = result();
    }
    return result;
  };
}

// Return a function thunk instead of a direct recursive call
function safeSum(n, acc = 0) {
  if (n === 0) return acc;
  return () => safeSum(n - 1, acc + n);
}

const runSum = trampoline(safeSum);
console.log(runSum(100000)); // 5000050000 (no stack overflow!)

注意 safeSum 中的累加器参数 acc:运行中的总和会保留在参数中,而非存储在栈上。其代价是每执行一步都需要额外分配函数空间,而且这种写法对读者来说不够直观,因此这类机制最好仅用于真正的递归算法。

避免将巨大数组拆分到多次调用中

一个常见的意外情况是:当把一个非常大的数组拆分到多次函数调用中时,每个元素都会作为单独的参数传递,而这些参数同样需要栈空间。

const hugeArray = new Array(150000).fill(1);

// CRASH: RangeError: Maximum call stack size exceeded
// Math.max(...hugeArray);

// SAFE: Process with a loop or reduce
const maxVal = hugeArray.reduce((max, val) => (val > max ? val : max), -Infinity);

根据引擎的不同,当数组包含十万项或更多时,类似 Math.max(...hugeArray) 的调用可能会抛出相同的 RangeError。而使用 reduce 或普通循环处理数据则没有此限制。

不要依赖尾调用优化

早期的教程以及 ECMAScript 2015 规范中提到了恰当的尾调用(PTC),通常也称为尾调用优化。其原理是:在严格模式下,如果一个函数的最终操作是返回另一个调用的结果,引擎就可以重用当前栈帧,而无需创建新的栈帧,这样就能让无限递归在恒定的栈空间内运行。

实际上,在各大引擎中只有 Safari 的 JavaScriptCore 实现了这一功能。Chrome 和 Node.js 所使用的 V8 以及 Firefox 的 SpiderMonkey 都选择不在标准 JavaScript 中提供该功能,主要是因为移除帧会丢失堆栈跟踪和调试器所依赖的历史记录。在生产代码中应视尾调用为不可用功能,对于深度递归则应使用循环或跳板机制。

预测面试题的输出结果

输出类问题其实就是堆栈问题

“这段代码会输出什么?”这类问题几乎出现在所有的 JavaScript 面试中,而大多数其实都是伪装起来的堆栈调用练习。面试官并非在测试你的记忆能力,而是在确认你是否理解执行顺序,而这一顺序完全由堆栈决定。在继续阅读之前,请先尝试解答以下题目:

function a() {
  console.log("a start");
  b();
  console.log("a end");
}

function b() {
  console.log("b start");
  c();
  console.log("b end");
}

function c() {
  console.log("c");
}

a();

逐帧追踪

程序会输出:

a start
b start
c
b end
a end

许多人期望a end能更早出现。但它之所以没有出现,原因如下:调用a()会将其框架推入执行队列,随后a便开始运行。

CALL STACK after a() is called:
  ┌──────────────────┐
  │  a()             │  <-- running
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a中的第一条语句记录了开始状态。接下来是b()的调用,因此一个属于b的帧被推到了最上层。

CALL STACK after b() is called inside a:
  ┌──────────────────┐
  │  b()             │  <-- running now
  ├──────────────────┤
  │  a()             │  <-- paused, waiting for b to finish
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a 目前处于暂停状态,其最后的 console.log("a end") 还未执行;在 b 完全执行完毕之前它无法执行该代码。在 b 内部,首先会输出 b start,随后调用 c(),从而增加第三个帧。

CALL STACK after c() is called inside b:
  ┌──────────────────┐
  │  c()             │  <-- running now
  ├──────────────────┤
  │  b()             │  <-- paused
  ├──────────────────┤
  │  a()             │  <-- paused
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

c记录下c的信息后便没有其他事情可做,于是返回。它的帧被弹出,b再次处于最上层。

CALL STACK after c() returns:
  ┌──────────────────┐
  │  b()             │  <-- resumes now
  ├──────────────────┤
  │  a()             │  <-- still waiting
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

b在调用完c()后会立即继续执行,输出b end,然后返回。其执行栈帧会被弹出,使a再次处于最上层。

CALL STACK after b() returns:
  ┌──────────────────┐
  │  a()             │  <-- resumes now
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a在调用其方法后会继续执行,记录a end,随后返回并被弹出。此时仅剩全局上下文。完整的输出顺序如下:

a start
b start
c
b end
a end

需要牢记的规则是:调用方不得在被调用函数尚未完全执行完毕前继续后续操作。 a end必须等待所有b的操作完成,而b又需等待所有c的操作完成。a是第一个发起调用的函数,因此也是最后一个执行完毕的。

处理任何输出问题的三步法

  1. 按顺序列出所有调用,从最外层的调用开始逐层向内分析。每次调用都会使调用方暂停执行。
  2. 跟踪栈顶状态。位于栈顶的函数正在运行,其下方的所有函数都处于等待状态。
  3. 记录每条日志。被调用函数内部的日志会先于调用方中该调用之后的任何日志出现。

模式始终如一:一直深入到最底层的调用,然后再按相反顺序逐层返回。这适用于同步代码。定时器、Promise 和事件触发的回调会在该模型之上增加一个队列;我们关于闭包与事件循环的文章介绍了这一层机制。

在 Chrome DevTools 中实时查看调用栈

在纸上进行追踪很适合面试,但在日常工作中,通常会直接在浏览器的调试器中查看调用栈。

使用 debugger 语句暂停执行

在某一行添加 debugger; 后,只要打开 DevTools,执行就会在该处暂停。可通过 F12 打开它,或右键点击页面选择“检查”,然后切换到“源代码”标签页。

function calculateTax(subtotal) {
  const rate = 0.08;
  debugger; // Execution pauses right here
  return subtotal * rate;
}

function checkoutCart() {
  const total = 100;
  const tax = calculateTax(total);
  console.log(total + tax);
}

checkoutCart();

当执行在 debugger 行停止时,侧边栏会显示当前的堆栈信息以及作用域内的变量:

DEVTOOLS SOURCES PANEL BREAKDOWN:

  ┌───────────────────────┐  ┌──────────────────────────────────────────────┐
  │ CALL STACK PANE       │  │ SCOPE PANE (Updates based on clicked frame)  │
  ├───────────────────────┤  ├──────────────────────────────────────────────┤
  │ calculateTax  (line 3)│  │ Local Scope:                                 │
  │ checkoutCart  (line 9)│  │   subtotal: 100                              │
  │ (anonymous)   (line 13│  │   rate: 0.08                                 │
  └───────────────────────┘  └──────────────────────────────────────────────┘

调用栈面板中,最上方是calculateTax,其下方是checkoutCart,最底部则是匿名顶层脚本,这与之前追踪的结构相同。点击checkoutCart后,编辑器会跳转到第9行,同时作用域面板会切换到该函数的作用域变量,显示的total: 100数值与调用calculateTax时的完全一致。能够查看已暂停的调用函数的状态,是调试功能中最实用的功能之一。

逐步执行调用

调试器工具栏提供了三种与栈操作直接对应的逐步执行控制选项:

  1. 跳过当前行(F10)会执行当前行代码,然后停在同一函数的下一行。如果当前行调用了其他函数,该调用会完整执行,调试器不会在其中暂停。
  2. 单步进入(F11)会跟随当前行中的调用,新建一个执行帧并在该帧的第一条语句处暂停。
  3. 单步退出(Shift+F11)会继续执行当前函数剩余的部分,弹出该函数对应的执行帧,并在调用返回后的下一行代码处暂停。

借助这三个控制功能,调试就能变成有条不紊地遍历调用栈,而非凭猜测行事。

堆栈跟踪:故障发生时的调用栈

堆栈跟踪能提供什么信息

当错误发生且未被捕获时,引擎会输出堆栈跟踪信息:即错误发生瞬间调用栈中所有执行帧的快照。它能够显示当时正在执行的代码、调用该代码的函数,以及出错的具体行号。堆栈跟踪本质上就是以文本形式呈现的调用栈,因此之前提到的所有规则在阅读堆栈跟踪时同样适用。

跟踪信息看起来往往令人生畏,只是一堆路径和数字,但一旦熟悉其结构,它就会成为定位错误的最快途径。

跟踪信息的构成

以一个小型调用链为例,其中最内层的函数抛出了异常:

function c() {
  throw new Error("something went wrong");
}

function b() {
  c();
}

function a() {
  b();
}

a();

在 Node.js 中运行时,它会输出如下内容:

Error: something went wrong
    at c (/box/index.js:2:9)
    at b (/box/index.js:6:3)
    at a (/box/index.js:10:3)
    at Object.<anonymous> (/box/index.js:13:1)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)

第一行显示错误类型和信息,分别为Error和something went wrong。这就是出错的原因。您经常会遇到的其他错误类型包括TypeError、ReferenceError、SyntaxError和RangeError。

以at开头的每一行代表错误发生时堆栈中的一个帧,格式如下:

at functionName (filename:lineNumber:columnNumber)

at c (/box/index.js:2:9) 表示函数 c 正在 /box/index.js 文件的第 2 行第 9 列运行,而该位置正是 throw 语句所在。

at Object.<anonymous> 表示代码在模块的顶层运行,即在任何命名函数之外。此处 a() 是从模块的顶层被调用的。在堆栈跟踪的其他位置,<anonymous> 通常表示没有名称的内联回调函数。

下方的行,如 Module._compile、Module.load 和 wrapModuleLoad,都是 Node.js 用于加载和执行文件的内部函数。这些并非你编写的代码,通常可以忽略。

阅读顺序

排序方式是让人困惑的地方。最新的帧会首先被打印出来,因此列表的顺序与实际调用顺序相反:

Read this direction (for debugging):
  ↑ TOP = where the error actually happened
  |
  | at c (/box/index.js:2:9)       <-- Error thrown HERE
  | at b (/box/index.js:6:3)       <-- b called c
  | at a (/box/index.js:10:3)      <-- a called b
  | at Object.<anonymous> (...)    <-- global code called a
  ↓
BOTTOM = where execution started

轨迹的底部是执行开始的入口点,顶部则是错误发生的位置。执行会沿着各个帧向上推进,直到错误发生使其停止。

进行调试时,从顶部开始,找到第一个at记录所在的文件和行号:那里就是程序崩溃的地方。然后向下查看,因为真正的原因往往在再往下的一两个帧中。错误虽然出现在c中,但到底是哪个调用者向c传递了错误数据?请检查b。又是谁以那种方式调用了b?请检查a。最顶层的帧显示的是症状,其下方的帧通常包含根本原因。

适用于任何轨迹的可靠步骤:

  1. 阅读错误类型和信息,以了解发生了何种类型的故障。
  2. 找到第一行指向您自身代码的 at 行,跳过运行时和库相关的帧。
  3. 打开该文件及对应行,查看其中发生的情况。
  4. 检查其正下方的帧,因为错误的值或不当的调用通常源自调用方。

使用 console.trace() 按需打印堆栈

通过调用 console.trace(),您可以在任何时刻无错误地打印当前堆栈:

function deepFunction() {
  console.trace("Who called me?");
  // prints current call stack right here
}

function middle() {
  deepFunction();
}

function outer() {
  middle();
}

outer();

输出结果如下:

Trace: Who called me?
    at deepFunction (/box/index.js:2:11)
    at middle (/box/index.js:7:3)
    at outer (/box/index.js:11:3)
    at Object.<anonymous> (/box/index.js:15:1)

当面对不熟悉的代码且无法确定函数是从何处被调用时,这种方法特别有用:添加 console.trace(),运行相关代码,即可看到完整的调用路径。在发布代码前请移除这些调用。

为函数命名

完全匿名的函数在跟踪信息中会显示为 <anonymous>,这样几乎无法作为搜索依据。下面的示例展示了两者之间的差异,不过第一个示例需要修正。

const doSomething = () => {
  throw new Error("broken");
};

doSomething();
Error: broken
    at <anonymous> (/box/index.js:2:9)  <-- useless for debugging

在当前的引擎中,直接赋值给 const 的箭头函数其名称会从变量名推断而来,因此这个例子实际显示的是 at doSomething 而非 <anonymous>。那些无用的 <anonymous> 标签则来自直接传递给 map、then 或事件监听器等函数的内联回调。而使用有名称的声明则不会产生歧义:

function doSomething() {
  throw new Error("broken");
}

doSomething();
Error: broken
    at doSomething (/box/index.js:2:9)  <-- instantly tells you what crashed

在充斥着内联回调的代码库中,数十个相同的<anonymous>帧会让跟踪过程变得极为繁琐。为回调命名或将其提取为带名称的函数,有助于保持跟踪信息的可读性。

V8与Node.js中的Async/await堆栈跟踪及生产环境工具

对于后端服务及大型前端应用,V8提供了用于控制跟踪信息捕获方式的API。

通过Error.stackTraceLimit捕获更多帧

默认情况下,V8在error.stack中最多记录10帧信息。在结构复杂的代码中,比如Express或NestJS的请求处理流程、Redux的中间件链,这10帧信息可能会被框架代码占用,导致自定义函数的信息无法显示。你可以全局提升这一限制:

// Capture up to 50 frames for deep diagnostics
Error.stackTraceLimit = 50;

// Or capture everything without any limit:
// Error.stackTraceLimit = Infinity;

此设置仅控制跟踪记录中要保存的帧数。它不会影响实际的堆栈最大深度,过高的数值,尤其是Infinity,会导致每个错误的生成成本上升,因此在生产环境中建议选择适中的数值。

使用Error.captureStackTrace生成更简洁的自定义错误信息

当您定义自己的错误类时,构造函数本身通常会出现在跟踪信息中。Error.captureStackTrace允许您将其排除,从而使跟踪信息从错误实际发生的位置开始显示:

class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
    // Omit the constructor call itself from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

第二个参数告诉 V8 跳过该函数及其上方的所有帧。if 语句则确保在没有此 V8 特有 API 的引擎中该类仍能正常工作。

使用 async/await 的异步堆栈跟踪

过去,异步代码生成的堆栈跟踪是截断的。来自 setTimeout 的回调或 Promise 会在原始调用者返回很久之后在新的堆栈上执行,因此调用链已经消失。

现代的 V8,在 Chrome 和 Node.js 中,都为 async/await 提供了零开销的异步堆栈跟踪。如果在 await 恢复执行后发生了异常,V8 仅在实际需要堆栈跟踪时才会重建等待函数的调用链,并用 async 标记这些帧:

Error: Query failed
    at executeQuery (db.js:14:11)
    at async getUserData (user.js:22:9)
    at async handleRequest (server.js:45:5)

该优势适用于原生 async/await 语法。冗长的手写 .then() 链以及基于回调的代码无法获得同样的重构效果,这更是建议在需要调试的代码中使用 await 的另一个理由。

调用栈的几条规则

  • JavaScript 在单个线程上执行代码。宿主环境可同时处理后台任务,但所有由此产生的回调都需在该线程上按顺序等待。
  • 调用栈是一个小型、快速、固定大小的后进先出结构,用于记录当前正在运行的函数以及每个调用者将在何处继续执行;对象和闭包则存储在堆中,并通过调用栈进行引用。
  • 调用操作会将一个帧推入栈中,而返回操作则会将其弹出。最顶层的帧始终处于运行状态,其下方的所有帧则处于暂停状态。
  • 堆栈溢出表现为 RangeError: Maximum call stack size exceeded,意味着堆栈的内存已被耗尽,通常是由于存在没有终止条件的递归,无论是直接的还是间接的。
  • 对于深度递归,应优先使用循环或跳板机制,避免将过大的数组传递给函数调用。真正的尾调用优化仅在 Safari 中支持,因此不可依赖此功能。
  • 在开发者工具中,Step Over、Step Into 和 Step Out 分别对应执行一次调用、进入一个函数帧以及离开一个函数帧。
  • 堆栈跟踪记录的是故障发生时的堆栈状态:最顶部是错误产生的位置,最底部是程序的入口点,而错误原因通常位于最顶部下方一两层函数帧处。
  • console.trace()、命名函数、Error.stackTraceLimit、Error.captureStackTrace以及原生的async/await都能让跟踪信息更加详尽。
  • 像setTimeout处理函数这样的队列回调只有在调用栈完全清空后才会执行,这也是为什么调用栈是理解异步JavaScript的基础。
  • 这样做的好处

    那些耗费数小时才能解决的错误,背后往往都是栈相关的问题:函数比预期更早返回、回调以异常顺序执行、本不应为undefined的值却变成了该值。能够快速解决这些问题的开发者并非更聪明,只是他们早已掌握了栈及其调用栈的结构,因此能直接定位到相关部分。调用栈记录了实际执行的内容,一旦信任它,调试工作就会变得轻松许多。

    相关阅读

  • JSON.stringify会默默省略、转换哪些内容以及拒绝序列化什么:了解JSON.stringify会忽略或修改哪些JavaScript值,toJSON、替换函数和恢复函数如何解决这些问题,以及何时使用structuredClone会更合适。