别再死记变量提升了——从 V8 编译过程真正理解 JS 执行机制

深入理解 JavaScript 执行机制:从编译到执行的全过程

JavaScript 的执行机制是每个前端开发者的必修课。理解了变量提升、调用栈、作用域和块级作用域,才能真正读懂代码的执行流程。本文将通过原理讲解 + 代码实战的方式,带你彻底搞懂 JS 的执行机制。

一、从一个例子说起

先看一段简单代码,你能否说出它的输出顺序和原因?

js 复制代码
showName('XXX');
console.log(myname);

var myname = '张三';
function showName(name) {
    console.log(name);
    var b = 1;
    console.log('showName run', name);
}

如果你能毫不犹豫地说出答案并解释原因,说明你对 JS 执行机制已经有了不错的理解。如果还有些犹豫,那这篇文章就是为你准备的。

答案是 :先输出 'XXX''showName run XXX',再输出 undefined

为什么 showName 可以在定义之前调用?为什么 myname 在赋值之前访问不是报错而是 undefined?这背后就是 JS 的编译与执行机制

二、执行上下文(Execution Context)

每当你运行一段 JS 代码,V8 引擎都会创建一个执行上下文对象来管理这段代码的执行。它由三个部分组成:

组成部分 存放内容
变量环境(Variable Environment) varfunction 声明的变量
词法环境(Lexical Environment) letconst 声明的变量,支持块级作用域
可执行代码 从上到下顺序执行的代码逻辑

简单理解:执行上下文就是一个"运行环境快照",记录了当前作用域里有哪些变量、它们的值是什么、代码执行到哪了。

三、编译阶段详解

JS 不是纯解释型语言,它是先编译,再执行的。编译总是发生在执行的前一刻。

3.1 全局代码的编译过程

以开头的代码为例,当 V8 拿到这段全局代码时,编译阶段做了下面的事:

javascript 复制代码
第 1 步:创建全局执行上下文对象

第 2 步:扫描所有 var 声明,将变量名作为 key,value 设为 undefined
         → myname = undefined

第 3 步:扫描所有 function 声明,将函数名作为 key,value 设为完整的函数体
         → showName = function showName(name) { ... }

(注意:全局作用域没有形参,所以跳过了「统一形参和实参」这一步)

经过编译后,在 V8 眼中代码变成了这样:

js 复制代码
// ⬇⬇⬇ 编译阶段的结果 ⬇⬇⬇
var myname;                    // 提升到顶部,值为 undefined
function showName(name) {      // 整个函数体提升到顶部
    var b;                     // 函数内部的 var 也提升了
    console.log(name);
    b = 1;
    console.log('showName run', name);
}
// ⬆⬆⬆ 编译阶段的结果 ⬆⬆⬆

// ⬇⬇⬇ 执行阶段(从上到下) ⬇⬇⬇
showName('XXX');               // ✅ 函数已存在,正常调用
console.log(myname);           // undefined(声明了但还未赋值)
myname = '张三';               // 赋值发生在原地

3.2 函数体内的编译过程

函数被调用时,也会经历一次编译,而且比全局多了一个关键步骤------统一形参和实参

来看这个例子:

js 复制代码
function fn(a) {
    console.log(a);   // 输出什么?
    var a = 2;
    function a() {}
    var b = a;
    console.log(a);   // 输出什么?
}
fn(3);

注意 :调用是 fn(3),传的是数字 3,和任何外层变量都没有关系。函数的形参 a 属于 fn 自己的作用域,是一个全新的变量。

fn(3) 被调用时,引擎为 fn 从零开始创建一个全新的执行上下文。编译过程如下:

css 复制代码
第 1 步:创建 fn 的执行上下文对象
        (全新的、空白的上下文,和全局没有任何变量共享)

第 2 步:找形参和 var 声明,将变量名登记为 key,value 暂设为 undefined
         - 形参 a        → a = undefined(先占位,等实参来覆盖)
         - var a         → a 已存在,跳过(不会重复声明)
         - var b         → b = undefined

