从变量提升到调用栈:V8 引擎如何 “读懂” JS 代码

在 Chrome 浏览器中,JavaScript 代码的编译和执行全靠 V8 引擎 。不同于 C++、Java 等编译型语言,JS 作为脚本语言,编译过程发生在执行前的一霎那 ------ 这也造就了它独特的执行逻辑:代码编写顺序和实际执行顺序往往并不一致

今天我们就从代码实例出发,一步步拆解 V8 引擎如何处理 JS 代码,把变量提升、执行上下文、调用栈这些核心概念讲明白~

一、先编译,后执行:V8 引擎的 "预加工" 操作

JS 代码运行时,V8 引擎不会直接逐行执行,而是先进入 编译阶段 做准备工作,再进入 执行阶段 运行代码。编译阶段的核心任务有两个:

  1. 检测语法错误(比如漏写括号、变量未声明等);
  1. 变量提升(提前识别变量和函数,为执行阶段铺路)。

看个直观例子

ini 复制代码
// 先调用函数、访问变量,再定义它们
showName();
console.log(myName);
console.log(hero);
var myName = 'Asdj';
let hero = '钢铁侠';
function showName() {
    console.log('函数showName被执行'); 
}

按 "从上到下" 的编写逻辑,showName() 和 console.log(myName) 早该报错,但实际运行结果是:

  • showName() 正常执行(输出 "函数 showName 被执行");
  • console.log(myName) 输出 undefined;
  • console.log(hero) 报错(Cannot access 'hero' before initialization)。

这就是 变量提升 在起作用 ------V8 引擎在编译时,会把 var 声明的变量和函数声明 "提前" 到当前作用域顶部。

变量提升的规则

V8 引擎编译时,对不同声明的 "提升逻辑" 不同:

声明类型 提升行为
函数声明 优先级最高,完整提升整个函数体(可以在定义前直接调用)
var 变量声明 只提升 "声明",不提升 "赋值",初始值设为 undefined
let/const 声明 不提升,会进入 "暂时性死区"(TDZ),执行阶段前访问会直接报错

编译后的执行逻辑

上面的代码经过编译后,实际执行顺序是这样的:

javascript 复制代码
// 编译阶段:提升的内容(开发者看不到,是引擎内部操作)
function showName(){ // 函数声明完整提升
    console.log('函数showName被执行');
}
var myName; // var变量只提升声明,初始值undefined
// 执行阶段:按编译后的顺序执行
showName(); // 正常调用(函数已提升)
console.log(myName); // 输出undefined(只声明未赋值)
console.log(hero); // 报错(let未提升,处于暂时性死区)
myName = 'Asdj'; // 执行赋值操作(编译阶段不处理赋值)
let hero = '钢铁侠'; // let声明和赋值在执行阶段进行

为什么需要变量提升?

本质是为了适配 JS "边编译、边执行" 的脚本语言特性:

编译阶段提前识别变量和函数,能避免执行时因 "变量未定义" 导致的逻辑断裂,同时允许函数声明在定义前调用,提升代码编写的灵活性。

二、执行上下文:代码运行的 "环境容器"

编译阶段的核心产物是 执行上下文(Execution Context) ------ 它就像一个 "环境容器",包含了代码执行所需的所有信息(变量、函数、作用域、this 等)。

V8 引擎会为不同类型的代码创建对应的执行上下文:

  • 全局执行上下文:全局代码(不在任何函数内的代码)对应的上下文,页面加载时创建,直到页面关闭才销毁;
  • 函数执行上下文 :函数代码对应的上下文,每次调用函数时都会创建一个全新的实例,函数执行完毕后就会被销毁(垃圾回收)。

执行上下文的组成

每个执行上下文都包含 3 个核心部分:

  1. 变量环境(Variable Environment) :存储 var 声明的变量和函数声明,允许重复声明;
  1. 词法环境(Lexical Environment) :存储 let/const 声明的变量,不允许重复声明,存在暂时性死区;
  1. 可执行代码(Executable Code) :编译后等待执行的代码(去除了声明提升部分)。

