Skip to content

JavaScript 代码的解析过程:词法分析与语法分析

在上一章中,我们看到了 JavaScript 代码从源码到机器码的完整旅程。现在让我们放慢脚步,深入探索这个旅程的起点:代码解析

V8 如何理解你写的 JavaScript 代码?这个过程和我们阅读英文文章非常相似:首先识别单词(词法分析),然后理解语法结构(语法分析)。

但对于浏览器来说,解析不仅仅是"理解",更是一场与时间的赛跑

为什么解析如此重要?

你可能听说过"脚本加载阻塞渲染"。当浏览器遇到 <script> 标签时,它必须暂停渲染,下载并执行脚本。而执行的第一步,就是解析。

如果解析太慢,用户就会看到白屏。因此,V8 的解析器(Parser)必须快如闪电。

编译器前端的两个阶段

在计算机科学中,将源代码转换为可执行代码的过程叫做编译。V8 的 Parser 属于编译器前端,它要完成两个关键任务:

  1. 词法分析(Lexical Analysis):将字符流分解为 Token 流
  2. 语法分析(Syntax Analysis):将 Token 流组织成抽象语法树(AST)

为什么要分成两个阶段?因为这样可以职责分离,降低复杂度

  • 词法分析只关注字符模式的识别,不涉及代码的结构
  • 语法分析只关注结构的正确性,不需要关心底层的字符处理

词法分析:从字符到 Token

Scanner 的任务

词法分析器(也叫 Scanner 或 Tokenizer)的任务是将源代码这个字符串分解成一个个有意义的"词法单元",我们称之为 Token

让我们用一个例子直观感受这个过程:

javascript
let sum = a + b;

这行代码包含 18 个字符(包括空格)。Scanner 会将它分解为 7 个 Token:

  1. let → 关键字 Token
  2. sum → 标识符 Token
  3. = → 赋值运算符 Token
  4. a → 标识符 Token
  5. + → 加法运算符 Token
  6. b → 标识符 Token
  7. ; → 分号 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)策略。

它的核心思想是:只解析当前需要的代码

  1. 预解析(Pre-parsing): 对于函数声明(但未调用),V8 只进行快速的"预解析"。它只检查语法错误,不生成 AST,不分配内存。这比全量解析快 2 倍以上。

  2. 全量解析(Full Parsing): 只有当函数真正被调用时,V8 才会回过头来,对这个函数进行全量解析,生成 AST 和字节码。

闭包的陷阱

惰性解析虽然快,但也带来了一个棘手的问题:闭包

javascript
function outer() {
  let x = 10;
  function inner() {
    return x; // inner 引用了 outer 的变量
  }
  return inner;
}

如果 inner 被惰性解析(跳过),V8 怎么知道它引用了 x?如果不知道它引用了 xouter 执行完后 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 会:

  1. 读取第一个字符,如果是数字,进入"数字识别"状态
  2. 继续读取字符,只要还是数字或小数点,就继续累积
  3. 遇到非数字字符(如空格、运算符),结束数字识别,生成 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 被调用,但 inner1inner2inner3 从未被调用,那么完整解析它们的函数体就是浪费时间。

V8 的解决方案是预解析(Pre-parsing,也叫 Lazy Parsing)。

预解析的策略

V8 采用以下策略:

  1. 顶层代码:直接全量解析
  2. 函数声明:先预解析,调用时再全量解析
  3. 立即执行函数(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 可以在多个线程中并行解析不同的脚本文件,进一步提升解析速度。

最佳实践

理解了解析过程,我们可以总结一些编码建议:

  1. 避免巨大的函数:大函数的解析和编译成本更高
  2. 合理使用 IIFE:对于立即执行的代码,使用 IIFE 可以减少预解析的开销
  3. 代码分割:将代码分成多个小文件,利用流式解析和并行解析
  4. 注意语法错误:语法错误会中断解析,影响后续代码的执行

本章小结

本章我们深入探索了 V8 如何解析 JavaScript 代码。让我们回顾核心要点:

  1. 词法分析:将字符流转换为 Token 流,识别关键字、标识符、运算符等
  2. 语法分析:将 Token 流组织成 AST,建立代码的结构化表示
  3. 预解析:对于函数声明,先预解析以提升启动速度,调用时再全量解析
  4. 性能优化:流式解析、解析缓存、并行解析等技术进一步提升了解析效率

词法分析和语法分析的职责是清晰分离的:

  • 词法分析关注字符模式,不涉及代码结构
  • 语法分析关注结构正确性,不关心底层字符

理解解析过程不仅能帮助你写出对 V8 更友好的代码,还能让你在遇到语法错误时更快地定位问题。

下一章,我们将深入 AST 的世界,详细探讨 AST 的结构、用途,以及如何利用 AST 进行代码分析和转换。你会发现,AST 不仅是 V8 的基础,也是 Babel、ESLint 等工具的核心。


思考题

  1. 为什么 Token 流要包含空格和换行符的位置信息?(提示:考虑错误提示和 Source Map)
  2. 预解析虽然能提升启动速度,但对于热点函数反而增加了开销。V8 如何在这两者之间取得平衡?
  3. 如果你是 V8 的开发者,你会如何设计解析器,让它既能快速启动,又能高效执行?
JavaScript 代码的解析过程:词法分析与语法分析 has loaded