先说人话:这套教程到底在解决什么问题?
你大概率见过这种场景:
- 业务代码一上线,核心逻辑很快就被别人扒走
- 明明会用 Babel 写 AST 插件,但一碰到"怎么把 AST 变成可执行字节码"就卡住
- 知道闭包、作用域链、
this这些概念,可一旦要自己实现一个运行时,脑子里全是结
这套教程就是奔着这个痛点来的。我们不会停在"JSVMP 是什么"的概念介绍,而是带你把一个能跑起来的寄存器式 JSVMP,从编译到执行,一步一步拆开。
你会亲手做出什么?
一个最小可用的 JavaScript 虚拟化保护编译器:
- 输入是一段普通 JavaScript
- 中间会经过 AST、IR、字节码几个阶段
- 输出是一段自包含的 JS 文件,里面带着字节码和解释器
flowchart LR
A["源码
var x = 1 + 2"] --> B["Frontend
解析成 AST"] B --> C["Lowering
展平成 IR"] C --> D["Emit
编码成数字字节码"] D --> E["Pack
拼上运行时"] E --> F["最终输出
一段可执行 JS"]
var x = 1 + 2"] --> B["Frontend
解析成 AST"] B --> C["Lowering
展平成 IR"] C --> D["Emit
编码成数字字节码"] D --> E["Pack
拼上运行时"] E --> F["最终输出
一段可执行 JS"]
如果你把它类比成"翻译系统",会更好理解:
- AST 像语法分析后的句子结构
- IR 像翻译过程中的中间稿
- 字节码像只给内部员工看的工单编号
- VM 解释器像真正干活的执行班组
外面的人看到的是一堆编号,但系统内部知道每个编号该怎么做。
为什么这条路线值得学?
因为它会把很多平时"会用但说不清"的东西,逼着你真正吃透。
- 你会真正理解编译器后端在干什么,而不是只停在 AST 改写
- 你会知道闭包为什么本质上是"函数 + 活着的环境对象"
- 你会知道
var提升、let的 TDZ、this绑定这些语义,运行时到底该怎么还原
但也先把丑话说前面:这条路线不轻松。它不像写个 Babel 插件那样当天就能见效,中间会反复遇到"看上去只差一行,结果整个 VM 跑偏"的问题。也正因为这样,这套教程才适合想进阶的人。
这套项目的真实边界
这里不装全能。
- 项目核心目标是讲清楚 JSVMP 主链路,不是造一个完整 JS 引擎
- 当前重点覆盖的是 ES5 核心语义,以及项目里已经实现的对象、异常、闭包、
this、arguments等能力 - 某些高级语法、复杂解构、完整语言边角行为,不是这套代码当前阶段的重点
换句话说,它更像一台"教学级但能跑真代码"的样机,而不是拿来直接替代浏览器引擎。
这反而是它的价值所在。东西做得太大,读者只会被淹没;东西做得刚好,你才能看清每一个齿轮怎么咬合。
阅读方式建议
这套教程最好按顺序看,因为后面的章节会反复用到前面建立起来的几个核心心智模型:
- 寄存器是"中间结果的临时工位"
- slot 是"变量在环境里的固定抽屉"
- env 链是"运行时版本的作用域链"
- 字节码是"给 VM 执行的数字化操作清单"
如果你跳着看,单章也能读懂一部分,但很容易出现"每句话都认识,连起来不知道在说什么"的情况。
教程地图
| 阶段 | 文件 | 你会带走什么 |
|---|---|---|
| 00 | 00-tutorial-guide.md |
先把整条路线和预期建立起来 |
| 01 | 01-architecture-overview.md |
理解 JSVMP 是什么,为什么选寄存器机 |
| 02 | 02-instruction-set-design.md |
搞懂 opcode、寄存器、slot、常量池怎么配合 |
| 03 | 03-compiler-ast-to-ir.md |
看懂 AST 为什么要先降成 IR,以及 lowering 怎么写 |
| 04 | 04-emit-and-runtime.md |
把符号化 IR 编成数字字节码,再交给 VM 跑起来 |
| 05 | 05-es5-core-features.md |
闭包、this、arguments、提升这些硬骨头怎么落地 |
| 06 | 06-testing-and-debugging.md |
怎么验证你的 VM 不是"看起来能跑,其实语义错了" |
配套示例怎么跑?
仓库已经准备好了按章节拆开的示例。建议你一边看文档,一边跑对应例子,不要只看不动手。
bash
pnpm build
node docs/examples/01-architecture/01-hello-vmp.js
node docs/examples/02-instruction-set/02-bytecode-decoder.js
node docs/examples/04-emit-and-runtime/01-step-by-step-execution.js
如果你把教程当成"视频字幕"来扫,收获会很有限。最有效的方式,是边读边猜结果,再运行示例验证自己的理解。
读代码前,先记住这几个核心文件
graph TD
A["src/compiler/frontend.ts
源码 -> AST"] --> B["src/compiler/lowering.ts
AST -> IR"] B --> C["src/compiler/emit.ts
IR -> 字节码"] C --> D["src/compiler/runtime-gen.ts
生成解释器源码"] D --> E["src/compiler/pack.ts
打成最终 JS"]
源码 -> AST"] --> B["src/compiler/lowering.ts
AST -> IR"] B --> C["src/compiler/emit.ts
IR -> 字节码"] C --> D["src/compiler/runtime-gen.ts
生成解释器源码"] D --> E["src/compiler/pack.ts
打成最终 JS"]
lowering.ts是编译器最考验基本功的地方emit.ts负责把"人能看懂的指令"压成数字runtime-gen.ts是最容易出细碎 bug 的地方,因为这里的pc、env、寄存器都得严丝合缝
你应该带着什么问题往下读?
我建议你边读边盯住这 4 个问题:
- AST 为什么不能直接执行,非得先变成 IR?
- 变量名为什么不直接塞进寄存器,而要分成 slot 和寄存器两套体系?
- 闭包捕获的到底是"值",还是"环境对象"?
- 为什么 VM 里最难查的 bug,往往不是算法错,而是状态没对齐?
后面的每一章,都会围着这几个问题慢慢把账算清楚。
一句话记住这套教程
这不是一套"介绍 JSVMP"的文档,而是一套带你把编译器、字节码和运行时真正接起来的工程化拆解。