JavaScript 到底是怎么运行的?从编译阶段到执行上下文全面解析

深入理解 JavaScript 运行机制:从 V8 引擎到作用域链

前言

学习 JavaScript 的过程中,我们总会遇到一些看起来违反直觉的现象。

例如变量明明写在后面,却能够提前访问:

ini 复制代码
console.log(a);
var a = 10;

程序没有报错,而是输出了 undefined

再比如函数可以在声明之前调用:

scss 复制代码
foo();

function foo() {
    console.log("hello");
}

甚至在作用域问题上,也经常会出现与直觉不符的结果:

ini 复制代码
let a = 100;

function foo() {
    console.log(a);
}

function bar() {
    let a = 200;
    foo();
}

bar();

很多初学者会认为输出应该是 200,但实际结果却是 100

这些现象看似毫无关联,但它们都来源于同一套底层运行机制。理解这套机制后,你会发现变量提升、作用域链、闭包、this 甚至 Event Loop,其实都建立在相同的基础之上。而这套基础的起点,就是 JavaScript 和 V8 引擎。


一、JavaScript 到底是什么?

很多人第一次接触 JavaScript 时,会把它简单理解为"前端开发语言"。

这种说法并不完全错误,但如果从计算机科学的角度来看,它并不能准确描述 JavaScript 的本质。 JavaScript 是一门高级编程语言。 所谓高级语言,指的是它更接近人类的思维方式,而不是机器的思维方式。

例如:

ini 复制代码
let price = 100;
let count = 2;

console.log(price * count);

对于开发者来说,这段代码的含义一目了然。

但是对于 CPU 来说:

  • 什么是 let
  • 什么是变量
  • 什么是 console.log

这些概念完全不存在。 CPU 能够理解的只有机器指令。

因此,无论是 JavaScript、Java、Python 还是 Go,本质上都需要经过一个"翻译"的过程,最终转换成机器能够执行的指令。

这也是编程语言和计算机之间最根本的矛盾:

人类希望使用容易理解的语言编程,而计算机只能理解机器语言。

JavaScript 引擎存在的意义,就是负责完成这项翻译工作。


二、JavaScript 是解释型语言吗?

在很多教程中,我们经常能看到这样一句话:

JavaScript 是解释型语言。

于是很多初学者产生了一个误解:

解释型语言是不是就没有编译过程?

答案是否定的。这是一个非常典型的历史遗留认知。早期计算机语言通常分为两类:

第一类是编译型语言。

例如 C、C++。

它们的运行流程通常是:

复制代码
源码
↓
编译
↓
生成可执行文件
↓
运行

开发者能够明显感受到编译过程的存在。因为每次运行程序之前,都需要先进行编译。


第二类是解释型语言。

例如早期 JavaScript。

它们不需要提前生成可执行文件,而是在运行时由解释器逐步处理代码。

因此很多人形成了这样一种印象:

复制代码
编译型语言 → 有编译阶段

解释型语言 → 没有编译阶段

实际上这是不准确的。现代 JavaScript 引擎已经不再采用传统解释器模式。

如今主流引擎采用的是:

JIT(Just In Time Compilation)即时编译技术。

也就是说: JavaScript 既不是纯编译型语言,也不是纯解释型语言。而是一种融合了编译与解释特性的语言。


三、为什么很多人认为 JavaScript 没有编译阶段?

这个问题其实非常有意思。学习 C 语言时:你需要手动点击编译按钮,等待生成可执行文件, 然后再运行程序,整个过程非常明显。因此你能够清晰地感知到:

编译正在发生。


而 JavaScript 完全不同。

例如:

arduino 复制代码
console.log("hello");

刷新浏览器,程序瞬间运行,整个过程可能只需要几毫秒,开发者根本感受不到任何等待时间,于是很容易产生一种错觉:

JavaScript 是边读取边执行的。

事实上并不是。现代 JavaScript 引擎一定会先对代码进行编译处理,然后再执行。只是整个编译过程极快。快到人类几乎无法察觉。所以:

复制代码
感受不到编译

≠

没有编译

这是理解 JavaScript 运行机制的第一个关键认知。


四、V8 引擎是什么?

既然 JavaScript 需要完成编译,那么是谁负责这项工作?答案就是 JavaScript 引擎

目前世界上最著名的 JavaScript 引擎之一是:V8

它由 Google 开发,最初应用于 Chrome 浏览器,后来又被引入到 Node.js 中。

