JS 代码是怎么跑起来的:执行上下文、变量提升与调用栈
一、从一段"反直觉"的代码说起
javascript
showName()
console.log(myname)
var myname = '张三'
function showName() {
console.log('函数showName执行')
}
第一次看这段代码,直觉是报错------showName 和 myname 都在使用之后才声明,怎么可能正常运行?
实际运行结果:
javascript
函数showName执行
undefined
showName() 正常调用了,myname 没有报错,输出的是 undefined。
这就引出了今天的核心问题:JS 引擎在执行代码之前,到底做了什么?
二、先编译,再执行
JS 不是逐行解释执行的,V8 引擎在真正跑代码之前,会先做一次编译 。这个编译过程发生在执行的前一刻,目的是生成执行上下文(Execution Context)。
流程是这样的:
读取代码
↓
编译阶段(生成执行上下文)
↓
执行阶段(从上到下跑代码)
执行上下文可以理解为一个对象,里面装了代码运行需要的所有环境信息:
kotlin
执行上下文 {
变量环境 ← 存 var 声明的变量 和 函数声明
词法环境 ← 存 let / const 声明的变量
this ← this 指向谁
outer ← 作用域链,找不到变量往哪找
}

三、变量提升 vs 函数提升
编译阶段做的核心工作,就是提升------把声明整理出来,提前存入执行上下文。
但变量提升和函数提升,行为是不一样的。
3.1 变量提升(var)
var 声明的变量,编译阶段会把变量名 提升,初始值设为 undefined,赋值留在原地等执行阶段处理。
javascript
// 开发者写的
var myname = '张三'
// V8 引擎眼里的执行顺序
var myname // 编译阶段:提升声明,值为 undefined
myname = '张三' // 执行阶段:赋值才发生
所以 console.log(myname) 在赋值之前执行,拿到的就是 undefined,而不是报错。
3.2 函数提升
函数声明的提升比变量"彻底"得多------整个函数体都会在编译阶段存入变量环境,不只是名字。
javascript
// V8 眼里,函数声明被整体提升
function showName() {
console.log('函数showName执行')
}
// 所以调用在声明之前也没问题
showName()
3.3 函数声明 vs 变量声明,谁优先?
javascript
console.log(func) // 输出函数体,不是 undefined
function func(name) {}
var func = '123'
同一个名字,既有函数声明又有变量声明,编译阶段函数声明优先 覆盖变量声明。func 在变量环境里存的是函数对象,不是 undefined。
赋值 func = '123' 发生在执行阶段,所以 console.log 打印出来是函数体。
3.4 let / const:不提升
javascript
console.log(b) // ReferenceError
let b = 9
let 和 const 声明的变量存在词法环境 里,编译阶段不做提升,也不给初始值。在声明语句执行之前访问,直接报 ReferenceError。
这段时间(从进入作用域到声明语句执行)叫做暂时性死区(TDZ,Temporal Dead Zone)。
| 声明方式 | 存放位置 | 编译阶段处理 | 访问未初始化时 |
|---|---|---|---|
var |
变量环境 | 提升,值为 undefined |
返回 undefined |
| 函数声明 | 变量环境 | 整体提升,值为函数对象 | 正常访问 |
let / const |
词法环境 | 不提升,TDZ | ReferenceError |
四、一道题,把提升规则都用上
javascript
var a = 1
function fn(a) {
var a = 2
function a() {}
var b = a
console.log(a)
}
fn(3)
输出什么?
分析 fn 的编译阶段,V8 生成 fn 的执行上下文:
第一步:处理形参
css
变量环境: { a: undefined }
第二步:统一形参和实参
css
变量环境: { a: 3 } ← 实参是 3
第三步:找 var 声明 var a = 2 中 a 已存在,跳过(不覆盖)。
第四步:找函数声明 function a() {} 函数声明优先,覆盖 a:
css
变量环境: { a: [Function: a] }
进入执行阶段:
javascript
var a = 2 // a 被赋值为 2
function a() {} // 函数声明已在编译阶段处理,跳过
var b = a // b = 2
console.log(a) // 2
输出:2。
这道题把"形参统一、var 提升、函数提升、执行阶段赋值"全部串联起来了,每个细节都有对应规则。
五、var 的块级作用域问题
javascript
function varTest() {
var x = 1
if (true) {
var x = 2 // 同一个 x,不是新的
console.log(x) // 2
}
console.log(x) // 2
}
var 不支持块级作用域,if 块里的 var x = 2 修改的是函数作用域里那个唯一的 x,出了 if 块依然是 2。
换成 let:
javascript
function varTest() {
var x = 1
if (true) {
let x = 2 // 独立的块级作用域变量
console.log(x) // 2
}
console.log(x) // 1 ← 外层的 x 没被动
}
let 的 x 存在词法环境里,块级作用域结束后销毁,外层的 x 完好无损。
六、调用栈:V8 怎么管理函数调用
6.1 调用栈是什么
调用栈(Call Stack)是 V8 用来追踪当前在执行哪个函数的一种数据结构,后进先出(LIFO)。
每次进入一段可执行代码(全局代码或函数),V8 就会生成一个执行上下文,压入调用栈。函数执行完毕,对应的执行上下文从栈顶弹出销毁。
6.2 全局代码的执行上下文
程序启动时,V8 先创建全局执行上下文压栈:
css
调用栈:
┌─────────────────────┐ ← 栈顶
│ 全局执行上下文 │
│ 变量环境: { │
│ varTest: fn │
│ } │
│ 词法环境: {} │
└─────────────────────┘
6.3 调用函数时
调用 varTest() 时,V8 编译 varTest 函数体,生成 varTest 的执行上下文,压入栈顶:
css
调用栈:
┌─────────────────────┐ ← 栈顶(当前执行这里)
│ varTest 执行上下文 │
│ 变量环境: { │
│ x: undefined │
│ } │
│ 词法环境: {} │
│ outer: 全局上下文 │
├─────────────────────┤
│ 全局执行上下文 │
└─────────────────────┘

