用 JS 写虚拟机完全可行,而且现在不少开源项目已经跑在生产环境里,核心思路是用 JavaScript 实现字节码解释器或 AST 遍历器,性能优化的重点在于执行引擎设计、内存布局和热路径优化。
用JS写虚拟机有什么应用场景?
很多人听到“JS 写虚拟机”第一反应是“图什么”,其实相当一部分场景需要的是灵活嵌入和动态部署,而不是极致性能。
最常见的场景包括:
- 在线代码编辑器/学习平台:前端执行用户输入的代码,SQL、Python 子集,后端只负责下发任务。
- 低代码平台:规则引擎、流程编排器,用户拖拽生成的逻辑,需要一种安全的中间表示来执行。
- IoT 设备管理面板:设备上报数据后,前端需要跑一套自定义过滤规则。
- 游戏技能脚本:小游戏里让玩家自定义宏或技能连招,理论上类似 MMORPG 的插件系统。
- 灰度发布规则引擎:在网关层或者前端 SDK 里执行一组 A/B 分流条件。
这些场景有一个共同点:逻辑需要频繁更新,但又不想走完整发布流程,行业共识认为,用 JS 做嵌入式解释器能把“改规则”变成“改配置”,JS 自带 JSON 生态和跨平台特性,字节码可以用 JSON 序列化传输,天然适配 Serverless 架构。
JavaScript虚拟机性能优化技巧
纯手写一个 AST 遍历器其实很简单,三五十行就能跑通,但性能会惨不忍睹,想达到接近生产可用的水平,需要从以下几层去优化。
执行引擎:跳过AST,直接上字节码
AST 遍历本质是递归 + 动态分派,每个节点访问都要查一次类型,多数情况下,AST 解释器的执行速度只有原生代码的百分之一,而且内存占用极高,更好做法是 先编译成字节码,再用一个 while 循环执行。
1 + 2 这个表达式,AST 是嵌套对象,字节码可以是:
PUSH 1
PUSH 2
ADD
JS 用数组来表示字节码流:
- 减少函数调用:每个操作码对应一个
switch case,紧凑且容易命中 CPU 分支预测。 - 指令缓存友好:字节码是线性数组,遍历时不会像 AST 那样跳跃访问堆内存。
实际项目中,用数组加 switch 的解释器性能比递归 AST 高 3 到 10 倍,这是相当常见的提升幅度。
栈式虚拟机与寄存器式虚拟机的取舍
字节码有两大流派:
- 栈式:操作数压栈,指令天然紧凑,JVM、Python 早期都是这个路线。
- 寄存器式:指令直接指向寄存器索引,Android 的 Dalvik 是典型代表。

在 JS 里做选择很简单:
| 维度 | 栈式 | 寄存器式 |
|---|---|---|
| 字节码大小 | 小 | 大,每条指令要多几个操作数 |
| 解释器实现 | 简单,用 Array 当栈即可 | 复杂,需要模拟寄存器的索引绑定 |
| 执行速度 | 稍慢,频繁 push/pop | 快,但 JS 数组访问不一定便宜 |
| 适合场景 | 快速实现,原型验证 | 追求吞吐量的正式实现 |
给你一个建议:栈式起步,寄存器式后续再优化,因为栈式解释器的 bug 更容易排查,先把整个流程跑通再谈性能,V8 引擎对 JS 数组的优化让简单的 pop / push 成本并不高。
用 TypedArray 重构内存布局
这是很多人忽略的一点:V8 对 “整数数组” 的优化依赖于数组里只存整数,如果你用普通数组存字节码和栈数据,一不留神混入一个浮点数或字符串,数组就会变成 “字典模式”,性能断崖式下跌。
解决方案是用 Uint8Array 存字节码,用 Int32Array 或 Float64Array 存操作数栈。
好处很具体:
- 类型固定,V8 不会再做 hidden class 的猜测。
- 二进制数据可直接复制进
ArrayBuffer,序列化/反序列化非常快。 - 栈顶指针就是一个整数下标,入栈出栈就是数组
arr[++sp] = x/arr[sp--],避免动态类型判断。
如果你要实现的是一门带数值计算的语言(比如小型的表达式引擎),TypedArray 重写栈后,性能提升 2 倍以上是很正常的。
高频指令的特化与内联缓存
在解释器主循环里做一个基准测试,你会发现最常见的操作码就那几种:PUSH、POP、ADD、LOAD_ATTR、CALL,优化它们等于优化整个系统。
- LOAD_ATTR 特化:访问对象属性是极其频繁的操作,直接每次查哈希表太贵,可以像 V8 一样做 内联缓存,即记住这个属性名在某个对象中的属性的偏移位置,下次遇到相同的 “对象形状 + 属性名” 组合,直接按缓存的索引读取属性。
- 类型判断前置:在执行 ADD 之前,先检查两个操作数都是整数,如果都是整数,走
a + b直接计算;否则再进入通用路径处理字符串拼接或浮点数。 - 避免大函数:不要把解释器的主循环写成一个巨大函数,V8 对函数内联的优化有限,太大容易超内联预算,拆成几个小型 helper 函数,再配合
// @inline使用,往往效果更好。
用 worker 线程隔离执行环境
如果你的虚拟机需要处理不可信代码,你不能在同一个进程里敞开着跑

