来看一段代码,你猜输出什么?
js
showName('极客时间');
console.log(myname);
var myname = '张三';
function showName(name) {
var b = 1;
console.log('函数showName 执行');
}
如果你觉得第一行就会报错------能理解,但错了。这段代码正常运行,输出 '函数showName 执行' 然后 undefined。
这不是魔法。这是 JS 执行机制的外在表现。
第一层:变量提升只是表象
大多数教程这样解释:var 和 function 声明会被"提升"到作用域顶部。所以上面那段代码在 V8 眼里等价于:
js
var myname; // 变量提升
function showName() { // 函数提升
var b;
b = 1;
console.log('函数showName 执行');
}
showName();
console.log(myname); // undefined
myname = '张三';
但"提升"只是一个比喻。代码没有被物理移动------任何一个 JS 引擎都不会改动你的源码。 真正发生的事情,在你看不到的地方。
第二层:编译总在执行前一刻发生
JS 不是纯解释型语言。V8 的执行流水线是:

编译阶段做四件事:
- 创建执行上下文对象
- 找形参和变量声明(
var),将它们作为 key 存入变量环境,值初始化为undefined - 统一形参和实参的值(全局作用域没有这一步)
- 找函数声明,将函数名作为 key,值为函数体,存入变量环境
注意这个顺序。第 4 步会覆盖 第 2 步中同名 key 的值------这就是为什么函数声明优先级高于 var:
js
console.log(func); // ƒ func() {}
function func() {}
var func = '123';
输出的是函数体,不是 undefined。因为编译阶段,函数声明的处理晚于 var,它覆盖了 undefined。
所以呢? "变量提升"不是行为本身,而是编译阶段的编译产物。真正需要理解的概念是执行上下文。
第三层:执行上下文里到底有什么
执行上下文不是一个抽象概念,它有明确的结构:

javascript
(image)
执行上下文
├── 变量环境 (Variable Environment)
│ └── function, var 声明的变量存在这里
├── 词法环境 (Lexical Environment)
│ └── let, const 声明的变量存在这里
└── 待执行代码
└── 从上到下,顺序执行
看这个例子:
js
function varTest() {
var x = 1; // 变量环境
if (true) {
let x = 2; // 词法环境(块级作用域)
console.log(x); // 2
}
console.log(x); // 1
}
varTest();

编译完成后,var x 在变量环境里,值为 undefined。执行到 var x = 1 时赋值。
进入 if 块时,词法环境里压入一个新栈帧,存放 let x = 2。块内的 console.log(x) 查找变量时,先从词法环境栈顶往下找,找不到再去变量环境 。找到 x = 2,输出 2。
离开 if 块,该栈帧弹出。最后的 console.log(x) 在词法环境中找不到 x,去变量环境找,输出 1。
这就是 let/const 块级作用域的实现原理------词法环境是一个栈结构,进出块就是在压栈弹栈。
根因:一张图画完全貌
来看一个更复杂的例子,把所有概念串起来:
js
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a); // 1
console.log(b); // 3
}
console.log(b); // 2
console.log(c); // 4
console.log(d); // ReferenceError: d is not defined
}
foo();
执行过程逐帧拆解:

进入 foo 函数体的编译阶段:
- 变量环境:
{ a: undefined, c: undefined }--- 注意c是var,不受块限制,直接提到函数作用域 - 词法环境:
[ { b: <uninitialized> } ]---let b在函数作用域,处于 TDZ
执行 var a = 1; let b = 2; 后:
- 变量环境:
{ a: 1, c: undefined } - 词法环境:
[ { b: 2 } ]
进入块,词法环境压入新栈帧(编译块内声明,尚未执行赋值):
- 变量环境:
{ a: 1, c: undefined }---c是var,早已在函数作用域 - 词法环境:
[ { b: <uninitialized>, d: <uninitialized> }, { b: 2 } ]--- 块级let进入 TDZ
逐行执行块内代码:
let b = 3→ 词法环境栈顶的b赋值为 3var c = 4→ 变量环境的c赋值为 4let d = 5→ 词法环境栈顶的d赋值为 5
三条赋值语句执行完毕后:
- 变量环境:
{ a: 1, c: 4 } - 词法环境:
[ { b: 3, d: 5 }, { b: 2 } ]
接着 console.log(b) 从栈顶查找,找到 b = 3。console.log(a) 在词法环境中找不到,去变量环境找,找到 a = 1。
离开块后,栈顶弹出:
- 变量环境:
{ a: 1, c: 4 }---c = 4在块内执行,但因为它是var,存在于变量环境 - 词法环境:
[ { b: 2 } ]--- 块级b和d随栈帧一起消失
最后的 console.log(d) 在词法环境和变量环境中都找不到,抛出 ReferenceError。
调用栈:这一切的容器
调用栈是 V8 管理函数调用关系的数据结构。栈顶指针指向当前正在执行的函数(或全局)。
erlang
调用栈
│
├── 全局执行上下文 ← 永远在栈底
├── foo 执行上下文
├── bar 执行上下文 ← 栈顶(当前执行)
...
规则很清晰:
- 编译总在执行前一刻发生------全局和每个函数体都是独立编译的
- 每个函数编译后生成一个执行上下文,压入调用栈
- 函数执行完毕,它的执行上下文弹出并销毁
补充:为什么 let/const 有"暂时性死区"
let 和 const 在编译阶段也会被处理------它们进入词法环境,但不会被初始化为 undefined 。从进入作用域到实际执行声明语句之间的区域,变量处于 "未初始化" 状态,任何访问都会抛出 ReferenceError。这就是暂时性死区(Temporal Dead Zone)。
加上不可重复声明------这两条规则本质上都是在修补 var 的历史设计缺陷。理解了编译过程,这些"新规则"就不再需要死记硬背。
总结
回到开头那句话:JS 不是逐行解释执行的。它在执行前总有一道编译工序。 这道工序的产物就是执行上下文------变量环境管 var/function,词法环境管 let/const,词法环境的栈结构就是块级作用域的实现。
下次遇到变量行为的面试题,不用回忆"那道题的答案是什么"------画一下执行上下文的结构图,答案自己会走出来。