这也是为什么 JavaScript 不仅能够运行在浏览器中,也能够运行在服务器上的原因。


V8 的核心职责非常简单:

将 JavaScript 代码转换成 CPU 能够执行的机器码。

从整体角度来看:

复制代码
JavaScript源码
↓
V8引擎
↓
机器码
↓
CPU执行

V8 就像一个实时翻译官。

负责在程序运行过程中,把开发者编写的代码翻译成计算机能够理解的形式。


五、V8 在编译阶段到底做了什么?

很多教程会直接给出一张流程图:

复制代码
源码
↓
Token
↓
AST
↓
字节码
↓
执行

但对于初学者来说,最大的疑问往往是:

每一步到底是在干什么?

事实上,编译阶段的核心目标只有一个:

理解开发者编写的代码。

为了完成这个目标,V8 会经历多个步骤。


1. 词法分析:把代码拆成单词

当 V8 拿到源码后,它首先面对的是一长串字符。

ini 复制代码
let age = 18;

对于人类来说,这是一句完整的话,但计算机无法直接理解,因此第一步需要做的事情,就是拆分。

把代码拆分成一个个最小的语义单位。

例如:

  • let
  • age
  • =
  • 18
  • ;

这些最小单位被称为 Token(词法单元),词法分析阶段的工作,就是把源码拆解成 Token,你可以把它理解成中文分词,只有先完成分词,后续才有可能理解整句话的含义。


2. 语法分析:检查代码是否符合规则

仅仅完成拆分还不够

复制代码
有单词

不代表能组成一句正确的话
ini 复制代码
let = age;

虽然每个 Token 都合法,但整体组合方式不符合 JavaScript 的语法规则,因此会直接抛出 SyntaxError。

语法分析阶段的核心职责,就是验证代码结构是否合法,如果语法不通过,程序甚至无法进入执行阶段。


3. AST:让计算机真正理解代码结构

通过语法检查之后,V8 会生成 AST。AST 的全称是Abstract Syntax Tree(抽象语法树)

很多初学者第一次接触 AST 时会有疑问:

代码不是已经写好了吗?为什么还需要变成树?

原因很简单,源码本质上只是字符串,字符串不利于分析和优化,而树结构能够清晰表达代码之间的层级关系。

css 复制代码
a + b * c

人类知道应该先计算乘法,,但计算机需要通过 AST 才能明确知道:

  • 加法是谁
  • 乘法是谁
  • 哪个运算优先执行

因此 AST 的本质是用一种更适合计算机处理的结构来描述源码。 后续的变量分析、作用域分析以及代码优化,都会建立在 AST 的基础之上。


六、执行上下文:代码运行前的准备工作

当 V8 完成编译之后,并不会立刻开始执行代码。

因为引擎还需要解决一个问题:代码运行时需要用到的数据放在哪里?

csharp 复制代码
var a = 10;

function foo() {}

执行之前,引擎必须提前知道:

  • a 存放在哪里
  • foo 存放在哪里
  • 当前作用域是什么
  • this 应该指向谁

为了管理这些信息,V8 创建了执行上下文(Execution Context)。

你可以把执行上下文理解为:

代码运行之前搭建好的工作环境。

后续所有代码执行,都会依赖这个环境。


七、执行上下文内部到底保存了什么?

前面我们已经知道,当 V8 完成编译工作后,会为即将执行的代码创建一个执行上下文(Execution Context)。

你可以把执行上下文理解成代码运行前准备好的工作环境,但是新的问题来了:

执行上下文里面到底存放了什么?

现代 JavaScript 规范中,一个执行上下文主要包含三部分内容:

  • Variable Environment(变量环境)
  • Lexical Environment(词法环境)
  • This Binding

其中,前两部分是理解 JavaScript 作用域机制的核心。

很多教程会简单地说:

csharp 复制代码
Variable Environment 存 var

Lexical Environment 存 let 和 const

这句话不能算错,但远远不够,因为它只描述了存储内容,却没有解释这两个环境存在的意义。

想真正理解变量提升和作用域链,就必须先搞懂这两个环境到底在做什么。


为什么需要 Variable Environment(变量环境)

如果回到 ES5 时代,JavaScript 中实际上只有两种最主要的声明方式:

csharp 复制代码
var a = 1;

function foo() {}

那时候还没有:let , const 也没有块级作用域。

ini 复制代码
if (true) {
    var a = 100;
}

console.log(a);

程序能够正常输出:

复制代码
100

因为 var 只有函数作用域,没有块级作用域。

