在很多前端开发者的印象里,JavaScript 是“解释执行”的脚本语言:代码从上往下执行即可。
但当你真正深入 V8 引擎,会发现这句话只对了一半。
JavaScript 在 执行之前,其实经历了一个极为关键的“编译阶段”——
这个阶段决定了 变量提升、作用域、函数声明优先级 等语言特性。
理解它,是写出无 bug、性能稳定、行为可预期代码的前提。
本文将带你从原理到实践,系统剖析 JS 的执行机制,最后配合 7 个经典样例逐行分析。
读完,你将彻底弄清楚:
- 为什么 JS 代码“写在前不一定先执行”;
- 为什么
var、let、const的表现差异如此之大; - 为什么某些变量是
undefined而不是报错; - 为什么函数声明优先级最高;
- 为什么对象和基本类型的赋值结果完全不同。
一、V8 引擎到底干了什么?
Chrome 使用的 V8 引擎 是目前最主流的 JS 引擎之一,它的任务是:
- 编译:将 JS 源码编译成更底层的可执行机器码。
- 执行:管理调用栈,执行代码逻辑,并分配/回收内存。
V8 在执行一段 JS 时,会划分出两个阶段:
1. 编译阶段
- 检查语法错误(Syntax Error);
- 变量提升(Hoisting);
- 创建执行上下文(Execution Context);
- 准备变量环境与词法环境。
2. 执行阶段
- 正式运行代码;
- 变量赋值;
- 函数调用;
- 上下文入栈/出栈;
- 垃圾回收。
V8 并不是一次性编译整个脚本,而是边编译、边执行的流式执行模式。
每当遇到一个函数调用,都会重新经历“编译 → 执行”的过程。
二、执行上下文与调用栈
理解 JS 的执行流程,关键在于两个概念:
1. 执行上下文(Execution Context)
它是 JS 在执行代码时的“运行环境容器”,分为三类:
- 全局执行上下文:程序一开始时创建;
- 函数执行上下文:每当函数调用时创建;
- Eval 执行上下文:少见,不建议使用。
每个执行上下文都包含三个重要部分:
- 变量环境(Variable Environment) :存储
var声明的变量; - 词法环境(Lexical Environment) :存储
let、const声明的变量; - this 绑定。
2. 调用栈(Call Stack)
调用栈是 V8 内部维护的一个 后进先出(LIFO) 结构:
- 全局上下文首先入栈;
- 函数调用时,新的上下文被压入栈顶;
- 函数执行完毕后出栈;
- 栈空时程序结束。
这就是为什么“函数执行完后变量会被销毁”的根本原因。
三、编译阶段的完整流程
以一段伪代码为例:
function fn(x) {
var y = 2;
let z = 3;
}
fn(10);
当 V8 看到这段代码时,它会做如下几步:
-
全局编译阶段
- 创建全局执行上下文;
- 在变量环境中注册
fn; - 暂不执行。
-
执行阶段
-
执行
fn(10); -
创建新的函数执行上下文;
-
编译
fn函数体:var y放入变量环境(初始值为undefined);let z放入词法环境(处于暂时性死区);- 形参
x与实参10绑定。
-
-
函数执行
y = 2;z = 3;- 函数执行完毕后,上下文出栈。
四、7 个经典样例逐行解析
例 1:函数声明与变量提升
showName();
console.log(myName);
console.log(hero);
var myName = 'zhangshan';
let hero = "钢铁侠";
function showName() {
console.log("函数showName被执行");
}
输出:
函数showName被执行
undefined
ReferenceError: Cannot access 'hero' before initialization
解析:
showName()函数声明在编译阶段就被提升;var myName被提升但值为undefined;let hero被提升但放入词法环境,在“暂时性死区”内;- 当访问
hero时,因尚未初始化而抛出错误。
例 2:为什么要有变量提升?
var myName;
function showName() {
console.log("函数showName被执行");
}
showName();
console.log(myName);
myName = "zhangshan";
输出:
函数showName被执行
undefined
解析:
编译阶段函数声明优先于变量声明,且 var 提升到顶层,初始化为 undefined。
这使得我们在定义前调用函数或访问变量不会报错,而只是未赋值。
例 3:函数作用域与变量提升细节
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
var b = a;
console.log(a);
}
fn(3);
输出:
3
2
解析:
编译阶段:
-
参数
a先于var a存在; -
var a提升但被参数同名遮蔽; -
执行时:
- 第一次
console.log(a)→ 输出参数3; a = 2后,第二次输出2。
- 第一次
如果把
var a = 2;改成function a(){},函数声明优先,会导致不同结果,这正是“函数声明优先于变量声明”的体现。
例 4:var 与 let 的重复声明对比
console.log(a);
console.log(b);
var a = 1;
var a = 2;
console.log(a);
let b = 3;
console.log(b);
输出:
undefined
ReferenceError: Cannot access 'b' before initialization
解析:
var a提升到顶层,初始化undefined;- 重复声明被忽略;
let b提升但处于暂时性死区;- 访问
b时抛出错误。
例 5:严格模式的不同表现
'use strict';
var a = 1;
var a = 2;
输出:
(正常执行,不报错)
在严格模式下,重复声明 var 仍然合法。
但如果是 let 或 const,则会直接报错。
严格模式更多的是防止隐式全局变量、禁止 this 指向 window、禁止重复参数名等。
例 6:函数表达式不会提升
func();
let func = () => {
console.log('函数表达式不会提升');
}
输出:
ReferenceError: Cannot access 'func' before initialization
解析:
箭头函数本质上是一个函数表达式,只有在执行阶段才会赋值给变量。
编译阶段,func 只是被记录在词法环境中,但未初始化。
访问时仍处于暂时性死区,导致错误。
例 7:基本类型与引用类型的内存差异
let str = 'hello';
let str2 = str;
str2 = '你好';
console.log(str, str2);
let obj = { name: '张三', age: 18 };
let obj2 = obj;
obj2.age++;
console.log(obj2, obj);
输出:
hello 你好
{ name: '张三', age: 19 } { name: '张三', age: 19 }
解析:
- 基本类型(Number、String、Boolean、Symbol、BigInt、Undefined、Null)存储在栈内存中,赋值时是值拷贝;
- 引用类型(Object、Array、Function)存储在堆内存中,赋值时是地址拷贝;
- 因此修改
obj2同时会影响obj。
五、为什么 JS 要设计“边编译边执行”?
因为 JS 是浏览器脚本语言,必须即时响应用户操作。
如果像 C++ 那样先整体编译再执行,网页响应速度会极慢。
于是,V8 采用了:
- 解释执行 + JIT(即时编译) 的混合模式;
- 在运行时动态优化热点代码;
- 将高频函数编译为高性能机器码。
这种机制让 JS 能既保持灵活,又兼顾性能。
六、总结:JS 执行机制的“三层模型”
| 层级 | 内容 | 关键特性 |
|---|---|---|
| 编译阶段 | 创建执行上下文、变量提升、函数提升 | var = undefined, let/const 暂时性死区 |
| 执行阶段 | 从上到下执行语句,赋值、调用函数 | 执行栈控制流程 |
| 内存层 | 栈(基本类型)、堆(引用类型) | 值拷贝 vs 地址拷贝 |
再加上调用栈这一结构,JS 就具备了清晰、可预测的执行逻辑:
- 编译总是发生在执行前的一瞬间;
- 全局与函数体都要创建自己的执行上下文;
- 函数执行完毕后上下文出栈并销毁;
- 变量提升、暂时性死区、作用域链 全都建立在这个机制之上。