第 3 步:统一形参和实参 ← 函数独有的步骤!
         - 实参是 3 → a = 3(用实参的值覆盖上一步的 undefined)

第 4 步:找 function 声明
         - function a(){} → a = [Function: a](函数声明最后处理,覆盖了 3)

为什么第 2 步 a 是 undefined 因为每个函数调用都会创建一个全新的执行上下文,从零开始初始化,和全局变量、外层作用域没有任何直接关系。第 2 步只是"登记名字、先占个位",真正的值在第 3 步(实参)或第 4 步(函数声明)才确定下来。

然后进入执行阶段:

css 复制代码
console.log(a)  →  [Function: a]     // a 被函数声明覆盖了
a = 2                                // 赋值,a 变成数字 2
function a(){}  →  编译阶段已处理,跳过
b = a           →  b = 2
console.log(a)  →  2

输出结果:[Function: a]2

关键记忆点:编译阶段的处理顺序是「形参/var(值为 undefined)→ 实参赋值 → 函数声明(值为函数体)」。函数声明最后处理,所以它的优先级最高,会覆盖同名的形参和 var 变量。

3.3 函数声明 vs var 声明优先级

这道面试题你一定见过:

js 复制代码
console.log(func);  // ?

var func = 345;

function func() {
}

var func = 123;

答案是 [Function: func]

原因很直接------编译阶段函数声明覆盖了 var 声明:

swift 复制代码
编译:
  步骤 2:var func     → func = undefined
  步骤 3:全局,跳过
  步骤 4:function func → func = [Function: func](覆盖 undefined)
          第二个 var func → func 已存在,跳过

执行:
  console.log(func) → [Function: func]
  func = 345        → func 现在是 345
  func = 123        → func 现在是 123

四、调用栈(Call Stack)

现在理解了执行上下文的概念,我们再来看调用栈------它就是管理多个执行上下文的数据结构。

调用栈是一个**后进先出(LIFO)**的栈,栈顶永远指向当前正在执行的函数。我们用上面 fn(3) 的例子来追踪调用栈的变化:

ini 复制代码
① 初始状态:全局执行上下文在栈底
┌──────────────────────┐
│  全局执行上下文       │ ← 栈顶 & 栈底
│  a = 1, fn = func   │
└──────────────────────┘

② 执行 fn(3),fn 的上下文压入栈顶
┌──────────────────────┐
│  fn 执行上下文        │ ← 栈顶(正在执行)
│  a = [Function: a]   │
│  b = undefined       │
├──────────────────────┤
│  全局执行上下文       │ ← 暂时挂起
└──────────────────────┘

③ fn 执行完毕,fn 的上下文弹出并销毁
┌──────────────────────┐
│  全局执行上下文       │ ← 栈顶(继续执行全局代码)
└──────────────────────┘

核心规则就三条:

  1. 编译总是发生在执行的前一刻------JS 是"先编译,再执行"
  2. 全局代码和每个函数调用都会生成执行上下文并压入调用栈
  3. 函数执行完毕后,它的执行上下文弹出栈并销毁

调用栈就像一叠盘子:你只能往最上面放盘子(函数调用),也只能从最上面取盘子(函数返回)。最底下的盘子(全局上下文)要等程序结束时才拿走。

五、var 与函数作用域

var 声明的变量只有函数作用域,没有块级作用域。这是很多 bug 的根源:

js 复制代码
function varTest() {
    var x = 1;
    if (true) {
        var x = 2;         // 和外面的 x 是同一个变量!
        console.log(x);    // 2
    }
    console.log(x);        // 2(不是 1!)
}

varTest();
// 输出:2  2

if 的花括号 {}var 来说形同虚设,里面的 var x = 2 修改的就是函数作用域中唯一的那个 x

六、let / const 与块级作用域

