深入理解JavaScript执行机制:编译阶段与执行阶段的奥秘
在JavaScript的世界里,你是否曾困惑于"变量未定义却能使用"、"函数在声明前就能调用"等现象?这些看似违反直觉的行为背后,是V8引擎精心设计的执行机制在起作用。今天,让我们一起揭开JavaScript执行机制的神秘面纱。
一、JavaScript执行的两个阶段
JavaScript的执行分为两个关键阶段:编译阶段 和执行阶段。V8引擎在代码执行前的"霎那"进行编译,而不是像C++那样提前编译成机器码。
编译阶段 :检测语法错误、进行变量提升 执行阶段:真正执行代码
这个机制是JavaScript与C++/Java等编译型语言的重要区别------JavaScript是编译型语言,但执行在运行时。
二、变量提升:var的魔法
var声明的变量会进行变量提升,在编译阶段被提升到作用域顶部,初始化为undefined。
让我们看一个经典例子:
ini
var a = 1;
function fn(a) {
console.log(a); // 3
var a = 2;
console.log(a); // 2
}
fn(3);
console.log(a); // 1
这段代码的执行结果看似违反直觉,但背后是JavaScript引擎精心设计的执行机制在起作用。让我们一步步拆解这个过程。
编译阶段:引擎的"提前准备"
在JavaScript执行前,V8引擎会进行编译阶段,这相当于引擎在"阅读"代码时做的一次"预处理"。这个阶段不会执行代码,而是为执行阶段做准备。
全局作用域的编译
在全局作用域中,引擎会:
- 发现
var a = 1,将其提升为var a = undefined
编译结果 :var a = undefined
函数fn作用域的编译
在函数fn的作用域中,引擎会:
- 发现
var a = 2,将其提升为var a = undefined
编译结果 :var a = undefined
💡 关键点 :编译阶段只处理变量声明,将它们"提升"到作用域顶部,但不会执行赋值操作。变量被初始化为
undefined。
执行阶段:代码的真正运行
在编译完成后,引擎进入执行阶段,开始真正执行代码。
1. 全局代码执行
var a = 1:全局变量a被赋值为1fn(3):调用函数fn,传入参数3
2. 函数fn的执行过程
-
参数a的赋值 :
fn(3)调用时,参数a被设置为3- 此时,函数作用域中的
a(编译阶段提升的var a = undefined)被赋值为3
- 此时,函数作用域中的
-
第一次console.log(a) :打印
a,输出3- 这是函数参数
a的值(3)
- 这是函数参数
-
var a = 2 :将
a赋值为2- 这是函数作用域中的
a(不是参数),覆盖了参数值3
- 这是函数作用域中的
-
第二次console.log(a) :打印
a,输出2- 这是函数作用域中
a的最新值(2)
- 这是函数作用域中
3. 函数执行完毕
- 函数fn执行完毕,其作用域被销毁
- 全局作用域的
a值仍为1
4. 最后一步:console.log(a)
- 打印全局变量
a,输出1 - 这是全局作用域中的
a(var a = 1赋值的)
在函数fn中:
-
编译阶段:
var a = undefined和function a = function() {}都被提升 -
执行阶段:
var a = 2将a赋值为2(覆盖了编译阶段的undefined)function a() {}是函数声明,但不会覆盖已赋值的变量
这与C++等编译型语言不同,JavaScript的编译阶段不会立即执行赋值,而是将变量提升到作用域顶部,初始化为undefined,然后在执行阶段才进行实际赋值。
三、let/const:告别变量提升
let和const声明的变量不会进行变量提升,而是进入"暂时性死区"(TDZ),在声明前使用会报错。
javascript
// 严格模式下
'use strict'
var a = 1;
var a = 2; // 无错误,覆盖
let b = 3;
let b = 4; // 报错:Identifier 'b' has already been declared
console.log(a);
console.log(b);