6.4 函数执行完毕
varTest 执行结束,它的执行上下文从栈顶弹出销毁,控制权回到全局:
makefile
调用栈:
┌─────────────────────┐ ← 栈顶
│ 全局执行上下文 │
└─────────────────────┘
函数里的局部变量随着执行上下文的销毁一并回收,这就是局部变量生命周期的底层原因。
6.5 编译发生在执行的前一刻
有一个细节值得注意:全局代码的编译在程序启动时完成,函数的编译在被调用的前一刻才发生。
函数嵌套越深,调用栈越高。如果递归没有终止条件,调用栈会一直增长,最终触发 Maximum call stack size exceeded(栈溢出)。
七、词法环境与块级作用域的实现
let / const 的块级作用域,在引擎层面也是通过词法环境的栈来实现的。
每进入一个 {} 块,词法环境就会新建一层,存放这个块里的 let / const 变量;块结束,这一层销毁。
javascript
function varTest() {
var x = 1 // → 变量环境
if (true) {
let x = 2 // → 词法环境(新建一层)
// 此时词法环境栈顶有 x = 2
}
// if 块结束,词法环境那一层弹出,x = 2 消失
console.log(x) // 从变量环境里找,x = 1
}
调用栈管理执行上下文,词法环境内部的栈管理块级作用域,两套机制配合,撑起了 JS 的作用域体系。
八、总结
今天学的东西,核心是搞清楚 JS 代码执行的完整流程:
javascript
读取代码
↓
编译阶段
├── 创建执行上下文
├── 变量环境:var 声明提升(undefined)、函数声明整体提升
├── 词法环境:let/const 登记但不初始化(TDZ)
└── 确定 this 和 outer(作用域链)
↓
执行阶段
├── 代码从上到下顺序执行
├── 赋值在执行阶段发生
└── 函数调用 → 生成新执行上下文 → 压入调用栈
↓
函数执行完毕 → 执行上下文弹出销毁
几个容易混的点,整理清楚:
变量提升 ≠ 函数提升 :变量只提升声明,初始值是 undefined;函数声明整体提升,可以在声明之前正常调用。
函数声明优先于变量声明:同名时,编译阶段函数声明会覆盖变量声明。
let/const 有 TDZ:进入作用域到声明语句之间不能访问,这是刻意设计的,让错误提前暴露。