吃透 JS 执行机制:从编译到执行,V8 引擎到底做了什么?
作为前端开发者,你一定遇到过这些困惑:为什么console.log(a)在var a = 1前执行不会报错,只是输出undefined?为什么let声明的变量提前访问就会报错?函数声明为什么总能优先于变量被访问? 这些问题的本质,都指向 JS 的核心执行机制 ——V8 引擎的编译阶段和执行阶段。本文会从底层逻辑出发,结合 V8 引擎的工作流程、执行上下文、调用栈等核心概念,把 JS 执行机制讲得明明白白,新手也能轻松理解。
一、JS 执行的核心:不是 “边写边执行”,而是 “先编译后执行”
很多人误以为 JS 作为 “脚本语言” 是逐行执行的,但实际上,V8 引擎在执行 JS 代码前,会先经历编译阶段,再进入执行阶段—— 这也是 JS 执行机制的核心。
1.1 先明确两个核心角色
- V8 引擎:Chrome/Node.js 的 JS 执行内核,负责 JS 代码的编译和执行;
- 执行流程:代码编写顺序 ≠ 执行顺序,编译阶段会提前处理变量、函数声明,为执行阶段做准备。
1.2 JS 执行的两个核心阶段
| 阶段 | 核心工作 | 执行时机 |
|---|---|---|
| 编译阶段 | 检测语法错误、变量 / 函数提升、创建执行上下文、初始化变量环境 / 词法环境 | 代码执行前的 “一霎那” |
| 执行阶段 | 按顺序执行代码、给变量赋值、执行函数调用、销毁执行上下文(垃圾回收) | 编译完成后,逐行执行 |
关键区别:JS 的编译不是像 Java/C++ 那样 “一次性编译成机器码”,而是边编译、边执行(准确说是 “编译
二、V8 引擎的核心设计:执行上下文与调用栈
要理解编译 / 执行阶段,必须先掌握 “执行上下文” 和 “调用栈”—— 这是 V8 管理 JS 执行的两大核心机制。
2.1 执行上下文:代码的 “运行环境容器”
一段可执行的 JS 代码(全局代码 / 函数代码),会被 V8 包裹成一个执行上下文对象,这个对象包含代码执行所需的所有信息:
- 变量环境(Variable Environment):存储
var声明的变量、函数声明、参数; - 词法环境(Lexical Environment):存储
let/const声明的变量(暂时性死区的核心载体); this指向、作用域链、可执行代码等。
执行上下文分为两类:
- 全局执行上下文:程序启动时创建,整个程序只有 1 个,直到页面关闭 / 进程结束才销毁;
- 函数执行上下文:每次调用函数时创建,函数执行完毕后销毁(局部变量随之回收)。
2.2 调用栈:执行上下文的 “管理者”
V8 用调用栈(Call Stack) 来管理执行上下文的入栈、执行、出栈,遵循 “先进后出” 的栈规则:
-
- 程序启动,全局执行上下文先被压入调用栈(栈底,永不提前出栈);
-
- 遇到函数调用,创建对应的函数执行上下文,压入调用栈(栈顶);
-
- 栈顶的执行上下文优先执行,执行完毕后出栈,垃圾回收机制回收局部变量;
-
- 所有代码执行完毕,只剩全局执行上下文在栈底。
调用栈执行流程示例:
var a = 1;
function fn() {
console.log(a);
}
fn(); // 函数调用
- 第一步:全局执行上下文入栈(栈底);
- 第二步:调用fn(),fn的函数执行上下文入栈(栈顶);
- 第三步:执行fn的代码,输出1,执行完毕后fn的上下文出栈;
- 第四步:全局代码执行完毕,等待页面关闭后全局上下文出栈。
三、编译阶段:V8 到底做了哪些准备工作?
编译阶段是 “变量提升”“暂时性死区” 的核心原因,我们以一段代码为例,拆解编译阶段的完整流程:
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
var b = a;
}
fn(3);
3.1 全局代码的编译阶段
全局代码编译时,V8 会创建全局执行上下文对象,并完成以下工作:
- 初始化变量环境: ◦ 扫描var声明:a被提升,初始值为undefined; ◦ 扫描函数声明:fn被完整提升,值为函数体(函数声明优先级 > 变量声明); ◦ 绑定this:全局this指向window(浏览器)/global(Node)。
- 初始化词法环境:全局无let/const,词法环境为空;
- 检测语法错误:若有语法错误(如少分号、括号不匹配),直接终止执行。 此时全局执行上下文的变量环境:
全局变量环境 = {
a: undefined,
fn: [Function: fn]
}
3.2 函数代码的编译阶段(调用fn(3)时)
调用fn(3)时,V8 会立即编译fn的函数代码,创建函数执行上下文对象,步骤如下:
-
处理形参和 arguments:
- 形参
a绑定实参值3; - 创建
arguments对象:arguments = [3]。
- 形参
-
初始化变量环境:
- 扫描
var声明:var a仅做声明(不覆盖形参a的3),var b初始值为undefined; - 若有函数声明(如
function a() {}),则函数声明优先级最高,会覆盖形参 / 变量(后文示例)。
- 扫描
-
初始化词法环境:函数内无
let/const,词法环境为空; -
绑定作用域链:函数上下文的作用域链 = 自身变量环境 → 全局变量环境。
此时函数执行上下文的变量环境:
fn变量环境 = {
a: 3, // 形参赋值优先于var声明
b: undefined
}
3.3 变量提升的优先级规则
编译阶段的 “提升优先级” 是核心,记住这个顺序:函数声明 > 函数参数 > var 变量声明 示例验证(含函数声明):
var a = 1;
function fn(a) {
console.log(a); // 输出:[Function: a]
var a = 2;
function a() {}; // 函数声明
}
fn(3);
编译fn时,变量环境初始化顺序:
- 形参a赋值为3;
- 函数声明function a() {}覆盖形参a,a变为函数体;
- var a仅声明,不改变已有值; 因此第一个console.log(a)输出函数体,而非3。
四、执行阶段:按顺序赋值与执行
编译阶段完成后,V8 进入执行阶段,核心工作是 “给变量赋值” 和 “执行代码逻辑”—— 此时才是真正的 “逐行执行”。
4.1 全局代码的执行阶段
还是以之前的代码为例:
var a = 1; // 编译阶段a已提升为undefined
function fn(a) { /* 函数体 */ }
fn(3);
执行阶段流程:
- 执行var a = 1:将全局变量环境中a的值从undefined改为1;
- 执行function fn(...):函数声明已在编译阶段完成,无额外操作;
- 执行fn(3):触发fn的函数执行上下文创建(编译)→ 执行fn的代码。
4.2 函数代码的执行阶段
以fn(3)为例,编译阶段已准备好变量环境,执行阶段逐行处理:
function fn(a) {
console.log(a); // 编译后a=3 → 输出3
var a = 2; // 赋值:将变量环境中a的值从3改为2
var b = a; // 赋值:将b的值从undefined改为2
}
执行阶段的核心特点:
- 仅处理 “赋值操作”,不处理 “声明操作”(声明已在编译阶段完成);
- 变量查找遵循 “作用域链”:先找自身变量环境,找不到再找全局。
五、var vs let/const:编译阶段的核心区别
var和let/const的差异,本质是编译阶段 “存储位置” 和 “初始化规则” 不同:
| 特性 | var | let/const |
|---|---|---|
| 存储位置 | 变量环境 | 词法环境 |
| 初始化时机 | 编译阶段初始化为 undefined | 编译阶段未初始化(暂时性死区) |
| 提升特性 | 完全提升(可提前访问,值为 undefined) | 部分提升(提前访问报错) |
| 重复声明 | 允许 | 不允许 |
5.1 暂时性死区(TDZ):let/const 的核心特性
let/const声明的变量存储在词法环境中,编译阶段仅 “创建绑定”(登记变量名),但不初始化 —— 此时变量处于 “暂时性死区”,提前访问会报错:
console.log(b); // 报错:Cannot access 'b' before initialization
let b = 4; // 执行到这一行,b才脱离死区,完成初始化
5.2 示例对比:var vs let
// var:编译阶段初始化undefined,可提前访问
console.log(a); // undefined
var a = 1;
// let:编译阶段未初始化,提前访问报错
console.log(b); // ReferenceError
let b = 2;
六、JS 执行机制的核心总结
- 执行规则:JS 不是纯逐行执行,而是 “一段代码先编译,后执行”—— 编译在执行前的一霎那完成,编译阶段做准备,执行阶段做赋值;
- 核心载体:执行上下文是代码的运行环境,包含变量环境 / 词法环境;调用栈管理执行上下文的入栈、执行、出栈;
- 提升规则:编译阶段的变量 / 函数提升有优先级(函数声明 > 参数 > var),let/const 仅部分提升(存在暂时性死区);
- 垃圾回收:函数执行上下文出栈时,局部变量会被垃圾回收,全局变量需手动清理或页面关闭后回收。