用代码拆解执行上下文流程

我们用一段带函数的代码,一步步看执行上下文的创建、执行、销毁过程:

ini 复制代码
var a = 1;
function fn(a) {
  console.log(a);
  var a = 2;
  var b = a;
  console.log(a);
}
fn(3);
console.log(a);

步骤 1:全局执行上下文(编译阶段)

V8 引擎先编译全局代码,创建全局执行上下文:

  • 变量环境:a: undefined(var a 提升声明)、fn: 函数体(函数声明完整提升);
  • 词法环境:空(无 let/const 声明);
  • 可执行代码:全局代码(赋值、函数调用等逻辑)。

步骤 2:全局执行上下文(执行阶段)

全局执行上下文被压入调用栈后,开始执行代码:

  1. 执行 var a = 1:变量环境中 a 的值从 undefined 更新为 1;
  1. 执行 fn(3):调用函数,触发 函数执行上下文 的创建。

步骤 3:函数执行上下文(编译阶段)

编译 fn 函数内部代码,创建函数执行上下文:

  • 变量环境:形参 a: 3(实参赋值给形参)、b: undefined(var b 提升声明);
  • 词法环境:空(无 let/const 声明);
  • 可执行代码:函数内部代码(console.log(a)、var a = 2 等)。

步骤 4:函数执行上下文(执行阶段)

函数执行上下文被压入调用栈顶(优先执行),开始执行内部代码:

  1. 执行 console.log(a):读取变量环境中的形参 a,输出 3;
  1. 执行 var a = 2:变量环境中 a 的值从 3 更新为 2;
  1. 执行 var b = a:变量环境中 b 的值设为 2;
  1. 执行 console.log(a):读取变量环境中的 a,输出 2;
  1. 函数执行完毕:函数执行上下文从调用栈中弹出,被垃圾回收销毁。

步骤 5:回到全局执行上下文

继续执行全局代码:

  • 执行 console.log(a):读取全局变量环境中的 a,输出 1。

最终运行结果:3 → 2 → 1------ 这正是执行上下文 "创建 → 执行 → 销毁" 的完整流程体现。

三、调用栈:管理执行上下文的 "调度中枢"

V8 引擎用 调用栈(Call Stack) 来管理所有执行上下文 ------ 调用栈是一种 "先进后出(LIFO)" 的数据结构,能确保代码按正确顺序执行。

调用栈的工作流程

  1. 页面加载时,全局执行上下文 先被压入栈底;
  1. 调用函数时,创建对应的 函数执行上下文 并压入栈顶;
  1. 栈顶的执行上下文(当前正在执行的代码)优先执行;
  1. 函数执行完毕,其执行上下文从栈顶弹出并销毁;
  1. 所有代码执行完毕,调用栈中只剩全局执行上下文,页面关闭时弹出销毁。

用例子理解调用栈变化

javascript 复制代码
function a() {
  console.log('a执行');
  b(); // 调用函数b
}
function b() {
  console.log('b执行');
}
a(); // 调用函数a

调用栈的变化过程(可视化):

执行步骤 调用栈状态 说明
1 [全局执行上下文] 页面加载,全局上下文入栈
2 [全局,a] 调用 a (),a 的上下文入栈
3 [全局,a, b] a 中调用 b (),b 的上下文入栈
4 [全局,a] b 执行完毕,出栈
5 [全局] a 执行完毕,出栈
6 [] 页面关闭,全局上下文出栈

运行结果:a执行 → b执行------ 完全符合调用栈 "栈顶优先执行" 的规则。

四、var、let/const 的核心区别:从编译角度看

var 和 let/const 的差异,本质是 编译阶段存储位置不同(变量环境 vs 词法环境),我们用代码直观感受:

