图解 V8 引擎底层运行原理:从变量环境(VariableEnvironment)彻底看透 JavaScript 变量提升
图解 V8 引擎底层运行原理:从变量环境(VariableEnvironment)彻底看透 JavaScript 变量提升
一、 引言:一周赶工出来的弱类型动态语言与它的早期瑕疵
很多初学者都在吐槽 JavaScript 存在各种“反直觉”的怪异行为,比如变量还没声明就能用、函数在定义前就能调。要理解这一切,我们得把时钟拨回 1995 年。
早期的 JavaScript 为了蹭一波 Java 的热度而得名,它被定位为一款弱类型的动态语言,最初的设计目标非常纯粹且轻量——仅仅是为了给浏览器的网页添加一些简单的交互(比如 DOM 编程、实现幻灯片播放等效果)。
因为当时时间紧迫,网景公司的布兰登·艾奇(Brendan Eich)仅仅用了大约一周的时间就将这个“KPI项目”开发了出来。作为一个没有经过长达数年深思熟虑、在赶工状态下诞生的浏览器副产品,早期的 JavaScript 难免带有一些语法的瑕疵和历史包袱。
随着 2015 年 ES6(ECMAScript 2015) 标准的落地,JavaScript 迎来了向企业级大型项目开发发展的黄金时代。但无论标准如何演进,底层的运行逻辑从未改变。想要进阶高级前端工程师,我们必须扒开它略显粗糙的表象,去直面 V8 引擎的内心世界。
二、从内存分配与作用域(Scope)看变量的生命周期
在 JavaScript 中,任何一个变量的声明与赋值,其背后都有着严密的内存与作用域规则在支撑。
1. 作用域的基本分类
作用域(Scope)决定了变量在代码中的可见性与生存周期。在 JS 中,主要存在以下三种作用域:
- 全局作用域(Global Scope): 处于代码最外层的区域,在任何地方都能访问到其中声明的变量。
- 函数局部作用域(Local Scope): 声明在函数内部的变量,属于局部作用域,外部代码无法突破函数边界进行访问。
- 块级作用域(Block Scope): ES6 新增特性。任何由一对大括号
{ }包裹的区域(如if语句块、for循环块)都会诞生块级作用域,将变量牢牢锁在内部。
2. 变量的“冒泡查找规则”
由于 JavaScript 是弱类型动态语言,变量的类型完全由其存储的值来决定。当我们在某一行代码里尝试访问一个变量时,JS 引擎会启动“冒泡查找机制”:
- 就近查找: 首先在当前所处的当前作用域中搜寻该变量,如果找到则直接使用。
- 向外冒泡: 如果当前作用域没有找到,则顺着作用域链向上一层的外层作用域延伸查找。
- 触顶报错: 查找过程会一路冒泡到最顶层的全局作用域。如果最终依然一无所获,引擎就会立即停下来,抛出经典的运行时错误:
ReferenceError: XXX is not defined。
3. 内存角度的变量生命周期
从底层来看,变量的声明本质上是在物理内存中申请并开辟了一块专属的存储区域。
- 当一个函数被调用或者一个代码块被触发执行时,相关的变量在内存中被激活。
- 一旦该函数或代码块运行完毕退出,对应的这块局部内存就会被系统的垃圾回收(Garbage Collection, GC) 机制彻底销毁并回收。销毁函数、收回内存的这一整个闭环,构成了变量完整的生命周期。
三、 JavaScript 引擎是怎么运行代码的?
1. 传统认知误区:代码是按物理顺序一行行执行的吗?
很多经典的教科书会告诉你:“JavaScript 是一门解释型脚本语言,它的代码是按顺序从上到下一行行执行的。”
然而,如果真的是严格按照物理顺序执行,那么下面这段代码在逻辑上就根本说不通:
showName();
console.log(myName);
var myName = '极客时间';
function showName() {
console.log('函数showName被执行了');
}
如果按照物理顺序,第一行调用 showName() 和第二行打印 myName 时,它们的声明语句明明还在后面,理应直接引发 ReferenceError 崩溃。但实际运行结果却是:函数正确执行了,变量打印出了 undefined,程序并没有报错。
这铁一般的事实证明:JavaScript 代码绝对不是简单地“读一行执行一行”。
2. V8 引擎的破局:运行前那一霎那的“编译阶段”
JavaScript 虽然是一门脚本语言,没有传统编译语言那种独立的、漫长的编译期,但它在代码真正运行前的那一霎那,会有一个极其关键的编译阶段(Compilation)。
也就是说,JS 代码的完整生命线是由两个截然不同的阶段组成的:
输入一段代码后,它会首先经历编译阶段来为接下来的运行排兵布阵、准备好必要的数据结构;编译完成后,才会真正切入执行阶段。上述代码之所以能表现出“未声明先使用”的奇特现象,正是因为变量和函数的声明部分,早在编译阶段就已经被 V8 引擎处理完毕了。
四、执行上下文与变量环境
既然 JavaScript 在运行前的那一霎那会经历编译阶段,那么 V8 引擎在编译期到底捣鼓出了什么东西?
简单来说,输入一段 JavaScript 代码,经过 V8 引擎的编译后,会雷打不动地生成两部分内容:执行上下文(Execution Context) 和 可执行代码。
1. 什么是执行上下文?
执行上下文是 JavaScript 执行一段代码时的整个运行环境。它就像是一个备忘录或者工作台,里面不仅记录了当前代码块有哪些变量、函数声明,还管理着作用域链和 this 的指向。
当我们运行全局代码时,会进入全局执行上下文;当调用一个函数时,引擎又会瞬间为该函数创建并进入一个函数的执行上下文。
而在执行上下文的内部,存在着两个至关重要的底层数据结构:
- 变量环境(VariableEnvironment): 专门用来存放由
var声明的普通变量,以及传统的函数声明(Function Declaration)。 - 词法环境(Lexical Environment): 专门用来存放由 ES6 新增的
let和const声明的变量。
为了让你一目了然,我们直接把代码、编译产物与执行流程放在一张全景图里:
2. 函数是“一等公民”的编译期特权
在 JavaScript 中,函数是一等公民(First-Class Citizens)。这不仅意味着函数可以像普通变量一样被作为参数传递、作为返回值返回,更意味着它在编译阶段就拥有至高无上的特权。
我们结合代码片段来看看编译期变量环境的真实构造:
// 原始输入代码
showName();
console.log(myName);
var myName = 'moss';
function showName(){
var a = 1;
console.log('函数showName被执行了');
}
V8 引擎在编译阶段的行为拆解:
-
扫描变量 var myName: 引擎在全局作用域的变量环境(VariableEnvironment)中登记一个名字叫
myName的变量。由于它是var声明的普通变量,引擎会静默将其初始化为undefined。 -
扫描函数声明 function showName: 引擎发现这是一个完整的函数声明。因为函数是一等公民,引擎会直接在内存(堆内存)中创建对应的函数对象,并在变量环境中把
showName这个名字直接指向该函数对象的物理内存地址。
所以,在编译阶段结束、可执行代码还没跑的第一秒,全局执行上下文的“工作台”其实长成这样:
VariableEnvironment:
myName -> undefined
showName -> function : { console.log('函数showName被执行了') }
这就是为什么你在声明之前去调用 showName() 不会报错且能正确执行,而访问 myName 却只能拿到 undefined 的核心原因!因为在编译期,函数对象已经被初始化好了,而 var 变量却只拿到了一个 undefined 的空壳。
3. 函数表达式的翻车现场
面试官最喜欢在这里挖坑:如果我把上面的函数声明换成匿名函数(函数表达式),它还能在定义前成功调用吗?
我们来看测试:
var myName = undefined;
myName = 'moss';
// 完整的函数声明:没有涉及赋值操作,编译期直接提升并关联函数对象
function foo() {
console.log('foo');
}
// 匿名函数/函数表达式:本质是涉及了 "=" 赋值操作
var bar = function() {
console.log('bar');
}
底层避坑原理解析:
请记住一句话:变量提升只提升声明,绝不提升赋值!
-
function foo()是一个纯粹的函数声明,它在编译期就整体飞升了。 -
var bar = function(){...}的本质是先声明了一个名字叫 bar 的普通变量,然后把一个匿名函数赋值给它。
在编译阶段,V8 引擎只会看到 var bar,所以它对待 bar 的态度和对待普通变量一模一样——在变量环境中给它塞一个 undefined。至于后面那个具体的匿名函数体,它是属于执行阶段的赋值内容,编译期根本不会理睬。
因此,如果你在定义之前强行调用 bar(),此时的 bar 在变量环境里还仅仅只是个 undefined,你实际上是在执行 undefined(),程序会瞬间当场崩溃,抛出 TypeError: bar is not a function 错误!
五、两段代码扒开“变量提升(Hoisting)”的真实轨迹
很多人听到“变量提升”这个词,脑海里脑补的画面是:V8 引擎在运行代码时,伸出一只无形的大手,把你的代码文本在物理层面一行行移动到了文件的最前面。
这其实是一个巨大的认知误区!
实际上,在编译阶段,你写的 JavaScript 代码在盘片上的位置是绝对不会发生任何物理改变的。所谓的“提升”,本质上是 V8 引擎在编译期,提前把变量和函数的声明塞进了执行上下文的内存(变量环境)中,并在内存里分配好了默认值。
为了彻底撕开变量提升的面具,我们用图形和等价代码重构的方式,把它的幕后运行机制扒得一清二楚。
1. var 声明的“物理拆解”
我们在写代码时,经常习惯把声明和赋值写在同一行,例如:var myname = '极客时间';。但在 V8 引擎的眼里,这一行代码从来都不是一个整体,它会被一刀切成两部分。
如上图所示,当 V8 引擎遇到一行 var 变量声明并赋值的代码时:
- 前半句
var myname(蓝色部分): 属于编译阶段的管辖范围。引擎会直接把myname登记到变量环境中,并无条件初始化为undefined。 - 后半句
= '极客时间'(红色部分): 属于执行阶段的管辖范围。赋值动作被牢牢留在原地,只有当代码真正执行到这一行时,内存里的值才会由undefined刷新为'极客时间'。
因此,当你写出如下代码时:
console.log(pizza);
var pizza = 'Deep Dish';
在执行阶段的真实运行轨迹,完全等价于下面这段重构后的代码:
var pizza; // 【编译阶段】声明被提升至顶部,默认初始化为 undefined
console.log(pizza); // 【执行阶段】去变量环境一查,直接打印出 undefined
pizza = 'Deep Dish'; // 【执行阶段】赋值留在原地,此时内存中的值才发生改变
2.函数声明提升 vs 函数表达式提升
当我们在代码中引入函数时,变量提升的机制会变得更加扑朔迷离。特别是传统的函数声明与把函数赋值给 var 的函数表达式(匿名函数),它们在编译期的待遇有着天壤之别。
我们通过下面这张物理拆解对比图,直观感受它们在内存形态上的巨大差异:
结合上图,我们来拆解 V8 引擎对这两种写法的幕后态度:
-
普通的函数声明
function foo()(图上栏): 这是一个纯粹的函数声明,没有涉及任何 = 赋值操作。由于函数是一等公民,它享受全套飞升特权——整个函数体在编译阶段就会被整体提升,并在变量环境里直接绑定到完整的函数对象上。 -
var 变量形式的函数表达式
var bar = function()(图下栏): 这段代码的本质是先声明了一个名字叫 bar 的普通变量,然后把一个匿名函数赋值给它。在编译阶段,V8 引擎只会把它当成普通的 var 变量看,因此仅仅在变量环境里给它塞一个 undefined!至于后面那个具体的匿名函数体,它是属于执行阶段的赋值内容,编译期根本不会理睬。
3. 复杂混合代码的“等价重构对照流”
为了检验我们是否真正掌握了 V8 引擎的运行机理,我们来看一段混合了普通变量、匿名函数表达式、以及完整函数声明的复杂原始代码。
我们通过俩段代码来看看它在执行阶段前,是如何被等价拆解的:
showName();
console.log(myName);
var myName = '极客时间';
function showName() {
console.log('函数showName被执行了');
}
💡 案例 B:V8 引擎编译后,在执行阶段的真正运行形态(等价重构)
// 1. 【编译阶段】函数声明一等公民,整体飞升,初始化为函数对象
function showName() {
console.log('函数showName被执行了');
}
// 2. 【编译阶段】var 变量声明提升,但只提升名字,默认初始化为 undefined
var myName;
// ======================== 以上变量环境准备就绪,以下进入正式执行 ========================
showName(); // ✅ 正常执行!去变量环境一查,showName 是个完整的函数,成功打印
console.log(myName); // ⚠️ 打印 undefined!去变量环境一查,myName 此时还是 undefined 状态
myName = '极客时间'; // 运行到这里,变量环境里的 myName 终于被正式赋值
即下图:
通过上述代码,我们可以清晰地看到,JavaScript 代码在编译阶段为执行上下文做足了准备。在执行阶段,引擎只需要严格按照重构后的物理顺序,去执行上下文里的变量环境和词法环境查表拿数即可。
六、 ES6 词法环境与暂时性死区
既然 var 的变量提升机制存在如此明显的代码可读性隐患,那么在 ES6 标准中推出的 let 和 const,究竟是如何在底层实现安全合法的拦截的?
我们在声明之前尝试访问一个 let 变量:
console.log(pizza);
// 直接抛出运行时错误:ReferenceError: Cannot access 'pizza' before initialization
let pizza = 'Deep Dish';
在面对这段代码时,很多人会认为:既然报了“初始化前无法访问”的错误,是不是意味着 let 和 const 就完全没有变量提升了
答案再次出乎意料:其实,let 和 const 在编译阶段同样会被 V8 引擎提前注意到,它们也有“声明提升”的动作!
1. 词法环境(Lexical Environment)的特殊标记
在第四章的底层全景图中,我们提到了执行上下文内部包含两个核心区域。
-
var声明的变量被放入了变量环境,并在编译期被无条件抹上了初始值undefined。 -
而
let和const声明的变量,在编译阶段被引擎扫描到后,会被放入专属的词法环境。
最关键的区别就在于初始化的态度: V8 引擎在编译期为词法环境中的 let/const 变量开辟空间时,绝对不会为它们关联任何默认值,而是给它们打上一个极其特殊的“未初始化(uninitialized)”死区标记。
2. 暂时性死区(TDZ)的物理真相
在执行阶段,当代码进入一个块级作用域时,一个无形的结界就已经拉开了。
从块级作用域的起始物理位置开始,直到执行到真正声明该变量的这一行代码(如 let pizza = 'Deep Dish';)之前的整段物理区域,在底层被称为暂时性死区(Temporal Dead Zone, TDZ)。
-
在这个死区内,凡是你的代码企图提前去访问、打印、或是操作该变量,V8 引擎都会在词法环境中查表。
-
一旦查到该变量的状态是**“未初始化(uninitialized)”**,引擎就会瞬间触发强行拦截防御机制,当场抛出运行时崩溃错误:
ReferenceError: Cannot access 'pizza' before initialization。
这便是 let / const 远比 var 安全的底层秘密。它打破了早期粗糙的设计瑕疵,通过底层死区标记拒绝了不安全的“变量提升历史包袱”,绝不让你在变量还没完全准备好的时候,糊里糊涂地拿到一个莫名其妙的 undefined!
七、案例分析
当我们在同一个作用域里,大模大样地写了两个同名的函数声明时,V8 引擎在编译阶段会如何处理?
案例一:
function showName() {
console.log('极客邦');
}
showName();
function showName() {
console.log('极客时间');
}
showName();
// //同名函数声明时,后面覆盖前面,提升后打印两个极客时间
这段代码的实际运行结果是连续打印出两个 '极客时间'!很多人误以为代码是顺序执行的,所以第一个调用会打印“极客邦”,这完全想错了。
在编译阶段,V8 引擎从上到下扫描:
-
扫描到第一个
function showName(),在变量环境中登记showName,并指向存储“极客邦”的函数对象。 -
扫描到第二个
function showName(),引擎发现变量环境中已经存在同名的showName。由于函数声明是全套飞升的特权阶级,后来的同名声明会无情地直接覆盖掉前面的引用指针,将其重新指向存储“极客时间”的全新函数对象。
因此,进入执行阶段后的等价重构代码完全如下:
// 【编译阶段】同名函数声明无情覆盖,最终留在内存里的是“后浪”
function showName() {
console.log('极客时间');
}
// ======================== 以上变量环境准备就绪,以下进入正式执行 ========================
showName(); // ✅ 去变量环境一查,指向的是“极客时间”,打印!
showName(); // ✅ 再次去变量环境一查,依然是“极客时间”,打印!
如果一个是纯粹的函数声明,另一个是包裹在 var 变量里的函数表达式,且它们不幸撞了名,谁的优先级更高?
案例二:
showName();
// //函数是一等对象,变量提升,优先于其他变量
var showName = function() {
console.log(2);
}
function showName() {
console.log(1);
}
这段代码的实际运行结果是成功打印出 1,而不是抛出 showName is not a function,也没有打印 2。
在编译阶段,V8 引擎遭遇了特权阶级与平民的交锋:
-
函数声明大步飞升: 引擎先扫描到底部的
function showName(),由于函数是一等公民,引擎直接在变量环境里塞入一个完整的showName函数对象(内部逻辑是console.log(1))。 -
var 声明默默低头: 接着引擎扫描到中间的
var showName。引擎一查,发现变量环境中已经有一个同名的showName且它是个完整的函数对象。此时,V8 引擎会选择忽略这次var的静默初始化动作,绝对不会用undefined去覆盖已经准备好的珍贵函数对象!
进入执行阶段后,它的等价重构形态非常清晰:
// 1. 【编译阶段】函数声明优先成真,完全压制同名 var 声明的默认初始化
function showName() {
console.log(1);
}
// ======================== 以上变量环境准备就绪,以下进入正式执行 ========================
showName(); // ✅ 此时去变量环境抓数,showName 是个完美函数,成功执行并打印 1!
// 执行到这一行时,普通的 `=` 赋值动作触发,内存中的 showName 彻底被重写覆盖为匿名的 2
showName = function() {
console.log(2);
}
通过这两段物理轨迹的模拟重构,我们可以彻底得出一个金牌结论:在编译阶段的变量环境中,函数声明的权重具有压倒性优势,它会无情覆盖同名函数,但会坚决拒绝被同名的 var 声明置空为 undefined!
八 、 总结:var、let、const 在编译与执行阶段的终极抉择方案
经过对 V8 引擎底层的全链路追踪,我们终于可以把 JavaScript 执行原理和变量声明的规律死记硬背下来。为了方便日常复习与面试速查,我们用一张横向综合大表进行全面复盘:
| 序号 | 声明方式 | 块级作用域支持 | 编译阶段的内存表现(VariableEnvironment vs LexicalEnvironment) | 执行阶段遭遇声明前访问 | 现代项目推荐指数 |
|---|---|---|---|---|---|
| 1 | var |
❌ 不支持 | 登记在变量环境中,并被静默初始化默认值:undefined。 |
⚠️ 不报错,但拿到的是本不该拿到的 undefined 值。 |
❌ 彻底淘汰 |
| 2 | let |
支持 | 登记在单独的词法环境中,被打上“未初始化(uninitialized)”的特殊死区标记。 | ❌ 直接拦截报错:ReferenceError: Cannot access before initialization |
🌟 推荐(仅用于计数器、或确实需要被 = 重写的变量) |
| 3 | const |
支持 | 同样在词法环境中,打上未初始化标记。要求声明时必须在物理层面完成 = 赋值初始化。 |
❌ 直接拦截报错,且在后续执行阶段严禁使用 = 符号重写整个对象指针。 |
🔥 绝对首选(常量、组件、函数声明、对象与数组引用) |
本期分享到此结束,我们下期再见👋
更多推荐

所有评论(0)