JavaScript 执行栈和执行上下文详解
前言
想象一下,JavaScript 引擎就像一个超级聪明的管家,它需要管理一个复杂的大宅子(你的代码)。为了不搞混乱,它建立了一套严格的管理制度——这就是执行栈(Call Stack / Execution Stack)和执行上下文(Execution Context)。
本文将用最通俗易懂的方式,帮你彻底理解这两个核心概念。
1. 执行上下文(Execution Context):代码的"房间"
什么是执行上下文?
执行上下文(Execution Context)就像是代码执行的"房间"。每当 JavaScript 要执行一段代码时,它就会准备一个专门的房间,房间里放着执行这段代码所需要的所有东西。
🏠 执行上下文就像一个房间
┌─────────────────────────┐
│ 函数foo的房间 │
│ ┌─────┐ ┌─────┐ ┌─────┐│
│ │变量 │ │函数 │ │this ││
│ │仓库 │ │仓库 │ │指针 ││
│ └─────┘ └─────┘ └─────┘│
└─────────────────────────┘
执行上下文的类型
JavaScript 中有三种"房间":
-
全局房间(全局执行上下文 - Global Execution Context)
- 🏡 就像大宅子的主厅,程序一启动就准备好
- 整个程序只有一个
- 里面放着全局变量和函数
-
函数房间(函数执行上下文 - Function Execution Context)
- 🚪 每次调用函数都会新建一个房间
- 可以有很多个
- 函数执行完毕就销毁房间
-
eval 房间(eval 执行上下文 - Eval Execution Context)
- 🚫 不推荐使用,就像危险的地下室
房间里都有什么?
每个执行上下文"房间"里都有三个重要的仓库:
执行上下文 = {
变量环境(VariableEnvironment): {
// 存放 var 声明的变量和 function 声明的函数
// 就像一个"传统仓库",东西一进来就有标签
},
词法环境(LexicalEnvironment): {
// 存放 let 和 const 声明的变量
// 就像一个"现代仓库",需要正式启用才能使用
},
this指针(this binding): {
// 告诉你"这个房间属于谁"
}
}
2. 执行栈(Call Stack / Execution Stack):房间的管理员
什么是栈?
栈(Stack)就像餐厅里叠盘子的架子:
🍽️ 叠盘子的比喻
最后放的盘子 ← 最先拿走 (栈顶 - Top)
┌─────────┐
│ 盘子3 │
├─────────┤
│ 盘子2 │
├─────────┤
│ 盘子1 │ ← 最先放的,最后拿走 (栈底 - Bottom)
└─────────┘
架子底部
栈的规则很简单:
- 📥 入栈(Push):只能从顶部放入新盘子
- 📤 出栈(Pop):只能从顶部拿走盘子
- 🔄 LIFO 原则(Last In First Out):后进先出
JavaScript 执行栈:房间的电梯
JavaScript 执行栈就像一个只能向上的电梯,管理着所有的"房间"(执行上下文):
🏢 执行栈就像一座楼
楼层4: [ foo函数房间 ] ← 当前正在这里
楼层3: [ bar函数房间 ]
楼层2: [ baz函数房间 ]
楼层1: [ 全局房间 ] ← 永远在最底层
──────────────
JavaScript引擎
关键规则:
- 🎯 同一时间只能在一个房间里工作(栈顶)
- 🏠 全局房间永远在最底层
- 📞 函数调用 = 新房间进入电梯
- ✅ 函数结束 = 房间离开电梯
3. 执行上下文的生命周期(Execution Context Lifecycle):房间的装修过程
每个"房间"的准备分为两个阶段,就像装修房子一样:
第一阶段:装修阶段(创建阶段 - Creation Phase)
在这个阶段,JavaScript 引擎是个勤劳的装修工,按照固定的顺序装修房间:
🔨 装修房间的步骤
1️⃣ 准备房间框架(创建变量对象VO - Variable Object)
┌─────────────────┐
│ 空房间准备好 │
└─────────────────┘
2️⃣ 安装门牌号(处理函数参数 - Function Parameters)
如果是函数房间,在门上写上参数名和值
参数1: x = 传入的值
参数2: y = 传入的值
3️⃣ 安装传话筒(创建arguments对象 - Arguments Object)
只有函数房间有,记录所有传入的参数
arguments = {0: 值1, 1: 值2, length: 2}
4️⃣ 安装功能区(处理函数声明 - Function Declarations)
function声明的函数 → 立即安装完整的功能
就像安装了一台完整的机器
5️⃣ 贴标签(处理变量声明 - Variable Declarations)
var声明的变量 → 先贴个"undefined"标签
let/const声明的变量 → 贴个"请勿使用"标签
🎯 装修阶段的关键点:
// 装修完成后的房间布局
房间 = {
传统仓库: {
// 1. 参数区(只有函数房间有)
参数x: 实际传入的值,
参数y: 实际传入的值,
// 2. 通讯设备(只有函数房间有)
arguments: {所有传入参数的记录},
// 3. 完整设备区
function声明的函数: 完整的函数对象, // ✅ 立即可用
// 4. 占位区
var声明的变量: undefined, // ⚠️ 只是占位
},
现代仓库: {
let声明的变量: <请勿使用>, // 🚫 暂时性死区
const声明的变量: <请勿使用>, // 🚫 暂时性死区
}
}
💡 记忆口诀:
"参数 arguments 函数 var,let 和 const 要等等"
第二阶段:入住阶段(执行阶段 - Execution Phase)
装修完成后,就开始真正使用房间了:
🏠 入住阶段的变化
装修时:
┌─────────────────┐
│ var a = undefined │ ← 只是占位
│ let b = <请勿使用> │ ← 不能碰
│ function foo(){} │ ← 已经可用
└─────────────────┘
入住后:
┌─────────────────┐
│ var a = 10 │ ← 真正赋值了!
│ let b = 20 │ ← 现在可以用了!
│ function foo(){} │ ← 保持不变
└─────────────────┘
🔄 执行阶段做什么:
- 📝 逐行执行代码
- 🏷️ 给变量贴上真正的值
- 📞 调用函数(创建新房间)
4. 生动实例:一次完整的房间管理
让我们通过一个生动的例子,看看 JavaScript 管家是如何工作的:
// 👇 下面是我们要分析的代码
console.log('程序开始啦!')
var a = 10
let b = 20
const c = 30
function foo(x, y) {
console.log('进入foo房间')
console.log(arguments) // 查看传话筒记录
var m = 100
let n = 200
function bar() {
console.log('进入bar房间')
console.log(x, y, m, n) // 可以看到外面房间的东西
}
var bar2 = function () {
console.log('这是个后装的设备')
}
bar() // 创建bar房间
console.log('离开foo房间')
}
foo(1, 2, 3) // 创建foo房间,传入3个参数
console.log('程序结束')
🏠 全局房间的装修过程
装修阶段(创建阶段):
全局房间 = {
传统仓库: {
a: undefined, // var变量,先占位
foo: function foo(x,y){...} // 函数声明,立即安装
},
现代仓库: {
b: <请勿使用>, // let,暂时性死区
c: <请勿使用> // const,暂时性死区
}
}
入住阶段(执行阶段):
全局房间 = {
传统仓库: {
a: 10, // 执行 a = 10 后
foo: function foo(x,y){...} // 保持不变
},
现代仓库: {
b: 20, // 执行 let b = 20 后
c: 30 // 执行 const c = 30 后
}
}
🚪 foo 函数房间的装修过程
当调用 foo(1, 2, 3) 时,管家开始装修 foo 房间:
装修阶段(创建阶段):
foo房间 = {
传统仓库: {
// 1️⃣ 安装门牌号(处理参数)
x: 1, // 第1个参数
y: 2, // 第2个参数
// 2️⃣ 安装传话筒(arguments对象)
arguments: {
0: 1, // 第1个参数
1: 2, // 第2个参数
2: 3, // 第3个参数(多出来的)
length: 3 // 总共3个参数
},
// 3️⃣ 安装功能设备(函数声明)
bar: function bar(){...}, // 立即安装完成
// 4️⃣ 贴占位标签(变量声明)
m: undefined, // var变量占位
bar2: undefined, // 函数表达式也是var,所以只占位
},
现代仓库: {
n: <请勿使用> // let变量,暂时性死区
}
}
入住阶段(执行阶段):
foo房间 = {
传统仓库: {
x: 1, // 参数保持不变
y: 2, // 参数保持不变
arguments: {0:1, 1:2, 2:3, length:3}, // 保持不变
bar: function bar(){...}, // 函数声明保持不变
m: 100, // 执行 var m = 100 后
bar2: function(){...}, // 执行函数表达式赋值后
},
现代仓库: {
n: 200 // 执行 let n = 200 后
}
}
5. 执行栈的运作:电梯的上上下下
让我们看看执行栈这个"电梯"是如何运作的:
📈 执行栈的变化过程
第1步:程序启动
┌─────────────────┐
│ 全局房间 │ ← 管家在这里工作
└─────────────────┘
第2步:调用 foo(1,2,3)
┌─────────────────┐
│ foo房间 │ ← 管家跑到这里工作
├─────────────────┤
│ 全局房间 │ ← 在下面等待
└─────────────────┘
第3步:在foo中调用 bar()
┌─────────────────┐
│ bar房间 │ ← 管家现在在这里
├─────────────────┤
│ foo房间 │ ← 等待中
├─────────────────┤
│ 全局房间 │ ← 在最下面等待
└─────────────────┘
第4步:bar执行完毕
┌─────────────────┐
│ foo房间 │ ← 管家回到这里继续
├─────────────────┤
│ 全局房间 │ ← 在下面等待
└─────────────────┘
💨 bar房间被销毁了
第5步:foo执行完毕
┌─────────────────┐
│ 全局房间 │ ← 管家回到全局房间
└─────────────────┘
💨 foo房间也被销毁了
🔑 关键理解:
- 🎯 管家同时只能在一个房间工作(栈顶)
- 📞 函数调用 = 新房间入栈
- ✅ 函数返回 = 房间出栈并销毁
- 🏠 全局房间永远在最底层
6. 变量提升(Variable Hoisting)的真相:装修工的提前规划
现在你知道了房间装修的过程,就能理解变量提升的真相了!
🔍 现象观察
// 👀 看起来很奇怪的现象
console.log(a) // undefined(没报错!)
console.log(b) // 报错:Cannot access 'b' before initialization
console.log(foo) // 输出:function foo(){...}(完整函数!)
console.log(bar) // undefined(不是函数!)
var a = 5
let b = 10
function foo() {
return 'function declaration'
}
var bar = function () {
return 'function expression'
}
🔨 真相解释
其实是装修工(JavaScript 引擎)提前做了规划:
🏗️ 装修阶段的提前规划
装修工扫描代码:
"我看到了 var a、let b、function foo、var bar"
装修工的安排:
┌─────────────────────────────┐
│ 传统仓库区域 │
│ a: undefined (占位) │
│ foo: function (完整安装) │
│ bar: undefined (占位) │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 现代仓库区域 │
│ b: <请勿使用> (未启用) │
└─────────────────────────────┘
💡 这就是为什么:
var a→ 装修阶段占位为 undefined,所以能访问但值是 undefinedlet b→ 装修阶段未启用,所以不能访问function foo→ 装修阶段完整安装,所以能正常使用var bar→ 装修阶段只占位,函数表达式要等入住后才安装
🎯 记忆技巧
装修工的口诀:
"函数声明我先装,var 变量占个位,
let 和 const 要等等,表达式后来装"
7. 面试攻略:高频问题解答
🎯 核心概念速记
📚 知识卡片
执行上下文(Execution Context) = 代码执行的房间
执行栈(Call Stack/Execution Stack) = 管理房间的电梯
变量对象(Variable Object) = 房间里的仓库
生命周期(Lifecycle) = 装修阶段(Creation Phase) + 入住阶段(Execution Phase)
🔥 高频面试题
Q1: 什么是变量提升(Variable Hoisting)?
标准答案: 变量提升(Variable Hoisting)是执行上下文创建阶段的表现。就像装修工提前规划房间布局一样,JavaScript 引擎会按照"参数 → arguments → 函数声明 → 变量声明"的顺序预处理代码。函数声明会被完整"安装",var 变量只是"占位"为 undefined。
Q2: let/const 为什么有暂时性死区(Temporal Dead Zone)?
标准答案: let/const 就像需要"预约启用"的现代设备,在装修阶段被标记为"请勿使用"状态,只有执行到声明语句时才正式启用。这个"请勿使用"期间就是暂时性死区(Temporal Dead Zone,简称 TDZ)。
Q3: 函数调用时发生了什么?
标准答案: 就像新建一个房间的完整流程:
- 创建新房间(函数执行上下文)
- 装修房间(创建阶段:处理参数、arguments、函数声明、变量声明)
- 房间入栈(推入执行栈顶)
- 入住使用(执行阶段:逐行执行代码)
- 房间出栈并销毁(函数执行完毕)
Q4: arguments 对象有什么特点?
标准答案: arguments 就像函数房间的"传话筒",记录了所有传入的参数:
- 包含所有实参(即使超过形参数量)
- 有 length 属性表示参数个数
- 与形参建立映射关系(非严格模式)
- 只存在于函数执行上下文中
Q5: 函数声明(Function Declaration)和函数表达式(Function Expression)的区别?
标准答案:
- 函数声明(Function Declaration):装修阶段就完整安装,可以在声明前调用
- 函数表达式(Function Expression):装修阶段只占位,入住阶段才安装,必须在赋值后调用
Q6: 为什么会栈溢出(Stack Overflow)?
标准答案: 就像电梯超载一样,当递归调用太深或无限递归时,执行栈会堆积太多房间超过最大容量限制,导致栈溢出错误(Stack Overflow Error)。
🧠 记忆宝典
核心记忆点:
- 执行栈(Call Stack / Execution Stack):后进先出的电梯,管理所有房间
- 装修阶段(Creation Phase):参数 → arguments → 函数声明 → 变量声明
- 入住阶段(Execution Phase):逐行执行,变量真正赋值
- 变量对象(Variable Object):房间的仓库,存储所有标识符
- var:装修时占位 undefined
- let/const:装修时暂时性死区(TDZ)
- 函数声明(Function Declaration):装修时完整安装
- 函数表达式(Function Expression):装修时占位,入住时安装
结语
通过"房间管理"的比喻,我们彻底理解了 JavaScript 执行栈和执行上下文的工作原理:
- **执行上下文(Execution Context)**就像代码执行的专用房间
- **执行栈(Call Stack / Execution Stack)**就像管理房间的电梯系统
- **变量提升(Variable Hoisting)**就像装修工的提前规划
- **函数调用(Function Call)**就像新建房间的完整流程
掌握了这些核心概念,你就能轻松理解变量提升、作用域(Scope)、闭包(Closure)等 JavaScript 的高级特性。无论是日常开发还是面试,这些知识都会让你游刃有余!
🎯 最后的建议: 不要死记硬背概念,而要理解背后的"房间管理"逻辑。当你能用自己的话解释给别人听时,你就真正掌握了这些知识!