在严格模式下,var重复声明不会报错,但会覆盖,console.log(a)结果为2;;而let重复声明会直接报错。这体现了let/const更严格的变量管理机制。
四、函数声明 vs 变量声明
在JavaScript中,变量提升和函数提升是两个看似相似但本质不同的概念。通过对比以下两段代码,我们将彻底理解它们的差异,特别是函数声明的完整提升特性。
代码1:
ini
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
console.log(a);
}
fn(3);
console.log(a);
代码2:
ini
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {};
console.log(a);
}
fn(3);
console.log(a);
这两段代码的唯一区别在于第二段代码中多了一个function a() {}。但结果却揭示了JavaScript中一个重要的机制:函数声明的完整提升。
代码1的编译结果
- 全局作用域:
var a = undefined - 函数fn作用域:
var a = undefined(函数参数a)和var a = undefined(函数内部var a)
代码2的编译结果
- 全局作用域:
var a = undefined - 函数fn作用域:
var a = undefined(函数参数a)、var a = undefined(函数内部var a)和function a = function() {}(函数声明a)
关键点 :函数声明function a() {}不仅被提升,整个函数体也被提升,而变量声明var a = 2只提升声明,不提升赋值。
执行阶段:
让我们一步步分析代码2的执行过程:
-
全局作用域:
var a = 1;:全局变量a被赋值为1
-
函数fn调用:
fn(3);:参数a被设置为3
-
函数fn内部执行:
console.log(a);:输出[Function: a](a被覆盖成了函数)var a = 2;:将函数内部的a赋值为2function a() {};:函数声明被提升,但不会覆盖已经赋值的变量console.log(a);:输出2(函数内部a的最新值,来自var a = 2)
-
函数执行完毕:
- 函数内部的a被销毁
-
全局作用域:
console.log(a);:输出1(全局变量a的值)
为什么函数声明不会覆盖变量赋值?
这是理解JavaScript执行机制的关键点:
- 函数声明:在编译阶段被提升,整个函数体(包括函数名和函数体)都被提升
- 变量声明:只提升声明,赋值操作留在原地执行
在代码2中,函数声明function a() {}在编译阶段被提升,但执行阶段var a = 2已经将a赋值为2,所以函数声明不会覆盖这个赋值。函数声明只是将a指向一个函数,但var a = 2已经将a指向了一个数字。
一个生动的比喻
想象一下,你有一个房间(作用域),里面有三个"标签":
- 一个标签写着"a"(函数参数)
- 一个标签写着"a"(函数内部变量)
- 一个标签写着"函数a"(函数声明)
在编译阶段,所有标签都被贴到了房间的顶部:
- 两个"a"标签(一个用于函数参数,一个用于函数内部变量)
- "函数a"标签
在执行阶段:
- 函数参数a被设置为3
- 函数内部变量a被赋值为2(覆盖了之前的"a"标签)
- "函数a"标签被贴在了墙上,但不会覆盖已经赋值的"数字2"标签
所以,当你打印a时,看到的是"2",而不是函数。
与let/const的对比
如果将代码2中的var替换为let:
ini
let a = 1;
function fn(a) {
console.log(a);
let a = 2;
function a() {};
console.log(a); // ReferenceError: Cannot access 'a' before initialization
}
fn(3);
这会抛出错误,因为let声明的变量不会被提升,而是进入"暂时性死区"(TDZ)。在let a = 2执行前,变量a处于TDZ中,访问它会报错。
结论:函数提升的完整特性
通过以上分析,我们可以清晰地总结:
- 变量提升 :只提升变量的声明,赋值留在原地执行。变量被初始化为
undefined。 - 函数提升:提升函数名和整个函数体,函数声明优先级高于变量声明。
- 函数声明与变量声明的关系:函数声明不会覆盖已经赋值的变量,但会覆盖未赋值的变量。
函数提升的"完整性"是其与变量提升的关键区别。函数声明不仅将函数名提升到作用域顶部,还将整个函数体(包括函数逻辑)提升,使我们可以在函数定义之前调用它。
理解这个机制,你就能避免许多JavaScript的"奇怪行为",写出更清晰、更可靠的代码。记住:函数提升是"完整的",而变量提升是"部分的"。这是JavaScript执行机制中最微妙但最重要的区别之一。
五、函数声明 vs 函数表达式
函数声明会进行提升,可以在声明前调用;而函数表达式不会提升。
scss
// 函数声明:提升
showName();
function showName() {
console.log('函数showName被执行');
}
// 函数表达式:不会提升
func();
let func = () => {
console.log('函数表达式不会提升');
};
// 会报错:func is not a function
这是因为函数声明在编译阶段被提升,而函数表达式只是普通变量赋值,需要等到执行阶段才会被赋值。
六、执行上下文与调用栈
JavaScript的执行依赖于执行上下文 ,包括变量环境 和词法环境。
- 全局执行上下文:在代码开始执行时创建
- 函数执行上下文:当函数被调用时创建
调用栈是管理执行上下文的数据结构,遵循"先进后出"原则:
- 全局执行上下文被压入调用栈
- 函数执行时,创建新的函数执行上下文压入栈
- 函数执行完毕,执行上下文出栈,变量被回收
七、简单数据类型与复杂数据类型
理解执行机制还需了解内存管理:
- 简单数据类型 (字符串、数字):存储在栈内存,直接存储值
- 复杂数据类型 (对象、数组):存储在堆内存,存储的是地址
ini
let str = 'hello';
let str2 = str; // 值拷贝
str2 = '你好';
console.log(str, str2);
let obj = { name: '郑老板', age: 18 };
let obj2 = obj; // 地址拷贝
obj2.age++;
console.log(obj, obj2);

对象是通过引用传递的,而不是通过值传递。这意味着:
obj是一个变量,它保存的是对象在内存中的地址(引用)let obj2 = obj;这行代码只是将obj的引用复制给obj2,并没有创建新的对象- 所以
obj和obj2都指向内存中同一个对象 - 当执行
obj2.age++时,实际上是在修改内存地址0x7F8A3B2C处的对象,所以obj和obj2都会看到这个修改。
八、为什么JavaScript要这样设计?
V8引擎设计JavaScript执行机制有其深意:
- 编译总是在执行前的一霎那:确保代码的即时性
- 全局和函数体的编译生成执行上下文:为执行做好准备
- 函数执行完毕后销毁执行上下文:实现变量垃圾回收
这种机制使得JavaScript既能像解释型语言一样灵活,又能保持一定的性能。
结语:理解机制,写出更优代码
JavaScript的执行机制看似复杂,实则有其逻辑。理解编译阶段与执行阶段的区别,掌握var/let/const、函数声明/表达式的不同,能帮助我们:
- 避免常见的"变量未定义"错误
- 优化代码结构,提高可读性
- 更好地理解作用域链和闭包
正如V8引擎的设计理念: "一边编译,后执行,在编译,再执行" 。理解了这个机制,你就不再被"奇怪的执行顺序"困扰,而是能预见代码的执行行为,写出更健壮、更高效的JavaScript代码。
记住,JavaScript不是简单的"从上到下执行",而是一个编译-执行的智能过程。掌握这个机制,你就能真正驾驭JavaScript,成为一名更优秀的前端开发者。