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

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

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

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

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

这个项目的目标,是用 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

后续演进可以分几步:

  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

函数调用会变成调用帧:

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

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

相关推荐
码艺-Alimjan13 分钟前
维吾尔语 RTL 排版的隐形利器:`\u200F`
前端·javascript
Highcharts.js43 分钟前
Highcharts 数据过滤实战指南:从基础实现到性能优化
javascript·性能优化·bi·highcharts·前端可视化·数据过滤·交互图表
何何____2 小时前
TypeScript 函数类型兼容性
前端·javascript·typescript
雪芽蓝域zzs2 小时前
json数据转化成interface属性
前端·javascript·json
鬼手点金3 小时前
ReAct 循环
linux·服务器·前端·javascript·react.js·前端框架
雪芽蓝域zzs3 小时前
Vue导入方法三种方式对比
前端·javascript·vue.js
VimorphTech5 小时前
React 任务轮询,为什么不能只写一个 setInterval?
javascript·typescript
lexiangqicheng5 小时前
Docker的沙箱机制是怎样的?
前端·javascript
LCG米6 小时前
STM32 HardFault故障定位实测:压栈帧寄存器解析、CFSR故障源判读与出错地址回溯
前端·javascript·stm32·单片机·嵌入式