ES6 引入了 letconst,它们支持块级作用域(Block Scope) ,任何一对 {} 都会形成一个独立的作用域。

6.1 基础对比

js 复制代码
function varTest() {
    var x = 1;
    if (true) {
        let x = 2;         // 这是一个全新的 x,仅在当前 {} 内有效
        console.log(x);    // 2
    }
    console.log(x);        // 1(外面的 x 不受影响)
}

varTest();
// 输出:2  1

6.2 深入:var 与 let 在同一个函数中混用

这块最容易混淆,我们用一个稍复杂的例子彻底讲清楚:

js 复制代码
function foo() {
    var a = 1;        // 变量环境 --- 函数作用域
    let b = 2;        // 词法环境 --- 函数作用域
    {fined
        let b = 3;    // 新建词法环境 --- 块级作用域,覆盖外层的 b
        var c = 4;    // var 无视块,提升到函数作用域
        let d = 5;    // 块级作用域,仅在此 {} 内有效
        console.log(a); // 1  ← 当前块没有 a,沿作用域链向外找
        console.log(b); // 3  ← 当前块有自己的 b,直接用
    }
    console.log(b);   // 2  ← 离开块,块内的 b 失效
    console.log(c);   // 4  ← var 被提升到函数作用域,块外也能访问
    console.log(d);   // ❌ ReferenceError: d is not defined
}

foo();

输出:

vbnet 复制代码
1
3
2
4
ReferenceError: d is not defined

变量的查找顺序 是这样的:引擎从当前代码所在的词法环境 开始查找,如果没找到,就沿着 outer 引用 往外一层找,直到全局作用域。不同层级的 {} 产生不同的词法环境,这些环境通过 outer 引用形成一条链------这就是常说的作用域链

一句话总结var 无视 {},始终归属于最近的函数let/const 严格限制在当前 {} 内。

七、暂时性死区(TDZ)

7.1 纠正一个常见误区

你可能听过「let 和 const 不会变量提升」------这个说法不准确

letconst 也会被提升(hoist) ,但与 var 有本质区别:

特性 var let / const
是否提升 ✅ 是 ✅ 是
提升后的状态 初始化为 undefined 未初始化(uninitialized)
声明前访问 返回 undefined 抛出 ReferenceError

从进入作用域到 let/const 声明语句实际执行之前的这段时间,变量处于暂时性死区(Temporal Dead Zone, TDZ),任何访问都会报错:

js 复制代码
console.log(x); // undefined --- var 提升且初始化为 undefined
var x = 1;

console.log(y); // ❌ ReferenceError: Cannot access 'y' before initialization
let y = 2;      // y 被提升了,但处于 TDZ,还不能用

7.2 不可重复声明

js 复制代码
let a = 1;
function a() {}  // ❌ SyntaxError: Identifier 'a' has already been declared

var 允许反复声明同一个变量名,而 let/const 在同一个作用域内完全禁止重复声明。

letconst 的 TDZ、块级作用域、不可重复声明等设计,本质上都是在修正 var 在 JavaScript 早期设计中的历史遗留问题,让代码行为更可控、更符合直觉。

八、完整知识图谱

javascript 复制代码
JavaScript 执行机制
│
├── 执行上下文(Execution Context)
│   ├── 变量环境 → 存 var / function
│   ├── 词法环境 → 存 let / const,支持块级作用域
│   └── 可执行代码
│
├── 编译阶段(发生在执行前一刻)
│   ├── ① 创建执行上下文对象
│   ├── ② 形参 + var 声明的变量名 → 值设为 undefined
│   ├── ③ 统一形参与实参(仅函数体内有此步骤)
│   └── ④ 函数声明 → 函数名 = 函数体(优先级最高)
│
├── 调用栈(管理多个执行上下文)
│   ├── LIFO 结构,栈顶 = 当前正在执行
│   └── 函数调用 = 压入,函数返回 = 弹出并销毁
│
├── var 特性
│   ├── 编译阶段提升 + 初始化为 undefined
│   ├── 函数作用域({} 无fined法限制它)
│   └── 允许重复声明
│
└── let / const 特性
    ├── 也会提升,但不初始化 → 暂时性死区(TDZ)
    ├── 块级作用域(每个 {} 都是独立的作用域)
    └── 同一作用域内禁止重复声明

