从 0 实现一个 Tiny JavaScript VM:项目架构拆解 这个项目的目标是用 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 RightBraceLexer 的职责包括跳过空白字符。识别数字、字符串等字面量。识别标识符和关键字。识别运算符和分隔符。记录源码位置。产生词法 diagnostic。再次强调不关心语法结构。所以Lexer 不应该判断while后面有没有(也不应该判断1 ;是否语法错误。这些属于 Parser。Lexer 就像一个幼儿园识字老师。她的唯一工作是看到while认出这是个单词关键字看到(认出这是个左括号符号看到1认出这是个数字看到;认出这是个句号/结束符好的模块边界是Lexer 只负责 TokenParser 负责结构。4. Parser把 Token 流变成 ASTParser 负责语法分析。它消费 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 直到条件为 falseAST Interpreter 的优点是实现直观每种 AST 节点对应一段执行逻辑。很容易验证语言语义。适合先把作用域、函数、数组、错误处理跑通。测试失败时容易定位问题。它的缺点也明显执行效率不高每次都在树上递归解释。但这正是合理的第一阶段。先把语义做对再考虑把 AST 编译成字节码。7. Runtime值、环境、函数和错误Runtime 是解释执行时真正承载状态的部分。运行时重点包括arduino代码解读复制代码Value 表示运行时值 Environment 维护变量绑定和作用域链 RuntimeError 表示运行时错误 Builtin 提供 print 等内置函数运行时值目前支持numberbooleannullarrayfunction环境模型支持块级作用域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 InterpreterAST 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 VMAST Interpreter 是直接执行树。Bytecode VM 则会先把 AST 编译成线性的指令序列ini代码解读复制代码1 2 * 3;可能编译成代码解读复制代码OP_CONSTANT 1 OP_CONSTANT 2 OP_CONSTANT 3 OP_MUL OP_ADD OP_POPVM 执行时维护一个操作数栈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绑定原型链查找构造函数和newParser 层要支持对象字面量和成员访问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这条路线的好处是每一步都有明确目标也都有可测试的结果。它不是一次性堆功能而是在用工程化方式拆解一个语言引擎。