吃透 JS 执行机制:从 V8 引擎视角看懂变量提升、执行上下文与调用栈

68 阅读9分钟

你是否也曾疑惑:为什么console.log(myName)写在变量声明前不报错,却输出undefined?为什么函数声明能在定义前调用,而函数表达式却不行?varlet/const的变量提升差异,到底本质是什么?

其实这些问题的答案,都藏在 V8 引擎的执行机制里。JS 作为 "解释型脚本语言",却并非边解释边执行这么简单 —— 它的 "编译阶段" 就发生在执行前的一刹那,正是这个隐藏的过程,决定了代码的实际执行顺序。今天就从 V8 引擎的工作视角,用通俗的语言拆解 JS 执行的核心逻辑,让你彻底搞懂变量提升、执行上下文和调用栈的底层原理。

一、打破认知:JS 不是 "边解释边执行"

很多人误以为 JS 是纯粹的解释型语言,代码执行时逐行解释逐行运行。但实际情况是:V8 引擎会先对代码进行编译,再执行,编译和执行交替进行

这个过程就像厨师做菜:不是拿到食材就直接下锅(边解释边执行),而是先把食材洗净切好、调料备好(编译阶段),再开火烹饪(执行阶段);遇到复杂菜品需要分步做时,也是先准备好该步骤的食材(编译子函数),再烹饪(执行子函数)。

V8 引擎的执行流程可以概括为:

  1. 接收 JS 代码,按代码块(全局代码、函数代码)划分
  2. 对当前代码块进行编译(极短时间完成)
  3. 执行编译后的代码
  4. 遇到新的函数调用,重复 "编译→执行" 步骤
  5. 代码执行完毕,回收资源

正是这个 "先编译后执行" 的机制,造就了 JS 中最让人困惑的 "变量提升" 现象 —— 编译阶段已经为变量和函数提前 "占位",执行阶段才能直接使用。

二、编译阶段:V8 引擎在 "偷偷准备" 什么?

编译阶段的核心工作,是为代码创建 "执行上下文"(Execution Context),并完成变量和函数的 "提前处理"。这一步就像为代码执行搭建好 "舞台",让所有需要的 "演员"(变量、函数)各就各位。

1. 执行上下文:代码执行的 "环境容器"

每一段可执行的 JS 代码(全局代码、函数代码),都会被一个执行上下文包裹。它就像一个专属工作区,包含了代码执行所需的所有信息:

  • 变量环境(Variable Environment):存储var声明的变量、函数声明
  • 词法环境(Lexical Environment):存储let/const声明的变量、常量
  • 可执行代码(Executable Code):编译后可直接执行的代码

全局代码会创建 "全局执行上下文",每个函数调用时会创建 "函数执行上下文"。这些执行上下文会被统一管理在 "调用栈" 中。

2. 变量提升的本质:编译阶段的 "占位操作"

变量提升不是变量真的被 "移动" 到代码顶部,而是编译阶段 V8 引擎做的两件事:

  1. 扫描代码中的变量声明(varletconst)和函数声明
  2. 为这些变量和函数在执行上下文中提前分配空间(占位)

但不同声明方式的 "占位规则" 不同,这也是varlet/const的核心差异:

  • var声明的变量:在变量环境中占位,初始值设为undefined
  • 函数声明:优先级最高,在变量环境中直接存储函数体(而非undefined
  • let/const声明的变量:在词法环境中占位,但不赋予初始值(形成 "暂时性死区")

看个直观例子,编译前的代码:

js

showName();
console.log(myName);
console.log(hero);

var myName = 'cqw';
let hero = '钢铁侠';
function showName() {
    console.log(myName);
    console.log('函数showName被执行');
}

V8 引擎编译后的执行上下文状态:

  • 变量环境:myName: undefinedshowName: 函数体
  • 词法环境:hero: 未初始化(暂时性死区)

这就是为什么:

  • showName()能正常执行(函数声明已存储函数体)
  • console.log(myName)输出undefinedvar变量已初始化)
  • console.log(hero)报错(let变量处于暂时性死区,未初始化)

3. 函数执行上下文的特殊处理

当编译函数代码时,除了变量提升,还会额外处理参数:

  1. 扫描函数形参,在变量环境中创建形参变量
  2. 将实参值赋值给形参变量
  3. 再处理函数内部的变量声明和函数声明(函数声明优先级高于变量)

看这个经典面试题:

js

var a = 1;
function fn(a) {
    console.log(a);
    var a = 2;
    console.log(a);
}
fn(3);
console.log(a);

编译fn函数时的变量环境流程:

  1. 形参a占位,赋值实参3 → a: 3
  2. 扫描到var a,因已存在a(形参),忽略重复声明
  3. 执行阶段:先console.log(a)输出3,再执行a=2,最后输出2
  4. 全局a不受函数内部影响,最终输出1

如果函数内部有函数声明,优先级会更高:

js

function fn(a) {
    console.log(a);
    function a() {}
    var a = 2;
    console.log(a);
}
fn(3); // 输出:function a() {} → 2

编译时函数声明function a()会覆盖形参a,所以第一个console.log(a)输出函数体。

三、执行阶段:调用栈如何管理执行上下文?

编译阶段创建好执行上下文后,就进入执行阶段。V8 引擎用 "调用栈"(Call Stack)来管理执行上下文的执行顺序,调用栈遵循 "先进后出"(LIFO)原则。