javascript 复制代码
// 1. 访问var和let变量
console.log(a); // undefined(变量环境,提升后初始值undefined)
console.log(b); // 报错(词法环境,暂时性死区)
// 2. 重复声明
var a = 1;
var a = 2; // var允许重复声明,覆盖值
console.log(a); // 输出2
let b = 3;
// let b = 4; // let不允许重复声明,报错
console.log(b); // 输出3
// 3. 严格模式下的var
'use strict';
var c = 1;
var c = 2; // 严格模式下,var仍允许重复声明
console.log(c); // 输出2
// 4. 函数表达式
func(); // 报错:func is not a function(函数表达式不提升)
let func = () => {
    console.log('函数表达式不会提升');
}

核心区别总结

特性 var let/const
提升行为 提升声明,初始值 undefined 不提升,进入暂时性死区
重复声明 允许(无报错,覆盖值) 不允许(直接报错)
存储位置 变量环境 词法环境
函数表达式 仅提升变量声明(值为 undefined) 不提升(调用时报错)

五、数据类型存储:栈内存与堆内存的差异

JS 中简单数据类型和复杂数据类型的存储方式不同,导致赋值时出现 "值拷贝" 和 "引用拷贝" 的差异 ------ 本质是 栈内存堆内存 的使用逻辑不同。

先看代码例子

ini 复制代码
// 1. 简单数据类型(字符串、数字、布尔等)
let str = 'hello';
let str2 = str; // 值拷贝:复制栈内存中的值
str2 = '你好'; // 修改str2的栈内存值,不影响str
console.log(str, str2); // 输出:hello 你好
// 2. 复杂数据类型(对象、数组、函数等)
let obj = { 
    name: '老板',
    age: 18
};
let obj2 = obj; // 引用拷贝:复制栈内存中的地址(指向堆内存数据)
obj2.age++; // 通过地址修改堆内存数据,obj也受影响
console.log(obj2, obj); // 两者age都是19

栈内存与堆内存的区别

内存类型 特点 存储内容
栈内存 空间小、读取快 简单数据类型的 "值"、复杂数据类型的 "地址"
堆内存 空间大、存储复杂 复杂数据类型的 "实际数据"(如对象的键值对)

简单理解:

  • 简单数据类型:变量直接持有 "值"(存在栈里),赋值时复制 "值";
  • 复杂数据类型:变量持有 "地址"(存在栈里),"地址" 指向堆内存中的实际数据,赋值时复制 "地址"(两个变量指向同一堆数据)。

总结:V8 引擎执行 JS 的完整流程

V8 引擎执行 JS 代码的全过程可以概括为 4 步:

  1. 编译阶段:接管代码,检测语法错误,创建执行上下文(变量环境存 var 和函数声明,词法环境存 let/const),完成变量提升;
  1. 调用栈调度:全局执行上下文压入栈底,函数调用时创建函数执行上下文并压入栈顶;
  1. 执行阶段:栈顶执行上下文对应的代码逐行执行(变量赋值、函数调用等);
  1. 销毁阶段:函数执行完毕,其执行上下文出栈并销毁,全局执行上下文在页面关闭时销毁。
相关推荐
白兰地空瓶2 小时前
【深度揭秘】JS 那些看似简单方法的底层黑魔法
前端·javascript
进阶的小叮当2 小时前
Vue代码打包成apk?Cordova帮你解决!
android·前端·javascript
天天进步20152 小时前
从零开始构建现代化React应用:最佳实践与性能优化
前端·react.js·性能优化
程序媛_MISS_zhang_01103 小时前
浏览器开发者工具(尤其是 Vue Devtools 扩展)和 Vuex 的的订阅模式冲突
前端·javascript·vue.js
fruge3 小时前
Vue3.4 Effect 作用域 API 与 React Server Components 实战解析
前端·vue.js·react.js
神秘的猪头3 小时前
🌐 CSS 选择器详解:从基础到实战
前端·javascript
Zyx20073 小时前
JavaScript 执行机制深度解析(上):编译、提升与执行上下文
javascript
远山枫谷3 小时前
CSS选择器优先级计算你真的会吗?
前端·css
Forever_xl3 小时前
埋点监控平台全景调研
前端