作者:前端工程师
平台:稀土掘金
适用人群:初级~中级前端开发者,对 JS 执行机制有基础了解但想深入原理的同学
在日常开发中,你是否遇到过如下“诡异”现象?
js
编辑
console.log(myName); // undefined
var myName = "Mike";
或者:
js
编辑
showName(); // "函数showName被调用"
function showName() {
console.log("函数showName被调用");
}
而换成 let 却直接报错?
js
编辑
console.log(hero); // ReferenceError: Cannot access 'hero' before initialization
let hero = "Superman";
这些行为的背后,其实是 JavaScript 的执行机制 在起作用。本文将结合 V8 引擎的工作流程,从编译阶段、执行上下文、变量提升等角度,系统性地解析 JS 是如何一步步执行代码的。
一、JS 不是“解释型语言”那么简单
很多人说 JS 是“解释型语言”,但现代 JS 引擎(如 V8)早已不是一行行解释执行了。V8 实际上采用“即时编译”(JIT)策略:先快速编译,再优化执行。
JS 的执行分为两个关键阶段:
- 编译阶段(Compilation Phase)
- 执行阶段(Execution Phase)
这两个阶段几乎“无缝衔接”,但理解它们的差异,是掌握 JS 行为的关键。
二、编译阶段:变量提升的本质
2.1 什么是“变量提升”?
所谓“变量提升”,其实是 编译阶段对 var 和函数声明的预处理行为。
来看这段经典代码:
js
编辑
console.log(myName); // undefined
var myName = "Mike";
V8 在编译阶段会做以下处理:
- 创建 全局执行上下文(Global Execution Context)
- 扫描代码,发现
var myName→ 在变量环境中注册myName = undefined - 发现赋值语句
myName = "Mike"→ 留到执行阶段处理
所以实际执行顺序等价于:
js
编辑
var myName; // 编译阶段:提升并初始化为 undefined
console.log(myName); // 执行阶段:输出 undefined
myName = "Mike"; // 执行阶段:赋值
✅ 关键点:
var声明会被提升并初始化为undefined;赋值不会提升。
2.2 函数声明 vs 函数表达式
函数声明具有 更高优先级,不仅提升名称,还提升整个函数体:
js
编辑
showName(); // "函数showName被调用"
function showName() {
console.log("函数showName被调用");
}
编译后等价于:
js
编辑
function showName() { ... } // 整个函数被提升
showName(); // 正常调用
但如果是函数表达式(用 var/let 赋值):
js
编辑
func(); // TypeError: func is not a function
var func = function() {
console.log('函数表达式');
};
因为 var func 只提升变量名(值为 undefined),函数体未被提升。
⚠️ 使用
let定义函数表达式更危险:js 编辑 func(); // ReferenceError let func = () => {};因为
let存在 暂时性死区(TDZ) ,下文详述。
三、var vs let/const:提升机制的根本差异
| 特性 | var | let / const |
|---|---|---|
| 是否提升 | 是(提升到作用域顶部) | 是(但不初始化) |
| 初始化值 | undefined | 不初始化(进入 TDZ) |
| 重复声明 | 允许(覆盖) | 报错 |
| 作用域 | 函数作用域 | 块级作用域 |
3.1 案例对比
js
编辑
console.log(a); // undefined
var a = 1;
console.log(b); // ReferenceError!
let b = 2;
为什么 let 报错?因为:
let b在编译阶段被放入 词法环境(Lexical Environment)- 但在执行到
let b = 2之前,b处于 暂时性死区(Temporal Dead Zone, TDZ) - 任何访问都会抛出
ReferenceError
📌 TDZ 是 ES6 为避免变量未初始化就被使用而设计的安全机制。
四、函数调用与执行上下文栈
4.1 调用栈(Call Stack)的工作流程
V8 使用 调用栈 管理函数执行:
- 全局代码 → 创建 全局执行上下文,压入栈底
- 调用函数 → 创建 函数执行上下文,压入栈顶
- 函数执行完毕 → 上下文出栈,内存回收
4.2 案例:参数、变量、函数的优先级
js
编辑
var a = 1;
function fn(a) {
console.log(a); // ?
var a = 2;
// function a() {};
console.log(a); // ?
}
fn(3);
执行过程分析 重难点!!!:
-
全局上下文:
a = 1 -
调用
fn(3)→ 创建函数上下文- 形参
a接收实参3 - 编译阶段发现
var a→ 但形参已存在,不会重复声明 - 所以
a初始值为3
- 形参
-
执行
console.log(a)→ 输出3 -
执行
var a = 2→ 赋值,a变为2 -
第二次
console.log(a)→ 输出2
✅ 输出结果:
3
2
💡 如果取消注释
function a() {},由于 函数声明优先级高于变量和参数,a会被覆盖为函数! 结果就会变成[Function: a]
2让我们总结一下 编译阶段的工作流程
-
一段代码, 由V8引擎接管
- JS调用栈来管理JS执行 以函数单位
- 编译阶段
- 创建一个全局执行上下文对象
- 变量环境 a = underfined fn()
- 词法环境 空
- 可执行代码
- 创建一个全局执行上下文对象
-
fn(3) 执行阶段
- 创建新的函数执行上下文对象入栈 (编译阶段)
-
变量环境 函数是一等对象 函数声明更优先 a是参数
- a = underfined 因为var a 变量提升
- a = 3 因为参数给了a
- a = function 发现在该编译阶段中还有的函数声明 函数声明更优先
b = undefined
var 允许重复声明
-
词法环境
-
代码
-
- 创建新的函数执行上下文对象入栈 (编译阶段)
-
fn 执行阶段
-
a function -> 2
b = a = 2 (值拷贝)
a =2(赋值)
五、实战:常见面试题解析
题目1:变量提升 + 函数提升
js
编辑
showName();
console.log(myName);
var myName = "Mike";
let hero = "Superman";
function showName() {
console.log("函数showName被调用");
}
执行顺序:
-
编译阶段:
- 提升
function showName()→ 可直接调用 - 提升
var myName→myName = undefined let hero进入 TDZ(但未访问,不报错)
- 提升
-
执行阶段:
showName()→ 正常输出console.log(myName)→undefined- 赋值
myName = "Mike"、hero = "Superman"
✅ 输出:
text
编辑
函数showName被调用
undefined
题目2:重复声明
js
编辑
var a = 1;
var a = 2;
console.log(a); // 2
let b = 1;
// let b = 2; // SyntaxError: Identifier 'b' has already been declared
var允许重复声明(后声明覆盖前声明)let/const不允许,这是块级作用域的重要保障
六、总结要点
| 概念 | 说明 |
|---|---|
| 编译阶段 | V8 扫描代码,创建执行上下文,处理提升 |
| 变量提升 | var 提升并初始化为 undefined;let/const 提升但不初始化(TDZ) |
| 函数提升 | 函数声明整体提升,优先级最高 |
| 执行上下文 | 包含变量环境(Variable Environment)和词法环境(Lexical Environment) |
| 调用栈 | 管理函数执行顺序,先进后出(LIFO) |
| TDZ | let/const 在声明前不可访问,防止意外使用未初始化变量 |
七、拓展思考
7.1 为什么设计 TDZ?
早期 var 的提升机制导致很多 bug(如意外覆盖、未定义使用)。ES6 引入 let/const 和 TDZ,强制开发者在声明后再使用变量,提升代码健壮性。
7.2 var 还能用吗?
- 在现代项目中,推荐全部使用
let/const var仅用于兼容老代码或特殊场景(如全局挂载)
7.3 如何避免提升陷阱?
- 始终在作用域顶部声明变量(即使使用
let) - 启用 ESLint 规则:
no-var、no-use-before-define - 理解“提升”不是代码移动,而是编译行为
八、配图建议(供排版参考)
- 调用栈示意图:展示全局上下文 → 函数上下文入栈 → 出栈过程
- 执行上下文结构图:包含变量环境、词法环境、this 绑定等
- TDZ 示意图:用时间轴展示
let x声明前后访问的区别
(注:掘金支持 Markdown 插图,可使用 Mermaid 或手绘图辅助说明)
九、总结🧠 :JavaScript 的执行机制核心要点
JavaScript 并非“边解释边执行”,而是采用 “编译 + 执行”交替进行 的模式。V8 引擎在代码真正运行前的一瞬间完成编译,这与 C++/Java 等传统编译型语言(先完整编译再执行)有本质区别。
1. 调用栈:V8 管理执行流程的核心数据结构
- 编译总是在执行前发生:哪怕只有一行代码,V8 也会先快速编译,再执行。
- 每个可执行单元(全局代码或函数)都会创建对应的执行上下文,并压入调用栈。
- 函数执行完毕后,其执行上下文立即出栈并被销毁,相关变量随之进入垃圾回收流程。
2. 编译阶段的关键步骤(以函数为例)
当 V8 编译一段代码时,会按以下顺序构建执行上下文:
-
创建执行上下文对象
包含变量环境(Variable Environment)和词法环境(Lexical Environment)。 -
处理参数与变量声明
var声明的变量会被提升到变量环境,并初始化为undefined。let/const声明的变量会被放入词法环境,但处于暂时性死区(TDZ) ,直到执行到声明语句才可访问。
-
绑定实参与形参
函数调用时,实参会覆盖形参的初始值(仅限函数上下文;全局上下文无此步骤)。 -
处理函数声明
函数声明具有最高优先级:函数名作为标识符,其值为完整的函数体,并覆盖同名的变量或参数。
💡 一句话记住核心差异:
var是“提前报到但没上岗(undefined)”,
let/const是“人到了但被锁在门口(TDZ)”,
函数声明则是“直接空降 CEO”。
结语
JavaScript 的执行机制看似简单,实则暗藏玄机。理解 V8 引擎如何处理编译与执行,不仅能帮你写出更可靠的代码,还能在面试中脱颖而出。
记住:JS 不是“一边解释一边执行”,而是“先编译,再执行” 。掌握这个底层逻辑,你就站在了进阶之路的起点。
欢迎点赞、收藏、评论交流!
如果你觉得有用,不妨分享给正在被“变量提升”困扰的小伙伴 👇