你是否也曾疑惑:为什么console.log(myName)写在变量声明前不报错,却输出undefined?为什么函数声明能在定义前调用,而函数表达式却不行?var和let/const的变量提升差异,到底本质是什么?
其实这些问题的答案,都藏在 V8 引擎的执行机制里。JS 作为 "解释型脚本语言",却并非边解释边执行这么简单 —— 它的 "编译阶段" 就发生在执行前的一刹那,正是这个隐藏的过程,决定了代码的实际执行顺序。今天就从 V8 引擎的工作视角,用通俗的语言拆解 JS 执行的核心逻辑,让你彻底搞懂变量提升、执行上下文和调用栈的底层原理。
一、打破认知:JS 不是 "边解释边执行"
很多人误以为 JS 是纯粹的解释型语言,代码执行时逐行解释逐行运行。但实际情况是:V8 引擎会先对代码进行编译,再执行,编译和执行交替进行。
这个过程就像厨师做菜:不是拿到食材就直接下锅(边解释边执行),而是先把食材洗净切好、调料备好(编译阶段),再开火烹饪(执行阶段);遇到复杂菜品需要分步做时,也是先准备好该步骤的食材(编译子函数),再烹饪(执行子函数)。
V8 引擎的执行流程可以概括为:
- 接收 JS 代码,按代码块(全局代码、函数代码)划分
- 对当前代码块进行编译(极短时间完成)
- 执行编译后的代码
- 遇到新的函数调用,重复 "编译→执行" 步骤
- 代码执行完毕,回收资源
正是这个 "先编译后执行" 的机制,造就了 JS 中最让人困惑的 "变量提升" 现象 —— 编译阶段已经为变量和函数提前 "占位",执行阶段才能直接使用。
二、编译阶段:V8 引擎在 "偷偷准备" 什么?
编译阶段的核心工作,是为代码创建 "执行上下文"(Execution Context),并完成变量和函数的 "提前处理"。这一步就像为代码执行搭建好 "舞台",让所有需要的 "演员"(变量、函数)各就各位。
1. 执行上下文:代码执行的 "环境容器"
每一段可执行的 JS 代码(全局代码、函数代码),都会被一个执行上下文包裹。它就像一个专属工作区,包含了代码执行所需的所有信息:
- 变量环境(Variable Environment):存储
var声明的变量、函数声明 - 词法环境(Lexical Environment):存储
let/const声明的变量、常量 - 可执行代码(Executable Code):编译后可直接执行的代码
全局代码会创建 "全局执行上下文",每个函数调用时会创建 "函数执行上下文"。这些执行上下文会被统一管理在 "调用栈" 中。
2. 变量提升的本质:编译阶段的 "占位操作"
变量提升不是变量真的被 "移动" 到代码顶部,而是编译阶段 V8 引擎做的两件事:
- 扫描代码中的变量声明(
var、let、const)和函数声明 - 为这些变量和函数在执行上下文中提前分配空间(占位)
但不同声明方式的 "占位规则" 不同,这也是var和let/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: undefined、showName: 函数体 - 词法环境:
hero: 未初始化(暂时性死区)
这就是为什么:
showName()能正常执行(函数声明已存储函数体)console.log(myName)输出undefined(var变量已初始化)console.log(hero)报错(let变量处于暂时性死区,未初始化)
3. 函数执行上下文的特殊处理
当编译函数代码时,除了变量提升,还会额外处理参数:
- 扫描函数形参,在变量环境中创建形参变量
- 将实参值赋值给形参变量
- 再处理函数内部的变量声明和函数声明(函数声明优先级高于变量)
看这个经典面试题:
js
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
console.log(a);
}
fn(3);
console.log(a);
编译fn函数时的变量环境流程:
- 形参
a占位,赋值实参3→a: 3 - 扫描到
var a,因已存在a(形参),忽略重复声明 - 执行阶段:先
console.log(a)输出3,再执行a=2,最后输出2 - 全局
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. 调用栈的工作流程
- JS 代码开始执行时,先将 "全局执行上下文" 压入调用栈(栈底)
- 遇到函数调用时,创建该函数的执行上下文,压入栈顶
- 引擎始终执行栈顶的执行上下文(当前活跃的执行上下文)
- 函数执行完毕后,其执行上下文从栈顶弹出,释放内存(垃圾回收)
- 所有代码执行完毕,全局执行上下文弹出,调用栈为空
用一个简单例子演示调用栈变化:
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 与数据拷贝
理解了执行机制后,再回头看var和let/const的差异,以及数据拷贝的本质,就会豁然开朗。
1. var vs let/const:不止是重复声明
- 变量提升:
var提升后初始值为undefined,let/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赋值为实参1,var x重复声明被忽略 - 执行
fn:第一个console.log(x)输出1,x=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 引擎的 "编译→执行" 循环,配合调用栈管理执行上下文。记住这几个核心点,就能看透所有相关问题:
- 编译发生在执行前的一刹那,核心是创建执行上下文、处理变量提升
- 执行上下文包含变量环境、词法环境和可执行代码,是代码执行的基础
- 调用栈遵循 "先进后出",管理执行上下文的执行顺序
var和let/const的差异源于编译阶段的变量初始化规则- 数据拷贝的差异源于栈内存和堆内存的存储方式不同
理解这些底层原理后,你写代码时会更有底气,遇到 bug 时也能快速定位问题根源。JS 的很多特性看似反直觉,实则都有其设计逻辑 —— 看透 V8 引擎的工作方式,就能真正掌握 JS 的执行规律。