从 0 实现一个 Tiny JavaScript VM:项目架构拆解

0 阅读11分钟

哼哧哼哧干了一周多代码,终于开始写文字了hhh

非常感谢wjy学长的指导和推荐,也希望从这里开始,我能更好地理解CS,开始一些真正意义上的成长

项目地址->github.com/NIIIIIIIIka…

欢迎各位佬指点评价(鞠躬

这个项目的目标,是用 C++ 从 0 实现一个 Tiny JavaScript VM。

它不是为了复刻完整的 V8、SpiderMonkey 或 QuickJS,而是用一个可控的 JavaScript 子集,把语言引擎最核心的链路跑通:

Source Code
  -> Lexer
  -> Parser
  -> AST
  -> AST Interpreter
  -> Runtime

后续再继续演进到:

AST
  -> Bytecode Compiler
  -> Stack-based VM
  -> Object System
  -> Garbage Collector

如果把这个项目放在简历首页,我最想表达的不是“我写了一个玩具解释器”,而是:

我正在把一个 JavaScript 引擎拆成可理解、可测试、可演进的模块,并逐步实现编译前端、解释执行、运行时语义、虚拟机和内存管理。

1. 为什么要做 Tiny JavaScript VM?

完整 JavaScript 引擎非常复杂。

它不仅要支持变量、函数、对象、数组、闭包、原型链、异常、模块,还要处理 JIT、隐藏类、Inline Cache、垃圾回收、事件循环、标准库、宿主环境等大量工程问题。

如果一开始就试图实现完整 JS,很容易掉进两个坑:

  • 语法和边界太多,核心链路还没打通,就被大量细节淹没。
  • 模块之间没有清晰演进顺序,最后变成一堆难以验证的半成品。

所以这个项目选择先实现一个 Tiny JS 子集。

不是因为完整 JS 不重要,而是因为语言引擎的学习路径应该先抓主干:

源码如何变成 Token?
Token 如何变成 AST?
AST 如何被解释执行?
变量和作用域如何工作?
函数调用如何创建执行环境?
数组、对象这类引用值如何表达?
后续如何从 AST Interpreter 演进到 Bytecode VM?

这些问题打通之后,再扩展完整语言特性,才有稳定地基。

2. 整体架构

项目可以拆成五个核心模块:

Lexer        词法分析,把源码切成 Token
Parser       语法分析,把 Token 流组织成 AST
AST          抽象语法树,表达程序结构
Interpreter 解释器,直接执行 AST
Runtime      运行时值、环境、错误和内置能力

执行流程大概是:

let x = 1 + 2 * 3;

先经过 Lexer :

Let Identifier(x) Equal Number(1) Plus Number(2) Star Number(3) Semicolon Eof

再经过 Parser :

LetStmt
  name: x
  initializer:
    BinaryExpr(+)
      left: NumberExpr(1)
      right:
        BinaryExpr(*)
          left: NumberExpr(2)
          right: NumberExpr(3)

最后 Interpreter 执行这棵 AST,把变量 x 绑定到运行时值 7

这个流程虽然小,但已经包含了语言引擎的基本骨架。

3. Lexer:把源码变成 Token 流

Lexer 负责词法分析。

它只关心“字符如何组成词法单元”,不关心语法结构。

比如:

while (i < 3) {
  i = i + 1;
}

Lexer 会识别出:

While LeftParen Identifier(i) Less Number(3) RightParen
LeftBrace Identifier(i) Equal Identifier(i) Plus Number(1) Semicolon RightBrace

Lexer 的职责包括:

  • 跳过空白字符。
  • 识别数字、字符串等字面量。
  • 识别标识符和关键字。
  • 识别运算符和分隔符。
  • 记录源码位置。
  • 产生词法 diagnostic。

再次强调,不关心语法结构。所以Lexer 不应该判断 while 后面有没有 (,也不应该判断 1 + ; 是否语法错误。这些属于 Parser。

Lexer 就像一个幼儿园识字老师

她的唯一工作是:

  • 看到 while,认出这是个单词(关键字)
  • 看到 (,认出这是个左括号(符号)
  • 看到 1,认出这是个数字
  • 看到 ;,认出这是个句号/结束符

好的模块边界是:Lexer 只负责 Token,Parser 负责结构。

4. Parser:把 Token 流变成 AST

Parser 负责语法分析。

它消费 Lexer 产生的 Token 流,并按照语法规则构造 AST。

项目使用递归下降 Parser。每类语法结构对应一个解析函数:

statement()
letDeclaration()
ifStatement()
whileStatement()
functionDeclaration()
...

Parser 将源码分为两个文法范畴:

  • Statement (语句)→ 描述"程序做什么"(控制流 + 副作用)
  • Expression (表达式)→ 描述"值怎么算"(运算 + 求值)

语句由 statement() 分发:

let       -> letDeclaration()
if        -> ifStatement()
while     -> whileStatement()
function  -> functionDeclaration()
return    -> returnStatement()
{         -> blockStatement()
otherwise -> expressionStatement()

表达式则通过函数层级处理优先级(关于这部分后续会更详细讲解):

assignment          → 最顶层,最后解析(赋值)
  equality          → 再包(相等)
    comparison      → 再包(比较)
      term          → 再包(加减)
        factor      → 再包(乘除)
          call      → 再往上包一层(函数调用)
            primary → 最底层,最先被解析(如 123, "abc", (expr))

所以,:

1 + 2 * 3

不会被解析成:

(* (+ 1 2) 3)

而是会解析成:

(+ 1 (* 2 3))

因为 term() 处理加减,factor() 处理乘除;加法层拿右操作数时,会先让乘法层把 2 * 3 收成一棵子树。

Parser 还负责记录语法错误,例如:

let = 10;
1 + ;
print(1;

这些错误不会直接让进程崩掉,而是进入 diagnostic 列表,方便测试、命令行输出或未来接入编辑器。

5. AST:程序的结构化中间表示

AST 是 Lexer 和 Parser 之后的核心产物。

源码是线性的,AST 是结构化的。

例如:

function fact(n) {
  if (n <= 1) {
    return 1;
  }
  return n * fact(n - 1);
}

在 AST 中,它不是一段字符串,而是由节点组成的树:

FunctionStmt
  name: fact
  params: n
  body:
    IfStmt
      condition: BinaryExpr(<=)
      thenBranch: ReturnStmt(1)
    ReturnStmt
      BinaryExpr(*)
        left: VariableExpr(n)
        right: CallExpr(fact)

AST 的价值是:后续模块不需要再理解源码字符,也不需要关心 Token 流,只需要处理节点。

相应的,项目里的 AST 大致分为两类:

Expr 表达式节点
Stmt 语句节点

这层设计决定了解释器和后续编译器是否好写。

6. Interpreter:直接执行 AST

当前阶段选择先实现 AST Interpreter。

也就是说,Parser 得到 AST 之后,解释器直接递归访问 AST 节点并执行。

例如:

let x = 10;
x = x + 2;
x;

解释器执行流程大致是:

执行 LetStmt:在环境中定义 x = 10
执行 AssignExpr:读取 x,计算 x + 2,更新 x = 12
执行 ExprStmt:读取 x,得到最终结果 12

再比如:

while (i < 3) {
  i = i + 1;
}

解释器会反复:

计算 condition
如果 truthy,执行 body
再次计算 condition
直到条件为 false

AST Interpreter 的优点是实现直观:

  • 每种 AST 节点对应一段执行逻辑。
  • 很容易验证语言语义。
  • 适合先把作用域、函数、数组、错误处理跑通。
  • 测试失败时容易定位问题。

它的缺点也明显:执行效率不高,每次都在树上递归解释。

但这正是合理的第一阶段。先把语义做对,再考虑把 AST 编译成字节码。

7. Runtime:值、环境、函数和错误

Runtime 是解释执行时真正承载状态的部分。

运行时重点包括:

Value        表示运行时值
Environment 维护变量绑定和作用域链
RuntimeError 表示运行时错误
Builtin      提供 print 等内置函数

运行时值目前支持:

  • number
  • boolean
  • null
  • array
  • function

环境模型支持块级作用域:

let x = 1;
{
  let x = 2;
  x;
}
x;

块内的 x 会遮蔽外层 x,但不会污染外层作用域。

函数调用则需要创建新的调用环境:

function add(a, b) {
  return a + b;
}
add(1, 2);

执行 add(1, 2) 时,解释器要:

找到函数定义
检查参数个数
创建函数调用环境
绑定形参 a = 1, b = 2
执行函数体
处理 return
返回结果

递归函数也依赖这个调用模型:

function fact(n) {
  if (n <= 1) {
    return 1;
  }
  return n * fact(n - 1);
}

fact(5);

每次调用 fact 都有自己的参数绑定和执行环境。

8. 为什么不一开始做完整 JS?

完整 JS 的复杂度不在某一个点,而在所有语义互相叠加。

例如对象系统一旦进入,就会引出:

属性查找
原型链
this 绑定
方法调用
构造函数
new
动态属性增删
属性描述符

闭包一旦进入,就会引出:

词法环境捕获
变量生命周期延长
函数对象保存外层环境
逃逸变量如何存储

GC 一旦进入,就会引出:

对象图遍历
根集合
引用关系
循环引用
暂停时机
分配策略

这些都很重要,但如果在 Lexer、Parser、Interpreter 还没稳定时一起做,项目很容易失控。

所以这个项目采用分阶段策略:

先实现可运行的语言核心
再扩展运行时数据结构
再引入字节码和虚拟机
最后处理对象系统和内存管理

这更接近真实工程的演进方式:先建立闭环,再逐步增强。

9. 为什么先做 AST Interpreter?

AST Interpreter 是最适合第一阶段的执行模型。

原因很简单:它离语法树最近。

当 Parser 产出:

BinaryExpr(+)
  left: NumberExpr(1)
  right: BinaryExpr(*)

解释器可以直接递归求值:

evaluate left
evaluate right
apply operator

这个阶段重点验证的是语言语义:

  • 表达式优先级是否正确
  • 变量查找是否正确
  • 块作用域是否正确
  • if / while 是否正确
  • 函数调用和 return 是否正确
  • 数组引用语义是否正确

如果一开始就做 Bytecode VM,会同时面对两个问题:

语义是否正确?
字节码设计是否正确?

调试成本会明显变高。

所以更稳的路径是:

AST Interpreter 先作为语义基准
Bytecode VM 后续对齐 AST Interpreter 的行为

这意味着未来引入 VM 时,可以用同一批语言测试同时跑两套后端:

source -> parser -> AST Interpreter -> result
source -> parser -> compiler -> bytecode VM -> result

两边结果一致,说明 VM 语义基本正确。

10. 如何演进到 Bytecode VM?

AST Interpreter 是直接执行树。

Bytecode VM 则会先把 AST 编译成线性的指令序列:

1 + 2 * 3;

可能编译成:

OP_CONSTANT 1
OP_CONSTANT 2
OP_CONSTANT 3
OP_MUL
OP_ADD
OP_POP

VM 执行时维护一个操作数栈:

push 1
push 2
push 3
mul -> push 6
add -> push 7

后续演进可以分几步:

  1. 定义 Bytecode 指令集。
  2. 实现 Chunk / Constant Pool。
  3. 编写 AST 到 Bytecode 的 Compiler。
  4. 实现 Stack-based VM。
  5. 让现有测试同时覆盖 AST Interpreter 和 VM。
  6. 逐步把函数调用、局部变量、跳转、循环、数组、对象编译到字节码。

控制流会变成跳转指令:

OP_JUMP_IF_FALSE
OP_JUMP
OP_LOOP

函数调用会变成调用帧:

CallFrame
  function
  instruction pointer
  stack base

这一步完成后,项目就从“树解释器”迈向真正的虚拟机。

11. 如何演进到对象系统?

当数组引入了引用语义:

let a = [1, 2, 3];
let b = a;
b[0] = 99;
a[0]; // 99

这是对象系统的前奏。

后续可以把运行时值扩展成:

Number
Boolean
Null
ArrayObject
FunctionObject
PlainObject
StringObject

对象系统要解决的问题包括:

  • 对象属性存储
  • 属性读取和写入
  • 方法调用
  • this 绑定
  • 原型链查找
  • 构造函数和 new

Parser 层要支持对象字面量和成员访问,Runtime 层要支持属性表,Interpreter 或 VM 层要支持属性读写。

12. 后续如何演进到 GC?

只要语言支持数组、对象、函数和闭包,就会遇到内存管理问题。

早期可以用 C++ 的智能指针快速表达所有权关系,但如果要更接近真实引擎,就需要实现自己的对象堆和 GC。

一个可控的演进路径是:

先把所有引用类型统一放到 Heap
Value 中保存对象引用
Environment / Stack / CallFrame 作为 GC Root
实现 mark-sweep
再考虑引用计数或分代优化

Mark-Sweep 的基本过程是:

1. 从根对象出发
2. 标记所有可达对象
3. 遍历堆,释放未标记对象

根对象包括:

  • 当前执行栈上的值
  • 全局变量环境
  • 活跃函数调用帧
  • 被闭包捕获的环境
  • VM 常量池中的引用

这一步会把项目推进到语言运行时最核心的问题:对象生命周期如何管理。

总结

Tiny JavaScript VM 的价值不在于一开始就支持完整 JS,而在于把语言引擎的主链路拆开并逐步实现。

当前阶段的核心闭环是:

Lexer -> Parser -> AST -> Interpreter -> Runtime

它已经能执行变量、表达式、控制流、函数、递归、数组和内置函数。

下一阶段会把 AST 执行路径升级为:

AST -> Bytecode Compiler -> Stack-based VM

再继续补上:

Object System -> Closure -> Garbage Collector

这条路线的好处是每一步都有明确目标,也都有可测试的结果。它不是一次性堆功能,而是在用工程化方式拆解一个语言引擎。