Appearance
JavaScript 代码的解析过程:词法分析与语法分析
在上一章中,我们看到了 JavaScript 代码从源码到机器码的完整旅程。现在让我们放慢脚步,深入探索这个旅程的起点:代码解析。
V8 如何理解你写的 JavaScript 代码?这个过程和我们阅读英文文章非常相似:首先识别单词(词法分析),然后理解语法结构(语法分析)。
但对于浏览器来说,解析不仅仅是"理解",更是一场与时间的赛跑。
为什么解析如此重要?
你可能听说过"脚本加载阻塞渲染"。当浏览器遇到 <script> 标签时,它必须暂停渲染,下载并执行脚本。而执行的第一步,就是解析。
如果解析太慢,用户就会看到白屏。因此,V8 的解析器(Parser)必须快如闪电。
编译器前端的两个阶段
在计算机科学中,将源代码转换为可执行代码的过程叫做编译。V8 的 Parser 属于编译器前端,它要完成两个关键任务:
- 词法分析(Lexical Analysis):将字符流分解为 Token 流
- 语法分析(Syntax Analysis):将 Token 流组织成抽象语法树(AST)
为什么要分成两个阶段?因为这样可以职责分离,降低复杂度:
- 词法分析只关注字符模式的识别,不涉及代码的结构
- 语法分析只关注结构的正确性,不需要关心底层的字符处理
词法分析:从字符到 Token
Scanner 的任务
词法分析器(也叫 Scanner 或 Tokenizer)的任务是将源代码这个字符串分解成一个个有意义的"词法单元",我们称之为 Token。
让我们用一个例子直观感受这个过程:
javascript
let sum = a + b;这行代码包含 18 个字符(包括空格)。Scanner 会将它分解为 7 个 Token:
let→ 关键字 Tokensum→ 标识符 Token=→ 赋值运算符 Tokena→ 标识符 Token+→ 加法运算符 Tokenb→ 标识符 Token;→ 分号 Token
词法分析的难点:歧义性
JavaScript 的词法分析并不像看起来那么简单,因为语法中存在歧义。
最典型的例子是除法运算符 / 和正则表达式字面量 /abc/。
javascript
let a = 10 / 2; // 除法
let b = /abc/; // 正则当 Scanner 遇到一个 / 字符时,它无法立即判断这是除号还是正则的开始。它必须依赖上下文(Context)。这就是为什么 Scanner 和 Parser 往往需要协同工作,而不能完全独立。
语法分析:构建 AST
有了 Token,Parser 就可以构建 AST 了。但这里有一个巨大的性能挑战。
挑战:网页代码的"二八定律"
V8 团队在分析真实网页时发现了一个惊人的数据:在页面加载期间,只有约 10% - 20% 的 JavaScript 代码被实际执行了。
剩下的 80% 是什么?是点击按钮才触发的事件处理函数、是特定条件下才运行的逻辑。
如果 V8 一上来就解析所有代码,那将是对 CPU 和时间的巨大浪费。
V8 的策略:惰性解析(Lazy Parsing)
为了解决这个问题,V8 引入了惰性解析(Lazy Parsing)策略。
它的核心思想是:只解析当前需要的代码。
预解析(Pre-parsing): 对于函数声明(但未调用),V8 只进行快速的"预解析"。它只检查语法错误,不生成 AST,不分配内存。这比全量解析快 2 倍以上。
全量解析(Full Parsing): 只有当函数真正被调用时,V8 才会回过头来,对这个函数进行全量解析,生成 AST 和字节码。
闭包的陷阱
惰性解析虽然快,但也带来了一个棘手的问题:闭包。
javascript
function outer() {
let x = 10;
function inner() {
return x; // inner 引用了 outer 的变量
}
return inner;
}如果 inner 被惰性解析(跳过),V8 怎么知道它引用了 x?如果不知道它引用了 x,outer 执行完后 x 就会被垃圾回收,导致 inner 执行出错。
为了解决这个问题,V8 的预解析器必须足够"聪明",它需要扫描函数内部,识别出哪些变量被内部函数引用了(这些变量会被分配到堆上的 Context 中,而不是栈上)。
这就是为什么有时候你觉得代码写得没问题,但解析阶段却比预想的慢——因为预解析器在努力处理复杂的闭包关系。
总结
解析不仅仅是翻译,更是一场资源管理的艺术。
- Scanner 负责将字符切分为 Token,处理
/等歧义。 - Parser 负责构建 AST,但它很"懒"。
- Lazy Parsing 策略极大地提升了网页加载速度,但也增加了处理闭包的复杂度。
理解了这一点,你就明白了为什么减少首屏 JavaScript 体积如此重要——不仅是为了减少下载时间,更是为了减轻 Parser 的负担。
数字字面量(NUMBER)
各种形式的数字:
javascript
let decimal = 42; // 十进制
let hex = 0x1F; // 十六进制
let binary = 0b1010; // 二进制
let octal = 0o17; // 八进制
let float = 3.14; // 浮点数
let scientific = 1e5; // 科学计数法字符串字面量(STRING)
单引号或双引号包裹的文本,以及模板字符串:
javascript
let single = 'Hello';
let double = "World";
let template = `Hello, ${name}`;运算符(OPERATOR)
算术、比较、逻辑等运算符:
javascript
let sum = a + b; // 算术运算符
let isEqual = x === y; // 比较运算符
let result = p && q; // 逻辑运算符标点符号(PUNCTUATOR)
各种括号、逗号、分号等:
javascript
function test() { }
// ^ ^ ^
// ( ) { }
let arr = [1, 2, 3];
// ^ ^ ^
// [ , ]注释(COMMENT)
单行注释和多行注释。注释通常会被 Scanner 忽略,不会生成 Token:
javascript
// 这是单行注释
/* 这是
多行注释 */词法分析的实现
Scanner 是如何识别这些 Token 的?核心方法是状态机。
以识别数字为例,Scanner 会:
- 读取第一个字符,如果是数字,进入"数字识别"状态
- 继续读取字符,只要还是数字或小数点,就继续累积
- 遇到非数字字符(如空格、运算符),结束数字识别,生成 NUMBER Token
javascript
// 识别过程示例(简化版)
let input = "123 + 456";
let pos = 0;
// 读取 '1', '2', '3'
// 遇到空格,生成 Token: { type: 'NUMBER', value: '123' }
// 跳过空格
// 读取 '+'
// 生成 Token: { type: 'OPERATOR', value: '+' }
// 跳过空格
// 读取 '4', '5', '6'
// 遇到结尾,生成 Token: { type: 'NUMBER', value: '456' }词法错误的识别
Scanner 也负责发现一些基本的词法错误。比如:
javascript
let 123abc = 10; // 错误:标识符不能以数字开头Scanner 读到 123 后会识别为数字,接着遇到 abc,但数字后面直接跟字母是非法的,Scanner 会报错。
再比如:
javascript
let str = "hello; // 错误:字符串没有结束引号Scanner 会发现字符串一直没有结束,最终报出"未终止的字符串字面量"错误。
语法分析:从 Token 到 AST
Parser 的任务
有了 Token 流,接下来就是语法分析器(Parser)的任务了。Parser 要根据 JavaScript 的语法规则,将 Token 流组织成一棵抽象语法树(Abstract Syntax Tree,AST)。
AST 是一种树形数据结构,它准确地表达了代码的语法结构和层次关系。
让我们看一个例子:
javascript
function greet(name) {
return "Hello, " + name;
}Parser 会将它转换成这样的 AST(简化表示):
FunctionDeclaration
├── id: Identifier (name: "greet")
├── params: [
│ └── Identifier (name: "name")
│ ]
└── body: BlockStatement
└── ReturnStatement
└── BinaryExpression (operator: "+")
├── left: Literal (value: "Hello, ")
└── right: Identifier (name: "name")用 JSON 格式表示更清晰:
json
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "greet"
},
"params": [
{
"type": "Identifier",
"name": "name"
}
],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Literal",
"value": "Hello, "
},
"right": {
"type": "Identifier",
"name": "name"
}
}
}
]
}
}这棵树清晰地表达了代码的结构:
- 这是一个函数声明
- 函数名是
greet - 有一个参数
name - 函数体是一个块语句,包含一个返回语句
- 返回的是一个二元表达式,运算符是
+ - 左操作数是字符串字面量
"Hello, " - 右操作数是标识符
name
Parser 的实现方式
V8 的 Parser 采用的是递归下降(Recursive Descent)解析方式。
递归下降的核心思想是:为每种语法结构编写一个解析函数,函数内部根据当前 Token 的类型,递归地调用其他解析函数。
以解析函数声明为例(伪代码):
javascript
function parseFunctionDeclaration() {
// 期望第一个 Token 是 'function'
expect('function');
// 解析函数名
let id = parseIdentifier();
// 期望左括号
expect('(');
// 解析参数列表
let params = parseParameterList();
// 期望右括号
expect(')');
// 解析函数体
let body = parseBlockStatement();
// 构造并返回 AST 节点
return {
type: 'FunctionDeclaration',
id,
params,
body
};
}这种方式代码结构清晰,容易理解和维护。
语法错误的检测
Parser 在构建 AST 的过程中,会检查语法是否符合规范。如果发现错误,会立即报告。
常见的语法错误包括:
javascript
// 错误 1:缺少右括号
function test() {
console.log("test")
// 缺少 }
// 错误 2:表达式不完整
let x = 5 + ;
// 错误 3:意外的 Token
let y = * 10;Parser 会给出明确的错误提示,告诉你在哪一行、哪个位置出现了什么问题。
预解析:V8 的性能优化策略
全量解析 vs 预解析
现在我们知道了 Parser 会将代码转换为 AST。但是,对于大型应用,如果一开始就解析所有代码,会非常耗时。
考虑这样的场景:
javascript
function outer() {
function inner1() {
// 很长的函数体,但可能不会被调用
// ...
}
function inner2() {
// 另一个很长的函数体,也可能不会被调用
// ...
}
function inner3() {
// 还有更多函数...
// ...
}
// outer 被调用时,inner 函数不一定会被调用
}如果 outer 被调用,但 inner1、inner2、inner3 从未被调用,那么完整解析它们的函数体就是浪费时间。
V8 的解决方案是预解析(Pre-parsing,也叫 Lazy Parsing)。
预解析的策略
V8 采用以下策略:
- 顶层代码:直接全量解析
- 函数声明:先预解析,调用时再全量解析
- 立即执行函数(IIFE):直接全量解析
全量解析(Full Parse):
- 完整地构建 AST
- 生成函数的作用域信息
- 为后续的字节码生成做准备
预解析(Pre-parse):
- 只检查语法错误
- 记录函数的位置和基本信息(参数数量、是否有
eval等) - 不生成完整的 AST
当函数被调用时,V8 会回到源码,对该函数进行全量解析。
预解析的效果
预解析可以显著提升启动速度。根据 V8 团队的测试,预解析可以减少 30-50% 的初始解析时间。
但预解析也有代价:对于热点函数,它们会被解析两次(预解析一次,全量解析一次),反而增加了开销。
强制全量解析
在某些情况下,你希望函数立即被全量解析。V8 提供了几种方式:
方法 1:使用 IIFE
javascript
(function immediate() {
// 这个函数会被立即全量解析
console.log("IIFE");
})();方法 2:括号包裹
javascript
const fn = (function eager() {
// 括号包裹的函数会被全量解析
return function() {
console.log("eager");
};
})();方法 3:前置一元运算符
javascript
const fn = +function eager() {
// 前置运算符也会触发全量解析
return 42;
};这些技巧在某些框架的源码中很常见,目的是减少函数的重复解析。
预解析的决策过程
V8 如何决定是全量解析还是预解析?简化的决策流程如下:
遇到函数定义
↓
是 IIFE?
├─ 是 → 全量解析
└─ 否 ↓
是顶层函数?
├─ 是 → 全量解析
└─ 否 ↓
函数前有括号/运算符?
├─ 是 → 全量解析
└─ 否 ↓
预解析(延迟全量解析)解析过程的完整图景
让我们把词法分析、语法分析和预解析放在一起,看看完整的解析流程:
JavaScript 源代码
↓
词法分析(Scanner)
↓
Token 流
↓
语法分析(Parser)
↓
判断解析策略
├─ 顶层代码 → 全量解析 → 完整 AST
├─ IIFE → 全量解析 → 完整 AST
└─ 函数声明 → 预解析 → 基本信息
↓
(函数被调用时)
↓
全量解析 → 完整 AST实际例子
javascript
// 顶层代码:全量解析
let config = {
timeout: 3000
};
// IIFE:全量解析
(function init() {
console.log("App initialized");
})();
// 函数声明:预解析
function fetchData(url) {
// 这个函数体暂时不会被完整解析
return fetch(url).then(res => res.json());
}
// 当 fetchData 被调用时,才会全量解析
fetchData("/api/data");解析性能的其他优化
除了预解析,V8 还采用了其他优化手段:
流式解析
对于大型脚本文件,V8 支持流式解析(Streaming Parsing):边下载边解析,而不是等整个文件下载完毕再开始解析。
这在加载大型库(如 React、Vue)时尤其有用,可以减少页面的首屏时间。
解析缓存
V8 会缓存解析结果。如果同一个脚本文件被多次加载(如在不同页面中引用同一个库),V8 可以复用之前的解析结果,避免重复解析。
并行解析
在 Chrome 中,V8 可以在多个线程中并行解析不同的脚本文件,进一步提升解析速度。
最佳实践
理解了解析过程,我们可以总结一些编码建议:
- 避免巨大的函数:大函数的解析和编译成本更高
- 合理使用 IIFE:对于立即执行的代码,使用 IIFE 可以减少预解析的开销
- 代码分割:将代码分成多个小文件,利用流式解析和并行解析
- 注意语法错误:语法错误会中断解析,影响后续代码的执行
本章小结
本章我们深入探索了 V8 如何解析 JavaScript 代码。让我们回顾核心要点:
- 词法分析:将字符流转换为 Token 流,识别关键字、标识符、运算符等
- 语法分析:将 Token 流组织成 AST,建立代码的结构化表示
- 预解析:对于函数声明,先预解析以提升启动速度,调用时再全量解析
- 性能优化:流式解析、解析缓存、并行解析等技术进一步提升了解析效率
词法分析和语法分析的职责是清晰分离的:
- 词法分析关注字符模式,不涉及代码结构
- 语法分析关注结构正确性,不关心底层字符
理解解析过程不仅能帮助你写出对 V8 更友好的代码,还能让你在遇到语法错误时更快地定位问题。
下一章,我们将深入 AST 的世界,详细探讨 AST 的结构、用途,以及如何利用 AST 进行代码分析和转换。你会发现,AST 不仅是 V8 的基础,也是 Babel、ESLint 等工具的核心。
思考题:
- 为什么 Token 流要包含空格和换行符的位置信息?(提示:考虑错误提示和 Source Map)
- 预解析虽然能提升启动速度,但对于热点函数反而增加了开销。V8 如何在这两者之间取得平衡?
- 如果你是 V8 的开发者,你会如何设计解析器,让它既能快速启动,又能高效执行?