对于当时的 JavaScript 来说,引擎只需要记录:

当前作用域中有哪些变量和函数。

就足够完成代码执行。 于是执行上下文在创建阶段会提前收集这些声明。

例如:

ini 复制代码
var a = 100;

var b = 200;

function foo() {}

在代码真正执行之前,引擎已经建立好了类似这样的记录:

javascript 复制代码
a → undefined

b → undefined

foo → function(){}

这里需要特别注意一点,此时并没有执行赋值操作。引擎只是提前完成了变量登记。

可以理解为:

复制代码
建立变量名
↓
分配存储位置
↓
等待后续赋值

这部分工作,就是 Variable Environment 的职责。 因此 Variable Environment 最核心的作用是:

收集并管理当前作用域中的 var 和函数声明。


Variable Environment 如何产生变量提升

理解了上面的过程之后,变量提升就很好解释了。

ini 复制代码
console.log(a);

var a = 100;

很多人认为 JavaScript 会偷偷把代码改成:

ini 复制代码
var a;

console.log(a);

a = 100;

实际上 V8 根本不会修改源码。真正发生的是:

在执行上下文创建阶段,Variable Environment 已经记录了:

css 复制代码
a → undefined

因此执行到第一行代码时:

arduino 复制代码
console.log(a);

变量已经存在。 只是还没有完成赋值。所以输出:

javascript 复制代码
undefined

这就是变量提升的本质。变量提升并不是代码移动,而是:

Variable Environment 提前建立了变量绑定关系。


为什么后来又出现了 Lexical Environment

到了 ES6,JavaScript 引入了两个重要特性:

csharp 复制代码
let
const

以及:

复制代码
{
}

块级作用域。 问题随之出现。

ini 复制代码
if (true) {

    let a = 100;

}

console.log(a);

这里会直接报错,如果仍然按照 ES5 的方式管理变量,那么变量 a 应该一直存在。显然这与实际运行结果不符。

这说明:

let 和 const 需要一种全新的管理机制。

于是 JavaScript 规范引入了: Lexical Environment(词法环境)


Lexical Environment 的第一个职责:管理 let 和 const

例如:

ini 复制代码
let name = "Tom";

const age = 18;

执行上下文创建时,并不会像 var 那样初始化为 undefined。

而是进入一种特殊状态:

复制代码
name → 未初始化

age → 未初始化

因此:

ini 复制代码
console.log(name);

let name = "Tom";

会直接报错,因为变量虽然已经存在,但还没有完成初始化。

这段区域被称为: TDZ(Temporal Dead Zone,暂时性死区)

因此 TDZ 并不是一个额外机制。它本质上是 Lexical Environment 的一种状态。


Lexical Environment 的第二个职责:管理块级作用域

ini 复制代码
{
    let a = 100;
}

当代码进入这个代码块时,引擎会创建一个新的词法环境。 这个环境只属于当前代码块。

其中记录着:

css 复制代码
a → 100

当程序离开代码块后,这个词法环境就会被销毁。

因此:

arduino 复制代码
console.log(a);

无法访问变量,最终抛出错误。这就是块级作用域产生的根本原因。


Lexical Environment 最重要的职责:记录作用域关系

前面两个职责都很重要。

但真正让 Lexical Environment 成为 JavaScript 核心机制的原因,是它还负责记录:

当前作用域与外层作用域之间的关系。

例如:

csharp 复制代码
let a = 100;

function foo() {

}

当 foo 被创建时,引擎不仅会记录 foo 内部有哪些变量。

还会额外记录:

复制代码
foo 的外层作用域是谁

规范中把这个引用称为:

Outer Environment Reference

即:

csharp 复制代码
foo
 ↓
global

这种关系在函数创建时就已经确定,而不是在函数调用时确定。

这也是 JavaScript 被称为词法作用域语言的原因。


作用域链是如何产生的

例如:

javascript 复制代码
let a = 100;

function foo() {

    function bar() {

        console.log(a);

    }

}

创建阶段会形成如下关系:

csharp 复制代码
bar
 ↓
foo
 ↓
global

实际上对应的是:

markdown 复制代码
bar Lexical Environment
        ↓
foo Lexical Environment
        ↓
Global Lexical Environment

当程序执行:

arduino 复制代码
console.log(a);

引擎首先在 bar 的词法环境中查找变量,找不到,然后通过 Outer 引用进入 foo 的词法环境,仍然找不到。 继续向外查找, 最终在全局词法环境中找到变量。

这条不断向外查找变量的路径,就是作用域链。

