调用栈(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
逐步分析:
console.log('1')同步执行,压栈-执行-弹栈,输出1setTimeout注册回调到宏任务队列,不入栈Promise.then注册回调到微任务队列,不入栈console.log('4')同步执行,输出4- 全局代码执行完毕,调用栈清空
- 事件循环检查:先清空微任务 → 执行 Promise 回调 → 输出
3 - 微任务清空后,取宏任务 → 执行 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
引用本术语的文章