JS 代码是怎么跑起来的?拆解 V8 引擎的编译与执行逻辑

26 阅读8分钟

如果你写过 JavaScript 代码,可能会遇到这样的困惑:明明变量在后面声明,前面却能访问到;函数还没定义,调用却不报错;let 声明的变量提前用会直接报错…… 这些 “奇怪” 的现象,其实都和 JS 的执行机制有关。

JavaScript 作为一门 “边编译边执行” 的脚本语言,其运行过程并不像我们看到的那样 “从上到下逐行执行”。今天我们就以 Chrome 的 V8 引擎为例,拆解 JS 代码从编写到运行的全过程,搞懂编译阶段到底做了什么,执行阶段又遵循什么规则。

一、谁在 “驾驶” JS 代码?V8 引擎的角色

当我们在浏览器中运行 JS 代码时,真正负责处理代码的是浏览器内置的 JavaScript 引擎。以 Chrome 为例,它使用的 V8 引擎就像一个 “代码处理器”,主要负责两件事:编译代码执行代码

和 C++、Java 等编译型语言不同,JS 的编译不会在代码运行前单独完成,而是 “在执行前的一霎那” 快速完成编译,然后立即执行。这种 “边编译边执行” 的特性,让 JS 成为了灵活的脚本语言,但也带来了很多需要理解的执行细节。

二、编译阶段:代码执行前的 “准备工作”

想象一下,你要做一道菜,不会直接拿起食材就炒,而是会先准备好锅碗瓢盆、处理好食材 ——JS 的编译阶段就像这个 “准备过程”。V8 引擎在执行代码前,会先对代码进行编译,主要做三件事:检测语法错误创建执行上下文处理变量和函数的 “提升”

1. 什么是执行上下文?

执行上下文(Execution Context)是 V8 引擎为代码执行创建的 “环境容器”。任何 JS 代码(全局代码、函数代码)执行时,都会被包裹在一个执行上下文里。这个容器里包含了代码执行所需的所有信息,比如变量、函数、this 指向等。

可以把执行上下文理解为 “代码的运行舞台”:全局代码有 “全局执行上下文”,每个函数调用时会创建 “函数执行上下文”,它们共同组成了代码的运行环境。

2. 编译阶段的核心工作:变量环境与词法环境

执行上下文在编译阶段会初始化两个重要部分:变量环境(Variable Environment)  和词法环境(Lexical Environment) 。这两个环境的作用是 “提前处理变量和函数声明”,为执行阶段做准备。

  • 变量环境:主要存放用var声明的变量和函数声明。
  • 词法环境:主要存放用letconst声明的变量(这也是它们和var的核心区别之一)。

案例 1:var 的 “变量提升”

看这段代码:

javascript

运行

console.log(a); // 输出:undefined
var a = 1;

为什么a还没赋值就能被访问,且输出undefined

编译阶段,V8 引擎会做这些事:

  1. 扫描代码,发现var a的声明,在变量环境中创建一个a,并赋值undefined(这就是 “变量提升”)。
  2. 函数声明(如果有的话)会被优先处理,直接将函数体存入变量环境(函数提升比变量提升优先级高)。

到了执行阶段,代码逐行运行:

  • 执行console.log(a)时,变量环境中已有a: undefined,所以输出undefined
  • 执行a = 1时,才会把1赋值给变量环境中的a

案例 2:let/const 的 “暂时性死区”

再看这段代码:

javascript

运行

console.log(b); // 报错:Cannot access 'b' before initialization
let b = 2;

为什么let声明的变量提前访问会报错?

编译阶段,let b会被存入词法环境,但和var不同:

  • 词法环境中的b不会被赋值undefined,而是处于 “未初始化” 状态。
  • 从代码开始到let b声明这一段,被称为 “暂时性死区”,在这个区域访问b会直接报错。

执行阶段,只有当代码运行到let b = 2时,词法环境中的b才会被赋值2

案例 3:函数声明的 “优先提升”

函数声明的提升优先级比变量高,看这个例子:

javascript

运行

console.log(fn); // 输出:[Function: fn]
var fn = 1;
function fn() {}

编译阶段:

  1. 先处理函数声明function fn(),在变量环境中创建fn,值为函数体。
  2. 再处理var fn,但变量环境中已有fn(函数),var允许重复声明,所以不做修改(变量提升被函数提升覆盖)。