九、面试高频考题自测

题目 1:变量提升优先级

js 复制代码
console.log(a);
var a = 1;
function a() {}
console.log(a);

点击查看答案

输出

csharp 复制代码
[Function: a]
1

解析 :编译阶段函数声明覆盖 var 声明,第一个 console.log(a) 输出函数。执行阶段 a = 1 覆盖了函数,第二个 console.log(a) 输出 1

题目 2:块级作用域经典题

js 复制代码
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0);
}

for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0);
}

点击查看答案

var 版本输出:3 3 3(循环中共享同一个 i,setTimeout 执行时循环已结束,i = 3)

let 版本输出:0 1 2(每次迭代都创建一个独立的块级作用域,各自持有当时的 i 值)

这道题是面试中的"常青树",本质考察的就是 var 的函数作用域 vs let 的块级作用域。

题目 3:函数形参 + 内部声明

js 复制代码
function bar(x) {
    console.log(x);
    var x = 10;
    function x() {}
    console.log(x);
}
bar(5);
```fined

<details>
<summary>点击查看答案</summary>

**输出**:

Function: x 10

markdown 复制代码
**解析**:
- 编译阶段:形参 `x = 5` → 函数声明覆盖为 `[Function: x]`
- 执行阶段:第一个 `console.log(x)` 输出函数 → `x = 10` 赋值 → 第二个 `console.log(x)` 输出 `10`
</details>

## 十、总结

1. **JS 先编译再执行**:编译阶段处理声明(var/let/const/function),执行阶段才运行赋值和其他逻辑。
2. **函数声明优先级最高**:编译阶段最后处理函数声明,会覆盖同名的形参和 var 变量。
3. **var 只有函数作用域**:`{}` 块对它无效,容易造成变量污染。
4. **let/const 支持块级作用域**:每个 `{}` 都是独立的作用域,由词法环境实现。
5. **let/const 也会提升**:但不会被初始化为 `undefined`,而是进入暂时性死区(TDZ),声明前访问会报 ReferenceError。
6. **调用栈管理一切**:全局 + 每个函数调用 = 一个执行上下文,压入调用栈,执行完弹出销毁。

---

> 本文所有代码示例均可直接在浏览器控制台或 Node.js 中运行验证。建议你亲手敲一遍,边运行边在脑中模拟编译和执行的过程,理解会深刻很多。
相关推荐
橘子星1 小时前
别再懵圈!JS 执行机制的 “千层套路” 全揭秘
前端·javascript
GuWenyue1 小时前
LeetCode 76 最小覆盖子串|JS 滑动窗口标准解法
前端·算法·面试
YHHLAI1 小时前
前端 HTTP 请求 & LLM 接口开发
前端·网络协议·http
拾年2751 小时前
__proto__ vs prototype:90% 的人分不清的 JavaScript 核心
前端·javascript·面试
国科安芯1 小时前
国科安芯推出商业航天级抗辐照半双工 RS485 收发器 ASC485S2Y
前端·单片机·嵌入式硬件·架构·安全性测试
丑过三八线1 小时前
Umi 运行时配置 app.tsx 详解
前端
提子拌饭1332 小时前
个人月事记录表应用 - 鸿蒙PC Electron框架完整实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙系统
YHL2 小时前
📚 JS执行机制(执行上下文 + 调用栈 + 编译流程)
前端·javascript
不简说2 小时前
这次真香!sv-print 可视化打印设计器更新:插件脚手架、Excel 导出、弹窗 API 三连发
前端·javascript·前端框架