JavaScript 执行机制——调用栈和执行上下文

10 阅读15分钟

引言


假设你写下这样一段"看起来写错了"的代码:

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 声明的变量。
  • 词法环境:专门存放 letconst 声明以及管理块级作用域。
  • 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 两种作用域模型的核心差异。

作用域什么时候创建?

需要区分"地图"和"实体":

  • 词法作用域:在编译/写代码时就已经静态确定,是嵌套关系的"地图"。
  • 作用域实体:在运行时创建。全局上下文在脚本开始时创建;函数上下文在函数被调用时创建;块级作用域在执行进入该块时创建。

变量查找规则

当引擎执行代码遇到一个变量名时,查找顺序是:

  1. 先查当前执行上下文的词法环境栈,从栈顶(最内层块)逐层往外找。
  2. 词法环境链的末端链接到变量环境var 声明和函数声明在这里)。
  3. 还找不到,沿着变量环境的 [[OuterEnv]] 跳转到更外层作用域。
  4. 在外层重复上述流程(词法环境 → 变量环境 → 更外层),直到全局作用域,找到返回,找不到抛 ReferenceError

注意: 词法环境和变量环境不是两个平行的查找路径,而是通过 [[OuterEnv]] 串联起来的一条链——词法环境在链条上游,变量环境在下游。


第四层:双环境结构 —— ES6 最核心的设计

这是本文最重要的部分。在 ES6 中,一个执行上下文内部同时存在两个环境组件

从简单到复杂:双环境入门

在深入复杂例子之前,先用上面两段 var / let 对比代码来理解"双环境"是如何工作的。

只有 var 时,两个环境指向同一处:

vartest 执行上下文:
  变量环境 ──┐
             ├──→ { x: 12 }    (同一个环境记录)
  词法环境 ──┘

过程:
  var x = 1     → 环境记录中 x = 1
  进入 if 块     → 没有 let/const,不创建新的词法环境
  var x = 2     → 同一个 x,改为 2
  离开 if 块     → 无事发生
  console.log2

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

词法环境

  • 专门处理 letconst 声明以及块级作用域。
  • 它是一个栈结构:进入新块,新环境推入栈顶;离开块,栈顶环境弹出销毁。
  • 变量在编译阶段创建但不初始化。

两个环境的初始关系

在函数调用时,变量环境和词法环境最初指向同一个环境记录,并不是一开始就独立存在:

函数调用,创建执行上下文:
  变量环境 ──┐
             ├──→ 同一个声明式环境记录 (Declarative Environment Record)
  词法环境 ──┘

如果函数体内存在 let/const 声明:
  词法环境 → 新的声明式环境记录 → outer指向 → 变量环境(原来的那个)

如果函数体内只有 var(没有 let/const):
  变量环境和词法环境始终是同一个,本质没有区别

也就是说,词法环境只在有 let/const 声明时才从变量环境"分叉"出来,把变量环境包在 [[OuterEnv]] 链条的下游。两个环境的关系是"一条链上的上下游",不是两个平行的盒子。

复杂实战演示

理解了基础之后,来看一个混合了 varlet、块级作用域的复杂例子:

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: undefinedc: undefinedvar c 无视块,直接提升到这里)
  • 词法环境
    • 函数词法环境:b: TDZ
    • 编译器同时生成了"进入块时创建块级词法环境"的指令,但此时块级词法环境尚不存在。只有在运行时执行进入 { } 时,才会创建该环境,并在其中绑定 b: TDZd: 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 → 变量环境找到 11
B:console.log(b)词法栈顶(块)找到 33
离开块块级词法环境销毁b=3d=5 消失
C:console.log(b)函数词法环境找到 22
D:console.log(c)词法环境没有 → 变量环境找到 44
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) 编译阶段(关键!):

按照编译的优先级顺序处理:

  1. 形参a = 3(实参已传入,形参与实参统一)
  2. var a 声明:变量环境中已有 a(来自形参),var a 的声明被忽略(不覆盖形参的值)
  3. function a() {} 声明:函数声明优先级最高,覆盖变量环境中 a 的值为函数体

编译结束后,变量环境中 a 的值是 function a() {}

fn(3) 执行阶段:

步骤动作结果
A:console.log(a)变量环境 a = function a() {}输出 [Function: a]
var a = 2声明已在编译阶段处理,这里只执行赋值 a = 2a 变为 2
function a() {}编译阶段已处理,执行阶段跳过
var b = ab = 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();

调用栈变化

  1. 全局上下文入栈。
  2. outer() 调用,outer 上下文入栈。
  3. inner() 调用,inner 上下文入栈。
  4. inner 执行完毕,inner 上下文弹出销毁。
  5. outer 执行完毕,outer 上下文弹出销毁。

变量查找的完整路径

当执行到 inner 里的 console.log(a) 时:

  1. inner 的词法环境栈顶:没有 a
  2. inner 的变量环境:没有 a
  3. 通过 inner 的外部引用,跳转到 outer 的环境。
  4. outer 的词法环境:没有 a
  5. outer 的变量环境:找到 a = 1
  6. 输出 1

当执行到 outer 里的 console.log(c) 时:

  1. outer 的词法环境:没有 c
  2. outer 的变量环境:没有 c
  3. 通过外部引用跳转到全局:没有 c
  4. 抛出 ReferenceError: c is not defined

作用域链是单向的、从内到外的。外层永远无法访问内层的变量。


总结

让我们回顾一下 JavaScript 执行模型的完整图景:

  1. 先编译,后执行:编译阶段处理声明(var 初始化为 undefined,函数声明整体提升,let/const 进入 TDZ),执行阶段逐行运行并赋值。不理解这一点,就无法解释变量提升和暂时性死区。

  2. 调用栈是容器,执行上下文是内容。函数调用 = 创建上下文 + 压入栈顶,函数返回 = 上下文弹出 + 销毁。

  3. var 无视块级作用域,let 尊重块级作用域。两段几乎相同的代码——只在 if 块里换一个关键字——产生完全不同的输出,这是理解 ES6 作用域模型的最佳切入点。

  4. ES6 的执行上下文包含两个环境

    • 变量环境var 的地盘,功能简单,无视块级作用域。
    • 词法环境let/const 和块级作用域的地盘,是栈结构,进入块推入、离开块弹出。
  5. 函数编译阶段的优先级函数声明 > 形参 > var 声明。形参、var、function 三者同名时,函数声明最终胜出。

  6. let/const 不允许和 function 在同一作用域重复声明,这是 ES6 对 var 时代宽松规则的修正。

  7. 作用域链由执行上下文的外部引用串联而成,变量查找遵循"词法环境栈 → 变量环境 → 外部引用"的顺序,单向、从内到外。