引言
假设你写下这样一段"看起来写错了"的代码:
showName();
console.log(name);
var name = "why";
function showName() {
console.log("函数被执行了");
}
如果你第一次接触 JavaScript,你可能会想:showName() 在声明之前调用,不应该报错吗?console.log(name) 在 var name = "why" 之前,难道不会出问题?
实际上,这段代码完美运行——输出 "函数被执行了" 和 undefined,没有任何报错。
而在 V8 引擎的眼里,这段代码其实是长这样的:
// ═══════ 以下是编译阶段的结果 ═══════
var name; // var 声明提升,初始化为 undefined
function showName() { // 函数声明整体提升(名字 + 函数体一起)
console.log("hello world");
}
// ═══════ 以下是执行阶段 ═══════
showName(); // ✅ "hello world"
console.log(name); // ✅ undefined
name = "lxk"; // 赋值操作留在原地
同样的逻辑,再看一个更复杂的例子:
function outer() {
var a = 1;
let b = 2;
function inner() {
var c = 3;
let d = 4;
console.log(a);
console.log(b);
}
inner();
console.log(c); // ❌ ReferenceError: c 在 inner 作用域内,outer 访问不到
}
outer();
当这些代码运行时,JavaScript 引擎内部到底发生了什么?调用栈是如何管理的?变量到底存在哪里?作用域链又是如何构建的?
这篇文章将从最宏观的调用栈机制讲起,一路深入到 ES6 规范中最精妙的双环境结构,用丰富的代码实例彻底拆解 JavaScript 的执行模型。
第一层:调用栈与执行上下文 —— 容器与内容
概念速览
在深入细节之前,先建立一个宏观认知:
- 执行上下文对象包含三个核心组件:变量环境、词法环境、可执行代码。
- 调用栈(执行栈) 是 V8 引擎用来管理函数之间调用关系的数据结构。
- 栈顶指针永远指向当前正在执行的函数(或全局代码)。
- 函数调用 → 创建新的执行上下文 + 压入栈顶;函数返回 → 上下文弹出 + 销毁。
调用栈是什么?
调用栈是一个后进先出的数据结构,用来管理函数的调用顺序。它就像一个便签盒:
- 调用一个函数,就把一张新的便签(执行上下文)放进去。
- 函数执行完毕,就把这张便签拿出来扔掉。
- 永远只有最上面那张便签是"当前正在执行的"。
执行上下文是什么?
执行上下文是 JavaScript 代码运行时的完整环境信息,包含:
- 变量环境:专门存放
var声明的变量。 - 词法环境:专门存放
let、const声明以及管理块级作用域。 this绑定:当前函数的this指向。- 外部引用:指向外层作用域的"链"。
它们的关系
调用栈是物理容器,执行上下文是存在里面的内容。 调用栈决定了"谁在运行",执行上下文决定了"运行时能访问什么"。
第二层:编译与执行 —— 一段代码的两种阶段
很多开发者误以为 JavaScript 是纯粹的一行一行解释执行。实际上,引擎对每个可执行代码块(全局代码、函数体)都执行**"先编译,后执行"**的流程。
一个直观的例子
先看这段原始代码:
showName(); // ← 函数还没声明,居然能调用?
console.log(name); // ← 变量还没声明,会报错吗?
var name = "why";
function showName() {
console.log("函数被执行了");
}
运行结果:
函数被执行了
undefined
接下来是 V8 引擎编译之后、执行之前,代码在逻辑上的等价形式:
// ═══════ 以下是编译阶段的结果 ═══════
var name; // var 声明提升,初始化为 undefined
function showName() { // 函数声明整体提升(名字 + 函数体一起)
console.log("hello world");
}
// ═══════ 以下是执行阶段 ═══════
showName(); // 执行:showName 已经是完整函数 → "hello world"
console.log(name); // 执行:name 存在但未赋值 → undefined
name = "lxk"; // 执行:赋值操作留在原地
核心洞察:
var name = "why"这一行代码,在引擎眼里是两件事——编译阶段处理var name(声明 + 初始化为undefined),执行阶段处理name = "why"(赋值)。声明和赋值被拆开,这就是"变量提升"的本质。
编译阶段
引擎一次性扫描整个代码块,找出所有的变量声明和函数声明,并在相应的环境中创建标识符:
var声明:在变量环境中创建该变量,初始化为undefined。let/const声明:在词法环境中创建该变量,但不初始化,进入"暂时性死区"。function声明:视位置而定(详见后文),但都会整体提升。
执行阶段
编译完成后,引擎拿到创建好的执行上下文,从上到下一行一行地执行代码,进行赋值、运算等操作。
关键纠正
"一行一行执行编译"这个说法是错误的。 编译是对当前整个代码块的一次性扫描;执行才是逐行的。遇到函数调用时,会针对函数体开启新一轮的编译-执行周期。
第三层:作用域的本质 —— 地盘、创建与查找
作用域是什么?
作用域是一套规则,规定了引擎如何在当前环境及外层环境中查找变量。你可以把它理解为一个管理变量可见性的"地盘"。
JavaScript 有三种作用域:
- 全局作用域:顶级地盘,页面关闭才销毁。
- 函数作用域:
var的地盘,函数调用时创建,返回时销毁。 - 块级作用域:
let/const的精确地盘,由花括号{ }创建,进入时创建,离开时销毁。
var vs let:两段代码看清函数作用域与块级作用域
下面两段代码结构几乎一模一样,唯一的区别是 if 块里用 var 还是 let,结果却截然不同。
var 版本(无视块):
function vartest() {
var x = 1;
if (true) {
var x = 2; // ← 和外面的 var x 是同一个变量!
console.log(x); // 2
}
console.log(x); // 2 ← 块内的赋值"泄露"到了块外
}
vartest();
输出:
2
2
let 版本(尊重块):
function vartest() {
var x = 1;
if (true) {
let x = 2; // ← 这是一个全新的变量,和外层的 x 无关
console.log(x); // 2
}
console.log(x); // 1 ← 外层 x 不受块内 let 的影响
}
vartest();
输出:
2
1
核心洞察:
var的最小作用域单位是函数,{ }对它形同虚设。let的最小作用域单位是块,每个{ }都是一个新的边界。理解了这两段代码的区别,就理解了 JavaScript 两种作用域模型的核心差异。
作用域什么时候创建?
需要区分"地图"和"实体":
- 词法作用域:在编译/写代码时就已经静态确定,是嵌套关系的"地图"。
- 作用域实体:在运行时创建。全局上下文在脚本开始时创建;函数上下文在函数被调用时创建;块级作用域在执行进入该块时创建。
变量查找规则
当引擎执行代码遇到一个变量名时,查找顺序是:
- 先查当前执行上下文的词法环境栈,从栈顶(最内层块)逐层往外找。
- 词法环境链的末端链接到变量环境(
var声明和函数声明在这里)。 - 还找不到,沿着变量环境的
[[OuterEnv]]跳转到更外层作用域。 - 在外层重复上述流程(词法环境 → 变量环境 → 更外层),直到全局作用域,找到返回,找不到抛
ReferenceError。
注意: 词法环境和变量环境不是两个平行的查找路径,而是通过
[[OuterEnv]]串联起来的一条链——词法环境在链条上游,变量环境在下游。
第四层:双环境结构 —— ES6 最核心的设计
这是本文最重要的部分。在 ES6 中,一个执行上下文内部同时存在两个环境组件:
从简单到复杂:双环境入门
在深入复杂例子之前,先用上面两段 var / let 对比代码来理解"双环境"是如何工作的。
只有 var 时,两个环境指向同一处:
vartest 执行上下文:
变量环境 ──┐
├──→ { x: 1 → 2 } (同一个环境记录)
词法环境 ──┘
过程:
var x = 1 → 环境记录中 x = 1
进入 if 块 → 没有 let/const,不创建新的词法环境
var x = 2 → 同一个 x,改为 2
离开 if 块 → 无事发生
console.log → 2
有 let 时,词法环境分叉:
vartest 执行上下文:
变量环境 ──────→ { x: 1 } (var 的地盘)
↑ outer
词法环境 ──────→ { 函数词法环境 } → 进入块时: { x: 2 } (块级词法环境压入栈顶)
↓ 离开块时弹出销毁
过程:
var x = 1 → 变量环境 x = 1
进入 if 块 → 创建块级词法环境 { x: TDZ },推入词法环境栈顶
let x = 2 → 块级词法环境 x = 2
console.log → 查词法栈顶 → 块级环境有 x=2 → 输出 2
离开 if 块 → 块级词法环境弹出销毁
console.log → 查词法栈顶 → 函数词法环境没有 x → 变量环境 x=1 → 输出 1
变量环境
- 专门收容
var声明的变量。 - 功能单一,无视块级作用域。
- 变量在编译阶段初始化为
undefined。
词法环境
- 专门处理
let、const声明以及块级作用域。 - 它是一个栈结构:进入新块,新环境推入栈顶;离开块,栈顶环境弹出销毁。
- 变量在编译阶段创建但不初始化。
两个环境的初始关系
在函数调用时,变量环境和词法环境最初指向同一个环境记录,并不是一开始就独立存在:
函数调用,创建执行上下文:
变量环境 ──┐
├──→ 同一个声明式环境记录 (Declarative Environment Record)
词法环境 ──┘
如果函数体内存在 let/const 声明:
词法环境 → 新的声明式环境记录 → outer指向 → 变量环境(原来的那个)
如果函数体内只有 var(没有 let/const):
变量环境和词法环境始终是同一个,本质没有区别
也就是说,词法环境只在有
let/const声明时才从变量环境"分叉"出来,把变量环境包在[[OuterEnv]]链条的下游。两个环境的关系是"一条链上的上下游",不是两个平行的盒子。
复杂实战演示
理解了基础之后,来看一个混合了 var、let、块级作用域的复杂例子:
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a); // A
console.log(b); // B
}
console.log(b); // C
console.log(c); // D
console.log(d); // E
}
foo();
编译阶段(foo 函数体):
- 变量环境:
a: undefined,c: undefined(var c无视块,直接提升到这里) - 词法环境:
- 函数词法环境:
b: TDZ - 编译器同时生成了"进入块时创建块级词法环境"的指令,但此时块级词法环境尚不存在。只有在运行时执行进入
{ }时,才会创建该环境,并在其中绑定b: TDZ、d: TDZ
- 函数词法环境:
执行阶段:
| 步骤 | 动作 | 输出 |
|---|---|---|
var a = 1 | 变量环境 a = 1 | |
let b = 2 | 函数词法环境 b = 2 | |
| 进入块 | 创建块级词法环境,栈顶推入 | |
let b = 3 | 块级词法环境 b = 3 | |
var c = 4 | 变量环境 c = 4 | |
let d = 5 | 块级词法环境 d = 5 | |
A:console.log(a) | 词法栈(块→函数)都没 a → 变量环境找到 1 | 1 |
B:console.log(b) | 词法栈顶(块)找到 3 | 3 |
| 离开块 | 块级词法环境销毁,b=3 和 d=5 消失 | |
C:console.log(b) | 函数词法环境找到 2 | 2 |
D:console.log(c) | 词法环境没有 → 变量环境找到 4 | 4 |
E:console.log(d) | 词法环境没有 → 变量环境没有 → 全局没有 → 报错 | ❌ ReferenceError |
第五层:函数声明的特殊待遇
function 声明在不同位置的待遇完全不同,这是 ES6 为了兼容旧规范和采纳新规范达成的精巧妥协。
实例分析:当形参、var、function 三者相遇
下面这段代码短短几行,却同时涉及形参传递、var 提升、函数声明提升三个机制:
var a = 1;
function fn(a) {
console.log(a); // A:输出什么?
var a = 2;
function a() {}
var b = a;
console.log(a); // B:输出什么?
}
fn(3);
分步解析:
① 全局编译:
- 变量环境:
a: undefined - 函数
fn整体提升
② 全局执行:
a = 1- 调用
fn(3)
③ fn(3) 编译阶段(关键!):
按照编译的优先级顺序处理:
- 形参:
a = 3(实参已传入,形参与实参统一) var a声明:变量环境中已有a(来自形参),var a的声明被忽略(不覆盖形参的值)function a() {}声明:函数声明优先级最高,覆盖变量环境中a的值为函数体
编译结束后,变量环境中 a 的值是 function a() {}。
④ fn(3) 执行阶段:
| 步骤 | 动作 | 结果 |
|---|---|---|
A:console.log(a) | 变量环境 a = function a() {} | 输出 [Function: a] |
var a = 2 | 声明已在编译阶段处理,这里只执行赋值 a = 2 | a 变为 2 |
function a() {} | 编译阶段已处理,执行阶段跳过 | |
var b = a | b = 2 | |
B:console.log(a) | a = 2 | 输出 2 |
输出结果:
[Function: a]
2
核心洞察:在函数体内部的编译阶段,优先级是 函数声明 > 形参 > var 声明。函数声明会把同名形参和 var 变量全部"覆盖"掉。如果你不知道
function a(){}在编译阶段就已经把a变成了函数,你一定会对第一个console.log的输出感到困惑。
总结对比
| 声明位置 | var 变量 | let/const 变量 | 函数声明 |
|---|---|---|---|
| 全局 | 变量环境,初始化为 undefined | 词法环境,TDZ | 变量环境,初始化为完整函数 |
| 函数体内部 | 变量环境,初始化为 undefined | 词法环境,TDZ | 变量环境,初始化为完整函数 |
块级 { } 内部 | 变量环境,初始化为 undefined | 词法环境,TDZ | 两边都有:变量环境为 undefined,块级词法环境为完整函数 |
块内函数声明的双栖机制
function test() {
console.log(typeof bar); // "undefined"
if (true) {
function bar() { return 1; }
}
console.log(typeof bar); // "function"
}
test();
- 编译时,变量环境创建
bar: undefined,保证声明前访问不报错。 - 进入
if块时,块级词法环境创建bar: function。 - 离开
if块后,这个函数赋值影响到了外层的bar。
这就是双环境栖身的真相。
补充:let 与 function 的声明冲突
ES6 引入了 let/const 后,增加了一条规则:同一作用域内不允许重复声明。来看一个例子:
// 情况1:let 不能重复声明自身
// let a = 1;
// let a = 2; // ❌ SyntaxError: Identifier 'a' has already been declared
// 情况2:let 和 function 声明冲突
let a = 1;
function a() { // ❌ SyntaxError: Identifier 'a' has already been declared
console.log(a);
}
而在 var 时代,这是允许的——函数声明会覆盖 var 变量:
console.log(a); // [Function: a]
var a = 1;
function a() {
console.log(a);
}
核心洞察:
var+function可以共存(函数声明覆盖 var),但let/const+function在同一作用域中直接报错。这反映了 ES6 对语言一致性的修复——既然let不能重复声明,和function放在一起也应该报错。
第六层:嵌套函数与作用域链
function outer() {
var a = 1;
let b = 2;
function inner() {
var c = 3;
let d = 4;
console.log(a); // X
console.log(b); // Y
}
inner();
console.log(c); // Z
}
outer();
调用栈变化
- 全局上下文入栈。
outer()调用,outer上下文入栈。inner()调用,inner上下文入栈。inner执行完毕,inner上下文弹出销毁。outer执行完毕,outer上下文弹出销毁。
变量查找的完整路径
当执行到 inner 里的 console.log(a) 时:
- 查
inner的词法环境栈顶:没有a。 - 查
inner的变量环境:没有a。 - 通过
inner的外部引用,跳转到outer的环境。 - 查
outer的词法环境:没有a。 - 查
outer的变量环境:找到a = 1。 - 输出
1。
当执行到 outer 里的 console.log(c) 时:
- 查
outer的词法环境:没有c。 - 查
outer的变量环境:没有c。 - 通过外部引用跳转到全局:没有
c。 - 抛出
ReferenceError: c is not defined。
作用域链是单向的、从内到外的。外层永远无法访问内层的变量。
总结
让我们回顾一下 JavaScript 执行模型的完整图景:
-
先编译,后执行:编译阶段处理声明(var 初始化为
undefined,函数声明整体提升,let/const 进入 TDZ),执行阶段逐行运行并赋值。不理解这一点,就无法解释变量提升和暂时性死区。 -
调用栈是容器,执行上下文是内容。函数调用 = 创建上下文 + 压入栈顶,函数返回 = 上下文弹出 + 销毁。
-
var无视块级作用域,let尊重块级作用域。两段几乎相同的代码——只在if块里换一个关键字——产生完全不同的输出,这是理解 ES6 作用域模型的最佳切入点。 -
ES6 的执行上下文包含两个环境:
- 变量环境:
var的地盘,功能简单,无视块级作用域。 - 词法环境:
let/const和块级作用域的地盘,是栈结构,进入块推入、离开块弹出。
- 变量环境:
-
函数编译阶段的优先级:函数声明 > 形参 > var 声明。形参、var、function 三者同名时,函数声明最终胜出。
-
let/const不允许和function在同一作用域重复声明,这是 ES6 对var时代宽松规则的修正。 -
作用域链由执行上下文的外部引用串联而成,变量查找遵循"词法环境栈 → 变量环境 → 外部引用"的顺序,单向、从内到外。