,虽然 JS 天生单线程,但浏览器和 Node 都支持 Web Worker。
- 把虚拟机运行在 Worker 里,主线程只负责发送指令和接收结果。
- Worker 里的异常不会影响主线程。
- 内存和事件循环被天然隔离。
- 用
SharedArrayBuffer可以实现高吞吐的数据交换,前提是用户代码不能直接接触它。
这个方法特别适合实现在线运行的代码沙箱,普通做法是 postMessage 传 JSON,性能瓶颈往往在序列化上,改用 SharedArrayBuffer 之后,可以做到接近内存拷贝级别的数据传输速率。
JS实现自定义语言执行器时,内存管理怎么做
自己写解释器,内存管理逃不掉,JS 本身有 GC,但你的虚拟机内部分配的对象同样需要管理,如果你直接映射到 JS 对象,那就依赖 V8 的 GC,对于长生命周期对象(跨多次执行的全局变量),会造成频繁的 marking。
更好的方式是 在 TypedArray 里自己分配内存,用 ArrayBuffer 作为堆空间,自己写一个简单的 bump 分配器,或者用空闲链表,GC 策略可以选择:
- 引用计数:每个对象字段上维护一个 count,归零即回收,简单直接,但无法处理循环引用。
- 标记-清除:定期从根集合遍历,标记可达对象,然后清除,实现起来代码量较大。
- 代际回收:新对象在新生代,老对象晋升到老生代,这是 V8 的玩法,但你没必要自己做。
对多数场景,引用计数 + 循环引用检测 是一个不错的平衡,很多规则引擎的赋值操作很少,循环引用更罕见,直接用引用计数足够。
如果你做的是纯函数式的表达式执行器(无可变状态、无闭包分配),那内存管理的负担会小非常非常多,甚至可以复用同一个上下文对象,避免频繁分配。
虚拟机遇到性能瓶颈后,要不要换WebAssembly
如果纯 JS 解释器做到极限还是慢,通常意味着执行机制本身有浪费,这时有两个选择:
- 把热循环编译成 JS 代码:生成一段 JS 源码,
new Function执行,这相当于你从解释器升级为编译器,性能会提升一个数量级。 - 用 WebAssembly 写核心循环:把栈机、对象模型、GC 都迁到 Wasm,JS 只是壳。
行业共识认为,Wasm 的优化潜力更高,因为线性内存完全可控制,整数运算不受 V8 的 SMI 表示限制,但纯 Wasm 方案开发成本偏高,如果只是要跑内部 DSL,过度设计得不偿失。
一个折中路线:
- 解释器原型用纯 JS。
- 用
--trace-opt配合 V8 的优化日志找出热点指令。 - 把热点函数的派发逻辑降级为提前编译的 JS 分支。
多数情况下,这个流程走完后,剩下的性能差距已经不影响实际使用。

用JS实现自定义语言执行器的完整流程示例
给你一条实战路径,直接跟着做就能跑起来:
定义 token 类型:数字、标识符、操作符等
2. 用正则或手写 lexer 分词成 token 数组
3. 用递归下降解析器生成 AST(对象树)
4. 写一个简单的编译函数,遍历 AST 生成字节码数组
5. Uint8Array 保存字节码,Int32Array 保存常量池索引
6. 解释器主循环:switch(opcode) { ... }
7. 封装一个 execute(code, context) 接口对外
8. 用 Worker 包一层,隔离执行环境
第 4 步是核心中的核心,它决定了你的语言表达能力,字节码设计成栈式后,一条加法指令的 opcode 是 0x01,压栈是 0x02,取属性是 0x03,执行起来非常清晰。
可验证的优化成效
你可以在 Chrome DevTools 的 Performance 面板记录执行耗时,对比优化前后的执行时间:
| 优化手段 | 相对执行耗时(多次均值) |
|---|---|
| 递归 AST 遍历 | 1x(基线) |
| 字节码 + switch 循环 | 约 0.3x |
| TypedArray 栈 + 内联缓存 | 约 0.1x |
这个比例在不同硬件上会有浮动,但优化趋势是一致的。
特殊场景:在线 IDE 中的代码格式化器
如果你要把这套虚拟机用于在线 IDE 里的自定义格式化规则引擎,请注意线程模型,格式化操作可能很频繁,每敲一个字符就触发一次格式化验证,不要让虚拟机在主线程跑,直接扔到 Worker 里,否则输入延迟会给人“卡顿”的体验。
用JS写虚拟机到底合算吗?
如果你需要的是轻量级、可嵌入、可动态更新的规则引擎,用 JS 写虚拟机是合算的,尤其是低代码平台和 Serverless 场景,抛开性能和加载大小谈都是伪需求,性能优化的核心就是字节码 + TypedArray + 内联缓存 + Worker 隔离这四板斧,按顺序折腾完就能满足绝大多数业务需求。
用JS写虚拟机靠谱吗?
靠谱,但不是为了追求绝对性能,很多云产品的前端规则引擎就是这么干的,纯 JS 解释器跑一个简单的表达式在 0.1 毫秒内能完成,人感知不到延迟,如果代码规模大且频率高,再考虑 Wasm 路线。
最常见的性能坑是什么?
递归 AST 遍历和普通对象随机访问,这两点几乎总是一起出现的,AST 对象散落在堆里,遍历时的缓存局部性极差,解决办法就一条,改成字节码或扁平的指令数组。
需要引入 WebAssembly 吗?
并非必须,对大多数页面级规则引擎,纯 JS 字节码方案已经能跑 5000 条指令/毫秒,Wasm 只适合那些需要高吞吐数值计算的向量运算或密集算法场景,先把 JS 版跑通,再用量化数据决定是否值得迁移到 Wasm。