调用栈(Call Stack)

JavaScript 引擎中跟踪函数调用的 LIFO 数据结构。每调用一个函数就压入一个栈帧,函数返回就弹出。调用栈清空是微任务执行和渲染的前提条件。

定义

调用栈(Call Stack) 是 JavaScript 引擎用于跟踪函数调用的 LIFO(后进先出)数据结构。每当一个函数被调用,引擎就在栈顶压入一个栈帧(Stack Frame),包含函数的局部变量、参数和返回地址;函数执行完毕后弹出栈帧,控制权交还给调用方。

栈帧变化过程:逐步图解

通过一个三层调用的例子,看调用栈的完整 push/pop 过程:

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(4);

执行过程中,调用栈的变化如下:

text
步骤1: printSquare(4) 被调用
┌─────────────────┐
│  printSquare(4)  │  ← 栈顶
├─────────────────┤
│    (全局)        │
└─────────────────┘

步骤2: printSquare 内部调用 square(4)
┌─────────────────┐
│    square(4)     │  ← 栈顶
├─────────────────┤
│  printSquare(4)  │
├─────────────────┤
│    (全局)        │
└─────────────────┘

步骤3: square 内部调用 multiply(4, 4)
┌─────────────────┐
│  multiply(4, 4)  │  ← 栈顶(最深处)
├─────────────────┤
│    square(4)     │
├─────────────────┤
│  printSquare(4)  │
├─────────────────┤
│    (全局)        │
└─────────────────┘

步骤4: multiply 返回 16,弹出
┌─────────────────┐
│    square(4)     │  ← 栈顶
├─────────────────┤
│  printSquare(4)  │
├─────────────────┤
│    (全局)        │
└─────────────────┘

步骤5: square 返回 16,弹出
┌─────────────────┐
│  printSquare(4)  │  ← 栈顶
├─────────────────┤
│    (全局)        │
└─────────────────┘

步骤6: console.log(16) 执行完毕,printSquare 返回,弹出
┌─────────────────┐
│    (全局)        │  ← 栈空,事件循环可以接管
└─────────────────┘

每个栈帧保存了该函数的局部变量和参数(如 multiply 帧中保存 a=4, b=4),函数返回时这些数据随帧一起销毁。

栈溢出:当递归没有出口

调用栈有大小限制(不同引擎不同,Chrome 约 1 万多帧)。超出限制时引擎抛出错误:

javascript
function boom() {
  boom();
}

boom();
// Uncaught RangeError: Maximum call stack size exceeded

每次 boom() 调用自身,都会压入新栈帧,但永远不会弹出——栈帧数线性增长直到撞到上限。

实际开发中常见的触发场景:

javascript
// 组件意外无限渲染
function render() {
  setState({ count: count + 1 }); // 触发重新渲染 → 再次调用 render
}

// JSON 结构存在循环引用
const a = {};
const b = { ref: a };
a.ref = b;
JSON.stringify(a); // 内部递归序列化,检测到循环后报错

修复方式:确保递归有基准条件(base case),或用迭代替代递归。

与事件循环的协作

事件循环的核心判断就是:调用栈是否为空?

  • 不为空 → 继续执行当前代码,不做任何干预
  • 为空 → 先清空微任务队列(Promise.then 等),再取下一个宏任务(setTimeout 回调等)压入调用栈

这就是 setTimeout(fn, 0) 不是"立即执行"的原因。看这道经典题:

javascript
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

逐步分析:

  1. console.log('1') 同步执行,压栈-执行-弹栈,输出 1
  2. setTimeout 注册回调到宏任务队列,不入栈
  3. Promise.then 注册回调到微任务队列,不入栈
  4. console.log('4') 同步执行,输出 4
  5. 全局代码执行完毕,调用栈清空
  6. 事件循环检查:先清空微任务 → 执行 Promise 回调 → 输出 3
  7. 微任务清空后,取宏任务 → 执行 setTimeout 回调 → 输出 2

关键认知:只要调用栈不为空,任何异步回调都不会被执行。 一个长时间运行的同步操作会阻塞所有异步回调、UI 渲染和用户交互。

栈帧的生命周期

Mermaid

调试中的调用栈

浏览器 DevTools 的 Call Stack 面板展示的就是当前调用栈。断点暂停时可以看到完整的调用链,几个实用技巧:

  • 点击栈帧可以切换上下文:查看每一层调用的局部变量值
  • async 调用链:Chrome 默认显示 async 栈帧,可以追踪 await 之前的调用来源
  • Blackbox 脚本:右键将第三方库加入 blackbox,调用栈中会自动折叠这些帧,聚焦业务代码
  • console.trace():在代码中打印当前调用栈,不需要设断点
javascript
function handleClick() {
  processData();
}

function processData() {
  console.trace('调用链追踪');
}

// 控制台输出:
// console.trace 调用链追踪
//   processData      @ app.js:6
//   handleClick      @ app.js:2
//   (匿名)           @ index.html:10

引用本术语的文章