执行阶段:

  • console.log(fn)输出函数体。
  • 执行fn = 1时,变量环境中的fn才被赋值为1

3. 函数执行上下文的编译细节

当调用函数时,编译阶段会额外处理参数。看这个函数调用:

javascript

运行

function fn(a) {
  console.log(a); // 输出:function a() {}
  var a = 2;
  function a() {}
  console.log(a); // 输出:2
}
fn(3);

编译fn的执行上下文时,步骤是:

  1. 处理参数:形参a被存入变量环境,先赋值为实参3
  2. 处理函数声明:function a()被存入变量环境,覆盖参数a的值(现在a是函数)。
  3. 处理变量声明:var a发现变量环境中已有a,不做修改。

执行阶段:

  • 第一行console.log(a)输出函数(变量环境中当前a是函数)。
  • 执行a = 2时,变量环境中的a被赋值为2,第二行输出2

三、执行阶段:调用栈如何管理代码运行?

编译阶段做好准备后,代码就进入执行阶段。V8 引擎用调用栈(Call Stack)  来管理执行上下文的执行顺序 —— 调用栈是一种 “先进后出” 的数据结构,就像叠盘子:先放的盘子在最下面,最后放的在最上面,取的时候也先取最上面的。

1. 调用栈的工作流程

  • 第一步:全局代码执行前,全局执行上下文被压入调用栈(栈底)。
  • 第二步:当遇到函数调用时,创建该函数的执行上下文,压入调用栈(栈顶),优先执行栈顶的函数。
  • 第三步:函数执行完毕后,其执行上下文从调用栈中弹出(出栈),变量被垃圾回收。
  • 第四步:所有代码执行完,全局执行上下文弹出,调用栈清空。

案例:嵌套函数的调用栈变化

javascript

运行

function func3() {
  console.log('func3执行'); // 步骤3
}

function func2() {
  console.log('func2执行'); // 步骤2
  func3(); // 调用func3,创建func3执行上下文
}

function func1() {
  console.log('func1执行'); // 步骤1
  func2(); // 调用func2,创建func2执行上下文
}

func1(); // 调用func1,创建func1执行上下文

调用栈的变化过程:

  1. 全局执行上下文入栈(栈:[全局])。
  2. 执行func1(),func1 执行上下文入栈(栈:[全局,func1]),输出 “func1 执行”。
  3. func1 中调用func2(),func2 执行上下文入栈(栈:[全局,func1, func2]),输出 “func2 执行”。
  4. func2 中调用func3(),func3 执行上下文入栈(栈:[全局,func1, func2, func3]),输出 “func3 执行”。
  5. func3 执行完,出栈(栈:[全局,func1, func2])。
  6. func2 执行完,出栈(栈:[全局,func1])。
  7. func1 执行完,出栈(栈:[全局])。
  8. 全局代码执行完,全局执行上下文出栈,调用栈清空。

四、var、let、const 的核心区别

通过前面的分析,我们可以总结三者的区别:

特性varlet/const
提升方式提升到变量环境,赋值 undefined提升到词法环境,未初始化(暂时性死区)
重复声明允许不允许(同一作用域报错)
作用域函数级作用域块级作用域({} 内有效)
初始值可以不初始化const 必须初始化,let 可以不初始化

五、总结:JS 执行机制的核心逻辑

JS 代码的运行过程可以概括为 “编译 - 执行 - 再编译 - 再执行” 的循环,核心逻辑有三点:

  1. 编译先行:任何代码执行前,V8 引擎都会先进行编译,创建执行上下文,处理变量和函数的提升(var 在变量环境,let/const 在词法环境)。
  2. 调用栈管理:执行上下文通过调用栈管理,全局上下文先入栈,函数调用时新上下文入栈,执行完出栈,遵循 “先进后出” 规则。
  3. 动态执行:JS 边编译边执行,函数每次调用都会重新编译并创建新的执行上下文,这让代码更灵活,但也需要注意变量提升和作用域问题。

理解了这些机制,你就能解释为什么变量提前访问会有不同结果,为什么函数能在声明前调用,也能更清晰地排查代码中的执行顺序问题。JS 的执行机制看似复杂,其实都是 V8 引擎为了平衡灵活性和执行效率设计的规则 —— 掌握这些规则,才能写出更可控的代码。