1. 调用栈的工作流程

  1. JS 代码开始执行时,先将 "全局执行上下文" 压入调用栈(栈底)
  2. 遇到函数调用时,创建该函数的执行上下文,压入栈顶
  3. 引擎始终执行栈顶的执行上下文(当前活跃的执行上下文)
  4. 函数执行完毕后,其执行上下文从栈顶弹出,释放内存(垃圾回收)
  5. 所有代码执行完毕,全局执行上下文弹出,调用栈为空

用一个简单例子演示调用栈变化:

js

function fn1() {
    console.log('fn1执行');
    fn2();
}
function fn2() {
    console.log('fn2执行');
}
fn1();

调用栈变化过程:

  • 初始:调用栈为空
  • 执行全局代码:全局执行上下文压入栈 → [全局上下文]
  • 调用fn1():创建fn1执行上下文压入栈 → [全局上下文,fn1 上下文]
  • fn1内部调用fn2():创建fn2执行上下文压入栈 → [全局上下文,fn1 上下文,fn2 上下文]
  • fn2执行完毕:fn2上下文弹出 → [全局上下文,fn1 上下文]
  • fn1执行完毕:fn1上下文弹出 → [全局上下文]
  • 全局代码执行完毕:全局上下文弹出 → 栈空

2. 执行上下文的销毁与垃圾回收

函数执行上下文弹出调用栈后,其内部的变量和函数会失去引用(闭包除外),V8 引擎的垃圾回收机制会定期清理这些未被引用的内存空间。

这也是为什么函数执行完后,内部的局部变量就无法访问了 —— 它们所在的执行上下文已被销毁,内存被回收。

四、关键差异:var、let/const 与数据拷贝

理解了执行机制后,再回头看varlet/const的差异,以及数据拷贝的本质,就会豁然开朗。

1. var vs let/const:不止是重复声明

  • 变量提升:var提升后初始值为undefinedlet/const提升后处于暂时性死区(未初始化)
  • 重复声明:var允许重复声明(后声明会覆盖前声明),let/const禁止重复声明
  • 作用域:var只有函数作用域和全局作用域,let/const有块级作用域
  • 全局挂载:var声明的全局变量会挂载到window(浏览器环境),let/const不会

严格模式下var仍允许重复声明,但不推荐使用 ——let/const的设计更符合直觉,能避免很多因变量提升导致的 bug。

2. 数据拷贝:值拷贝 vs 引用拷贝

简单数据类型(字符串、数字、布尔等)和复杂数据类型(对象、数组等)的拷贝差异,本质是内存存储方式不同:

  • 简单数据类型:存储在栈内存,拷贝时直接复制值(值拷贝),修改拷贝后的值不影响原值
  • 复杂数据类型:存储在堆内存,栈内存中只存储堆内存地址,拷贝时复制的是地址(引用拷贝),修改拷贝后的值会影响原对象

例子验证:

js

// 简单数据类型:值拷贝
let str = 'hello';
let str2 = str;
str2 = '你好';
console.log(str, str2); // hello 你好(原值不变)

// 复杂数据类型:引用拷贝
let obj = { name: '郑老板', age: 18 };
let obj2 = obj;
obj2.age++;
console.log(obj, obj2); // 两者age都是19(原对象被修改)

如果想实现复杂数据类型的 "深拷贝"(不影响原对象),需要手动复制对象的所有属性,或使用JSON.parse(JSON.stringify())等方法。

五、实战面试题:检验你的理解程度

看完上面的内容,来做几道经典面试题,巩固一下知识点:

面试题 1:变量提升与函数声明优先级

js

console.log(a);
function a() {}
var a = 1;
console.log(a);
  • 编译阶段:变量环境中a先被函数声明赋值为函数体,后var a重复声明被忽略
  • 执行阶段:第一个console.log(a)输出function a(),执行a=1后,第二个console.log(a)输出1

面试题 2:函数执行上下文与参数处理

js

var x = 1;
function fn(x) {
    console.log(x);
    var x = 2;
    console.log(x);
}
fn(x);
console.log(x);
  • 编译fn时:形参x赋值为实参1var x重复声明被忽略
  • 执行fn:第一个console.log(x)输出1x=2后输出2
  • 全局x不受影响,最后输出1

面试题 3:暂时性死区

js

console.log(b);
let b = 2;
  • 编译阶段:b在词法环境中占位,处于暂时性死区
  • 执行阶段:console.log(b)b未初始化,报错Cannot access 'b' before initialization

总结:JS 执行机制的核心逻辑

JS 执行机制的本质,是 V8 引擎的 "编译→执行" 循环,配合调用栈管理执行上下文。记住这几个核心点,就能看透所有相关问题:

  1. 编译发生在执行前的一刹那,核心是创建执行上下文、处理变量提升
  2. 执行上下文包含变量环境、词法环境和可执行代码,是代码执行的基础
  3. 调用栈遵循 "先进后出",管理执行上下文的执行顺序
  4. varlet/const的差异源于编译阶段的变量初始化规则
  5. 数据拷贝的差异源于栈内存和堆内存的存储方式不同

理解这些底层原理后,你写代码时会更有底气,遇到 bug 时也能快速定位问题根源。JS 的很多特性看似反直觉,实则都有其设计逻辑 —— 看透 V8 引擎的工作方式,就能真正掌握 JS 的执行规律。

image.png