因此:

作用域链并不是 JavaScript 额外设计出来的一套机制。

它本质上只是 Lexical Environment 中 Outer 引用形成的一条查找路径。


Variable Environment 与 Lexical Environment 的本质区别

很多人把两者区别总结为:

csharp 复制代码
Variable Environment 存 var

Lexical Environment 存 let

这种说法过于表面。

更准确的理解应该是:

Variable Environment Lexical Environment
管理变量声明 管理作用域关系
负责 var 负责 let、const
负责函数声明登记 负责块级作用域
产生变量提升 产生 TDZ
保存变量绑定 保存 Outer 引用
不负责作用域链 负责作用域链

因此可以把它们理解成:

ini 复制代码
Variable Environment
=
员工档案室

记录有哪些员工

而:

ini 复制代码
Lexical Environment
=
组织架构图

记录员工属于哪个部门
记录部门之间的上下级关系

变量提升来自档案室,作用域链来自组织架构图。

这也是 JavaScript 执行上下文中最重要的一部分内容。


这条链路是完整闭环的


八、变量提升的本质

很多教程会说:

变量提升就是把代码移动到顶部。

这种说法只是方便理解,并不是真实发生的事情,实际上,JavaScript 引擎根本不会修改源码。

真正发生的是:在创建执行上下文时,引擎已经扫描了所有变量声明。并提前建立了变量记录。

ini 复制代码
console.log(a);

var a = 10;

执行第一行时:变量已经存在。只是尚未完成赋值。所以得到:

javascript 复制代码
undefined

变量提升的本质是:

执行上下文创建阶段提前建立变量绑定。

而不是代码位置发生改变。


九、作用域链的本质

当程序访问一个变量时,引擎并不是直接就能找到它。 而是会按照一定规则逐层查找。

scss 复制代码
let a = 100;

function foo() {

    function bar() {
        console.log(a);
    }

    bar();
}

foo();

执行到 a 时:

引擎会先在当前作用域查找。

找不到再到上一层作用域查找。

直到找到变量或者到达全局作用域。

这条查找路径被称为作用域链。


但作用域链的本质并不是简单的"向上查找"。

真正的本质是:

每个词法环境内部都保存着一个指向外层环境的引用

因此:

csharp 复制代码
bar
↓
foo
↓
global

实际上是:

csharp 复制代码
bar → foo → global

这样一种引用关系。

变量查找过程,本质上就是沿着这些引用不断向外查找。


结语

到这里,我们已经串起了 JavaScript 运行机制最核心的一条主线:

复制代码
JavaScript是什么
↓
为什么JS看起来没有编译
↓
V8是什么
↓
编译阶段做了什么
↓
执行上下文是什么
↓
Variable Environment做了什么
↓
为什么会出现变量提升
↓
Lexical Environment做了什么
↓
为什么会出现TDZ
↓
为什么会出现作用域链

JavaScript 是一门采用即时编译技术的高级语言。

当代码运行时,V8 会先经历词法分析、语法分析和 AST 构建,完成对源码的理解。随后创建执行上下文,并建立变量环境和词法环境。在这个过程中产生了变量提升,同时也建立了作用域链。最终,JavaScript 才真正进入执行阶段。

相关推荐
丷丩1 小时前
MapLibre GL JS第29课:添加Canvas源
javascript·gis·map·mapbox·maplibre gl js
utf8mb4安全女神2 小时前
【rsyslog服务】把所有服务的“临界点”以上的错误都保存在/var/log/alert.log⽇志中
java·前端·javascript
csdn_aspnet2 小时前
javascript 算法 LeetCode 编号 70 - 爬楼梯
开发语言·javascript·算法·leetcode·ecmascript
swipe2 小时前
DeepAgents 多 Agent 深度调研助手工程实战:从 createDeepAgent 到可控调研工作流
javascript·面试·langchain
moMo2 小时前
JavaScript 变量提升,执行上下文里的各种门道
javascript·面试
weixin_471383032 小时前
由浅入深递归练习
前端·javascript·vue.js
丷丩3 小时前
MapLibre GL JS第21课:绘制GeoJSON点图标、注记
前端·javascript·gis·mapbox·maplibre gl js
丷丩3 小时前
MapLibre GL JS第20课:更新GeoJSON多边形
前端·javascript·gis·mapbox·maplibre gl js
丷丩4 小时前
MapLibre GL JS第33课:渲染世界副本
javascript·gis·map·mapbox·maplibre gl js