跪拜 Guibai
← All articles
JavaScript

Building a Tiny JavaScript VM in C++: Lexer, Parser, AST Interpreter, and the Road to Bytecode

By free35 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Most developers treat JavaScript engines as magic. Building a tiny one from scratch exposes the concrete data structures and algorithms behind every `let`, `while`, and function call, and provides a mental model that directly improves debugging and performance work in real engines like V8.

Summary

A new C++ project, MiniJS, builds a working JavaScript VM from scratch, not to compete with V8, but to isolate and understand each layer of a language engine. The current implementation runs a controlled JS subset through a full pipeline: a lexer tokenizes source code, a recursive-descent parser builds an AST, and a tree-walking interpreter executes it against a runtime with scoped environments, functions, arrays, and error handling. The architecture deliberately avoids the full ECMAScript spec to prevent drowning in edge cases before the core loop is solid.

The project is designed for phased evolution. With the AST interpreter serving as a semantic baseline, the next steps compile the AST to a stack-based bytecode VM, introduce a proper object system with prototype chains, and eventually implement a mark-sweep garbage collector. Each phase produces testable, verifiable output, and the same test suite will validate both the interpreter and the future VM to ensure behavioral parity.

This approach treats a language engine as an engineering problem to be decomposed, not a monolith to be copied. The result is a learning tool that demonstrates how tokens become trees, how scope chains resolve variables, and how a runtime can grow from a simple evaluator into a managed, stack-based virtual machine.

Takeaways
Lexer, Parser, AST Interpreter, and Runtime form a closed loop that already executes variables, control flow, functions, recursion, and arrays.
Lexer only tokenizes—it does not validate grammar; that boundary keeps both modules simple and testable.
Recursive-descent parsing maps each grammar rule to a function, with expression precedence enforced by the call hierarchy, not by operator tables.
AST nodes split cleanly into Statements (control flow, side effects) and Expressions (value computation), which simplifies interpreter dispatch.
An AST interpreter walks the tree directly, making it the fastest path to verifying semantic correctness before any bytecode work begins.
Runtime environments use chained scopes to support block scoping and function calls, with each invocation creating a fresh binding environment.
Arrays already use reference semantics, which is the first step toward a full object system with shared mutable state.
Phased evolution—interpreter first, then bytecode VM, then object system, then GC—mirrors how production engines were built and keeps each stage debuggable.
Future bytecode compilation will turn control flow into jump instructions and function calls into call frames with instruction pointers and stack bases.
A mark-sweep GC will treat the execution stack, global environment, and closure-captured environments as roots for reachability tracing.
Conclusions

Treating the AST interpreter as a semantic oracle for the future bytecode VM is a practical testing strategy that commercial engine teams also use.

Separating the Lexer from the Parser so strictly—no grammar validation in the tokenizer—is a design discipline that prevents a common source of tangled, hard-to-test frontends.

Building a JS subset first is not a compromise; it is a recognition that language complexity is multiplicative, and stabilizing the core pipeline prevents exponential debugging costs.

The project implicitly argues that understanding an engine requires building the wrong thing first (a slow tree interpreter) to establish what correct behavior even looks like.

Reference semantics for arrays sneak in heap-like complexity early, forcing the runtime to confront aliasing and mutation before objects formally exist.

Many developers conflate 'learning a language' with 'learning its implementation,' but this project shows that the implementation is a separate, layered system where each layer can be understood in isolation.

Concepts & terms
Lexer (Lexical Analysis)
The phase that converts raw source code characters into a stream of Tokens—keywords, identifiers, literals, and symbols—without any awareness of grammatical structure.
Recursive-Descent Parser
A top-down parsing technique where each grammar rule (statement, expression, etc.) is implemented as a function that calls other rule functions, naturally handling precedence through the call stack.
AST (Abstract Syntax Tree)
A tree representation of source code that captures the program's grammatical structure, discarding syntactic noise like semicolons and parentheses so later stages can operate on meaning, not text.
AST Interpreter
An execution model that walks the AST directly, recursively evaluating each node. It is simple to implement and ideal for verifying language semantics before optimizing to bytecode.
Bytecode VM
A virtual machine that executes a linear sequence of low-level instructions (bytecode) using an operand stack, rather than walking a tree. It decouples compilation from execution and enables faster interpretation.
Mark-Sweep Garbage Collection
A memory management algorithm that traces all reachable objects from a set of root references, marks them as live, and then frees any unmarked objects on the heap.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