哼哧哼哧干了一周多代码,终于开始写文字了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
后续演进可以分几步:
- 定义 Bytecode 指令集。
- 实现 Chunk / Constant Pool。
- 编写 AST 到 Bytecode 的 Compiler。
- 实现 Stack-based VM。
- 让现有测试同时覆盖 AST Interpreter 和 VM。
- 逐步把函数调用、局部变量、跳转、循环、数组、对象编译到字节码。
控制流会变成跳转指令:
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
这条路线的好处是每一步都有明确目标,也都有可测试的结果。它不是一次性堆功能,而是在用工程化方式拆解一个语言引擎。