深入理解 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 才真正进入执行阶段。