调用栈和执行上下文的关系
JavaScript 调用栈如何承载全局与函数执行上下文,以及为什么递归过深会导致栈溢出。
#type / concept
#status / growing
#tech / dev / frontend
#resource / javascript
[!info] related notes
- 前置概念: ecmascript-execution-context
- 相关主题: V8, JavaScript事件循环
调用栈和执行上下文的关系
一句话定义
调用栈(Call Stack)是 JS 引擎管理执行上下文的 LIFO(后进先出)数据结构,记录当前正在执行的函数及其调用链。
核心机制 / 工作原理
执行上下文类型
| 类型 | 创建时机 | 特点 |
|---|---|---|
| 全局执行上下文 | 脚本加载时 | 唯一,创建全局对象(window/globalThis)和 this |
| 函数执行上下文 | 函数被调用时 | 每次调用创建一个新的,包含局部变量、参数、作用域链 |
| eval 执行上下文 | eval() 执行时 | 不推荐使用,有自己的作用域 |
创建阶段与执行阶段
每个执行上下文分两个阶段:
1. 创建阶段(Creation Phase)
- 确定
this绑定 - 创建变量环境(var 声明提升为 undefined)
- 创建词法环境(let/const 声明但不初始化,进入暂时性死区 TDZ)
- 建立作用域链(当前变量环境 + 外部环境引用)
2. 执行阶段(Execution Phase)
- 逐行执行代码,赋值变量,执行函数调用
调用栈的压栈与弹栈
调用栈状态变化:
1. [全局上下文] ← 脚本加载
2. [全局上下文, one 上下文] ← 调用 one()
3. [全局上下文, one 上下文, two 上下文] ← one 中调用 two()
4. [全局上下文, one 上下文] ← two 执行完,弹出
5. [全局上下文] ← one 执行完,弹出
6. [] ← 脚本结束,全局上下文弹出
最小例子
function one() {
two();
console.log("one");
}
function two() {
console.log("two");
}
one();
执行顺序:
- 全局上下文入栈
- 调用
one,one上下文入栈 one调用two,two上下文入栈two执行完出栈 → 输出"two"- 回到
one继续执行 → 输出"one" one执行完出栈- 最后回到全局
输出:
two
one
栈溢出示例
function recursive() {
recursive(); // 无限递归
}
recursive();
// Uncaught RangeError: Maximum call stack size exceeded
V8 默认栈深度约 10000-15000 层(取决于帧大小),超出后抛出 Maximum call stack size exceeded。
执行上下文与变量查找
const global = 'global';
function outer() {
const outerVar = 'outer';
function inner() {
const innerVar = 'inner';
console.log(global); // 沿作用域链找到全局
console.log(outerVar); // 沿作用域链找到 outer
console.log(innerVar); // 当前上下文
}
inner();
}
outer();
inner 的作用域链:inner 变量环境 → outer 变量环境 → 全局变量环境。
边界与常见误解
- 调用栈 ≠ 作用域链:调用栈管理执行顺序(谁调用谁),作用域链管理变量查找(去哪找变量)。二者独立运行
- 闭包不在调用栈上:函数返回后其执行上下文从栈上弹出,但变量对象被闭包引用仍在内存中
- 尾调用优化(TCO):ES6 规范要求尾调用不增加栈深度,但目前仅 Safari 实现了 TCO
- async/await 的栈:await 暂停时栈会被清空,恢复时重新构建;调试时可能看到”断掉”的调用栈
this绑定在创建阶段确定:不是执行时动态决定,而是进入上下文时就绑定了(箭头函数除外,它继承外层this)