这个项目的目标,是用 C++ 从 0 实现一个 Tiny JavaScript VM。
它不是为了复刻完整的 V8、SpiderMonkey 或 QuickJS,而是用一个可控的 JavaScript 子集,把语言引擎最核心的链路跑通:
rust
代码解读
复制代码
Source Code -> Lexer -> Parser -> AST -> AST Interpreter -> Runtime
后续再继续演进到:
rust
代码解读
复制代码
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 运行时值、环境、错误和内置能力
执行流程大概是:
ini
代码解读
复制代码
let x = 1 + 2 * 3;
先经过 Lexer :
scss
代码解读
复制代码
Let Identifier(x) Equal Number(1) Plus Number(2) Star Number(3) Semicolon Eof
再经过 Parser :
yaml
代码解读
复制代码
LetStmt name: x initializer: BinaryExpr(+) left: NumberExpr(1) right: BinaryExpr(*) left: NumberExpr(2) right: NumberExpr(3)
最后 Interpreter 执行这棵 AST,把变量 x 绑定到运行时值 7。
这个流程虽然小,但已经包含了语言引擎的基本骨架。
3. Lexer:把源码变成 Token 流
Lexer 负责词法分析。
它只关心"字符如何组成词法单元",不关心语法结构。
比如:
css
代码解读
复制代码
while (i < 3) { i = i + 1; }
Lexer 会识别出:
scss
代码解读
复制代码
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。每类语法结构对应一个解析函数:
scss
代码解读
复制代码
statement() letDeclaration() ifStatement() whileStatement() functionDeclaration() ...
Parser 将源码分为两个文法范畴:
- Statement (语句)→ 描述"程序做什么"(控制流 + 副作用)
- Expression (表达式)→ 描述"值怎么算"(运算 + 求值)
语句由 statement() 分发:
scss
代码解读
复制代码
let -> letDeclaration() if -> ifStatement() while -> whileStatement() function -> functionDeclaration() return -> returnStatement() { -> blockStatement() otherwise -> expressionStatement()
表达式则通过函数层级处理优先级(关于这部分后续会更详细讲解):
bash
代码解读
复制代码
assignment → 最顶层,最后解析(赋值) equality → 再包(相等) comparison → 再包(比较) term → 再包(加减) factor → 再包(乘除) call → 再往上包一层(函数调用) primary → 最底层,最先被解析(如 123, "abc", (expr))
所以,:
代码解读
复制代码
1 + 2 * 3
不会被解析成:
scss
代码解读
复制代码
(* (+ 1 2) 3)
而是会解析成:
scss
代码解读
复制代码
(+ 1 (* 2 3))
因为 term() 处理加减,factor() 处理乘除;加法层拿右操作数时,会先让乘法层把 2 * 3 收成一棵子树。
Parser 还负责记录语法错误,例如:
ini
代码解读
复制代码
let = 10; 1 + ; print(1;
这些错误不会直接让进程崩掉,而是进入 diagnostic 列表,方便测试、命令行输出或未来接入编辑器。
5. AST:程序的结构化中间表示
AST 是 Lexer 和 Parser 之后的核心产物。
源码是线性的,AST 是结构化的。
例如:
scss
代码解读
复制代码
function fact(n) { if (n <= 1) { return 1; } return n * fact(n - 1); }
在 AST 中,它不是一段字符串,而是由节点组成的树:
yaml
代码解读
复制代码
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 节点并执行。
例如:
ini
代码解读
复制代码
let x = 10; x = x + 2; x;
解释器执行流程大致是:
ini
代码解读
复制代码
执行 LetStmt:在环境中定义 x = 10 执行 AssignExpr:读取 x,计算 x + 2,更新 x = 12 执行 ExprStmt:读取 x,得到最终结果 12
再比如:
css
代码解读
复制代码
while (i < 3) { i = i + 1; }
解释器会反复:
sql
代码解读
复制代码
计算 condition 如果 truthy,执行 body 再次计算 condition 直到条件为 false
AST Interpreter 的优点是实现直观:
- 每种 AST 节点对应一段执行逻辑。
- 很容易验证语言语义。
- 适合先把作用域、函数、数组、错误处理跑通。
- 测试失败时容易定位问题。
它的缺点也明显:执行效率不高,每次都在树上递归解释。
但这正是合理的第一阶段。先把语义做对,再考虑把 AST 编译成字节码。
7. Runtime:值、环境、函数和错误
Runtime 是解释执行时真正承载状态的部分。
运行时重点包括:
arduino
代码解读
复制代码
Value 表示运行时值 Environment 维护变量绑定和作用域链 RuntimeError 表示运行时错误 Builtin 提供 print 等内置函数
运行时值目前支持:
- number
- boolean
- null
- array
- function
环境模型支持块级作用域:
ini
代码解读
复制代码
let x = 1; { let x = 2; x; } x;
块内的 x 会遮蔽外层 x,但不会污染外层作用域。
函数调用则需要创建新的调用环境:
css
代码解读
复制代码
function add(a, b) { return a + b; } add(1, 2);
执行 add(1, 2) 时,解释器要:
css
代码解读
复制代码
找到函数定义 检查参数个数 创建函数调用环境 绑定形参 a = 1, b = 2 执行函数体 处理 return 返回结果
递归函数也依赖这个调用模型:
scss
代码解读
复制代码
function fact(n) { if (n <= 1) { return 1; } return n * fact(n - 1); } fact(5);
每次调用 fact 都有自己的参数绑定和执行环境。
8. 为什么不一开始做完整 JS?
完整 JS 的复杂度不在某一个点,而在所有语义互相叠加。
例如对象系统一旦进入,就会引出:
arduino
代码解读
复制代码
属性查找 原型链 this 绑定 方法调用 构造函数 new 动态属性增删 属性描述符
闭包一旦进入,就会引出:
代码解读
复制代码
词法环境捕获 变量生命周期延长 函数对象保存外层环境 逃逸变量如何存储
GC 一旦进入,就会引出:
代码解读
复制代码
对象图遍历 根集合 引用关系 循环引用 暂停时机 分配策略
这些都很重要,但如果在 Lexer、Parser、Interpreter 还没稳定时一起做,项目很容易失控。
所以这个项目采用分阶段策略:
代码解读
复制代码
先实现可运行的语言核心 再扩展运行时数据结构 再引入字节码和虚拟机 最后处理对象系统和内存管理
这更接近真实工程的演进方式:先建立闭环,再逐步增强。
9. 为什么先做 AST Interpreter?
AST Interpreter 是最适合第一阶段的执行模型。
原因很简单:它离语法树最近。
当 Parser 产出:
scss
代码解读
复制代码
BinaryExpr(+) left: NumberExpr(1) right: BinaryExpr(*)
解释器可以直接递归求值:
css
代码解读
复制代码
evaluate left evaluate right apply operator
这个阶段重点验证的是语言语义:
- 表达式优先级是否正确
- 变量查找是否正确
- 块作用域是否正确
- if / while 是否正确
- 函数调用和 return 是否正确
- 数组引用语义是否正确
如果一开始就做 Bytecode VM,会同时面对两个问题:
代码解读
复制代码
语义是否正确? 字节码设计是否正确?
调试成本会明显变高。
所以更稳的路径是:
代码解读
复制代码
AST Interpreter 先作为语义基准 Bytecode VM 后续对齐 AST Interpreter 的行为
这意味着未来引入 VM 时,可以用同一批语言测试同时跑两套后端:
rust
代码解读
复制代码
source -> parser -> AST Interpreter -> result source -> parser -> compiler -> bytecode VM -> result
两边结果一致,说明 VM 语义基本正确。
10. 如何演进到 Bytecode VM?
AST Interpreter 是直接执行树。
Bytecode VM 则会先把 AST 编译成线性的指令序列:
ini
代码解读
复制代码
1 + 2 * 3;
可能编译成:
代码解读
复制代码
OP_CONSTANT 1 OP_CONSTANT 2 OP_CONSTANT 3 OP_MUL OP_ADD OP_POP
VM 执行时维护一个操作数栈:
perl
代码解读
复制代码
push 1 push 2 push 3 mul -> push 6 add -> push 7
后续演进可以分几步:
- 定义 Bytecode 指令集。
- 实现 Chunk / Constant Pool。
- 编写 AST 到 Bytecode 的 Compiler。
- 实现 Stack-based VM。
- 让现有测试同时覆盖 AST Interpreter 和 VM。
- 逐步把函数调用、局部变量、跳转、循环、数组、对象编译到字节码。
控制流会变成跳转指令:
代码解读
复制代码
OP_JUMP_IF_FALSE OP_JUMP OP_LOOP
函数调用会变成调用帧:
arduino
代码解读
复制代码
CallFrame function instruction pointer stack base
这一步完成后,项目就从"树解释器"迈向真正的虚拟机。
11. 如何演进到对象系统?
当数组引入了引用语义:
ini
代码解读
复制代码
let a = [1, 2, 3]; let b = a; b[0] = 99; a[0]; // 99
这是对象系统的前奏。
后续可以把运行时值扩展成:
javascript
代码解读
复制代码
Number Boolean Null ArrayObject FunctionObject PlainObject StringObject
对象系统要解决的问题包括:
- 对象属性存储
- 属性读取和写入
- 方法调用
this绑定- 原型链查找
- 构造函数和
new
Parser 层要支持对象字面量和成员访问,Runtime 层要支持属性表,Interpreter 或 VM 层要支持属性读写。
12. 后续如何演进到 GC?
只要语言支持数组、对象、函数和闭包,就会遇到内存管理问题。
早期可以用 C++ 的智能指针快速表达所有权关系,但如果要更接近真实引擎,就需要实现自己的对象堆和 GC。
一个可控的演进路径是:
css
代码解读
复制代码
先把所有引用类型统一放到 Heap Value 中保存对象引用 Environment / Stack / CallFrame 作为 GC Root 实现 mark-sweep 再考虑引用计数或分代优化
Mark-Sweep 的基本过程是:
markdown
代码解读
复制代码
1. 从根对象出发 2. 标记所有可达对象 3. 遍历堆,释放未标记对象
根对象包括:
- 当前执行栈上的值
- 全局变量环境
- 活跃函数调用帧
- 被闭包捕获的环境
- VM 常量池中的引用
这一步会把项目推进到语言运行时最核心的问题:对象生命周期如何管理。
总结
Tiny JavaScript VM 的价值不在于一开始就支持完整 JS,而在于把语言引擎的主链路拆开并逐步实现。
当前阶段的核心闭环是:
rust
代码解读
复制代码
Lexer -> Parser -> AST -> Interpreter -> Runtime
它已经能执行变量、表达式、控制流、函数、递归、数组和内置函数。
下一阶段会把 AST 执行路径升级为:
rust
代码解读
复制代码
AST -> Bytecode Compiler -> Stack-based VM
再继续补上:
rust
代码解读
复制代码
Object System -> Closure -> Garbage Collector
这条路线的好处是每一步都有明确目标,也都有可测试的结果。它不是一次性堆功能,而是在用工程化方式拆解一个语言引擎。