掘金热榜文章《纯 Go 写的 JS 引擎,跑分超过了 C 语言的 QuickJS》报道了 Goja 在部分基准中超过 QuickJS。 Goja 在部分基准中超过 QuickJS,说明纯 Go 方案已经能在特定工作负载下形成竞争力,但这属于局部结果,不能据此认定版本兼容、内存占用、长期稳定性和生产效率也全面领先。
需要区分三个问题:Goja 为什么能在某些测试中更快;“ECMAScript 5.1+”能不能承载现代 JavaScript;Go 服务、插件系统和动态脚本应该选 Goja、QuickJS,还是带 JIT 的 V8、JSC。现有资料能支持“Goja 是无 CGO 依赖的纯 Go ECMAScript 5.1+ 引擎”这一定位,但没有提供一套足以复现全部排名的完整 QuickJS 对比环境,因此更可靠的选型方法仍是固定环境、验证等价结果,再测自己的业务负载。
跑赢 QuickJS 究竟比较了什么?
“超过”必须绑定引擎版本、测试套件、输入数据和计时边界。不同版本可能调整解析器、正则、对象模型或内建函数,同一段脚本在不同预热状态下也会得到完全不同的结果。
一份可用于复核的记录,至少应包含以下内容:
| 类别 | 必须记录的内容 |
|---|---|
| 引擎版本 | Goja 使用的模块版本和源码提交;QuickJS 的上游版本、提交号和本地改动 |
| Go 环境 | Go 版本、GOOS、GOARCH、CGO_ENABLED、编译参数 |
| 宿主环境 | 操作系统及内核、CPU 型号、核心数、内存容量、是否处于节能或降频状态 |
| QuickJS 构建 | C 编译器及版本、配置参数、优化级别、是否启用扩展 |
| 测试输入 | 原始脚本、数据规模、预热输入与正式输入、预期输出 |
| 测试协议 | 预热次数、正式重复次数、超时时间、是否启用并发 |
| 输出指标 | 墙钟耗时、CPU 时间、内存分配、GC 次数或停顿、常驻内存、P95/P99 延迟 |
这里最容易出错的是“正确性门槛”。两个引擎必须完成相同功能并产生等价结果;如果 QuickJS 无法解析某段语法,而 Goja 能执行,这首先是兼容性和支持范围差异,不能简单记成 Goja 更快。反过来,如果某项测试提前异常退出,也不能把异常处理时间当成正常执行耗时。
运行阶段应分别测量:
| 阶段 | 建议计时边界 | 主要回答的问题 |
|---|---|---|
| 冷启动 | 进程启动、Runtime 初始化、首次解析和执行 | CLI、容器和函数计算场景的启动成本 |
| 首次编译 | Runtime 已创建,仅测脚本首次准备成本 | 短任务是否被编译阶段主导 |
| 预热后短任务 | 固定预热次数后重复执行 | 插件、规则计算等高频短任务 |
| 长时间稳态 | 持续运行并分段采样 | 分配、GC 和长期吞吐是否稳定 |
| 跨语言调用 | 分别测纯脚本和 Go/JavaScript 往返调用 | 宿主边界是否吞掉引擎性能差异 |
参考资料还提到 Goja 相比其他 Go JavaScript 引擎快 6~7 倍。 但这与“部分基准超过 QuickJS”不是同一测试口径:前者比较的是 Go 生态中的引擎,后者比较的是 Goja 与 QuickJS。不能把两个结论拼接成“Goja 比 QuickJS 快 6~7 倍”,现有资料也不足以支持这样的数字。
现有资料未提供完整原始数据,不能据此补写更大的倍数;可复核的记录应采用下面的表格:
| 场景 | 结果等价 | 中位数 | P95/P99 | 分配量 | GC | 常驻内存 | 原始输出 |
|---|---|---|---|---|---|---|---|
| 冷启动 | 通过/失败 | 待填 | 待填 | 待填 | 待填 | 待填 | 文件或命令 |
| 预热稳态 | 通过/失败 | 待填 | 待填 | 待填 | 待填 | 待填 | 文件或命令 |
| 正则与字符串 | 通过/失败 | 待填 | 待填 | 待填 | 待填 | 待填 | 文件或命令 |
| Go/JS 边界调用 | 通过/失败 | 待填 | 待填 | 待填 | 待填 | 待填 | 文件或命令 |
纯 Go 为什么可能在某些任务中跑得更快?
“纯 Go”首先说明实现和交付方式,不能直接据此推出性能结论。Goja 无需 CGO,可直接进入 Go 项目的构建、依赖和跨平台交付链路。 这能减少构建和交付环节的集成成本,但不能自动证明解释执行、对象分配或正则处理一定更快。
阅读 Goja 源码时,可以从公开入口 RunString 顺着调用链检查脚本如何被解析成内部程序表示,运行时如何处理变量和内建对象,函数调用如何进入求值路径,以及结果如何成为 Goja Value。随后再分别检查正则、严格模式、Go 函数回调和导出转换。不同版本的内部文件与函数命名可能变化,因此不应脱离具体提交讨论固定的内部结构。
一次典型调用可以抽象为:
Go 宿主调用
-> Runtime.RunString
-> 脚本解析与内部程序准备
-> 运行时上下文、变量与内建对象
-> 表达式求值、函数调用与控制流
-> Goja Value 结果
-> ExportTo 导出,或通过 Callable 返回 Go
局部领先的来源需要通过剖析验证,不能只依据语言标签判断。
首先,短任务可能主要消耗在解析、首次准备和固定开销上。此时,某个引擎内部数据结构、对象查找方式或 Go 编译器优化效果,都可能改变结果;预热充分的稳态测试则可能呈现另一套排序。
其次,Go 与 JavaScript 的边界调用会稀释引擎差异。如果一个基准反复把结构体、切片或对象图转成 JavaScript 值,再把结果导回 Go,测到的可能主要是装箱、属性映射和分配,而不是脚本执行本身。
再次,Goja 作为 Go 进程的一部分,其对象分配和生命周期受 Go 垃圾回收器影响。少分配的实现可能获得更稳定的尾延迟;频繁创建临时对象或跨边界复制数据,则可能带来额外 GC 压力。
最后,基准脚本是否真正等价也会影响结果。一个主要计算数值表达式的脚本、一个大量创建对象的脚本,以及一个以正则替换为主的脚本,实际上是在测试完全不同的引擎路径。
Go 侧可以直接使用标准工具链观察 CPU、分配和 GC:
go test -run '^$' -bench . -benchmem -count=10 ./bench
go test -run '^$' -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out ./bench
go tool pprof -top cpu.out
go tool pprof -top mem.out
GODEBUG=gctrace=1 go test -run '^$' -bench . -benchmem ./bench
其中 ./bench 应替换为实际基准包路径。如果 cpu.out 的热点集中在宿主转换、正则或对象分配,那么继续比较“解释器与解释器”的整体速度意义不大,应该先优化边界和数据布局。
QuickJS 不带 JIT;带 JIT 的引擎在不少计算任务上可能明显领先。2021 年的选型文章给出的旧有经验值是平均存在 5~10 倍差距。 这组数据用于说明 JIT 带来的量级差异,不能直接套用到 Goja 与 QuickJS 的比较中。现有资料也没有证明 Goja 属于 JIT 引擎,因此更不应把 Goja 和 V8、JSC 简单归为同一执行模型。
“ES5.1+”的加号究竟覆盖了哪些现代场景?
“ES5.1+”中的加号不是正式的 ECMAScript 版本号,也不代表通过了某个完整的一致性测试套件。参考资料称 Goja 完整支持 ECMAScript 5.1,包括正则表达式和严格模式;“+”具体覆盖了哪些后续能力,则不能仅凭这个标签展开。
QuickJS 的资料口径更明确:支持 ES2023,包括模块、异步生成器、Proxy 和 BigInt,并可选择提供 BigDecimal、BigFloat 与运算符重载等数学扩展。 两者可先按以下矩阵比较:
| 能力维度 | Goja | QuickJS | 验证重点 |
|---|---|---|---|
| ECMAScript 5.1 | 资料称完整支持。 | 资料称支持 ES2023。 | 用项目中的实际语法和错误路径验证 |
| 严格模式 | 资料明确列为支持。 | 给定资料未单独展开 | 检查抛错类型、作用域和 this 行为 |
| 正则表达式 | 资料明确列为支持。 | 给定资料未单独展开 | 检查替换、捕获组、Unicode 和边界行为 |
| 模块 | “ES5.1+”资料未给出逐项保证 | 明确支持。 | 同时验证语法支持与模块加载器 |
| 异步生成器 | 给定资料未确认 | 明确支持。 | 验证迭代协议和宿主调度 |
| Proxy | 给定资料未确认 | 明确支持。 | 检查代理陷阱、对象身份和异常传播 |
| BigInt | 给定资料未确认 | 明确支持。 | 检查与 Number 混合运算及序列化 |
| BigDecimal、BigFloat、运算符重载 | 给定资料未确认 | 可选扩展支持。 | 确认构建是否启用以及业务是否依赖 |
| Node.js、DOM、网络、定时器 | 不属于 ECMAScript 引擎本身,需由宿主提供 | 同样需要宿主提供 | 分开测试语言能力和宿主 API |
“资料未确认”不等于“不支持”,只表示不能拿现有资料替项目下结论。兼容性验证应使用分层测试:
| 测试层 | 应使用的材料 | 通过标准 |
|---|---|---|
| 语法与语义 | 项目真实代码片段 | 输出、异常类型和错误路径一致 |
| 内建对象与正则 | 日期、字符串、数组、对象、正则调用 | 结果一致,关键边界无差异 |
| 现代特性 | 模块、异步生成器、Proxy、BigInt 等实际依赖 | 特性可执行,异步顺序符合预期 |
| 第三方依赖 | 锁定版本后的真实 npm 包或内部模块 | 初始化、主要调用和错误分支均通过 |
| 前端产物 | 实际构建配置生成的 bundle | 能加载、执行并产生预期 DOM 或业务结果 |
| 宿主 API | 文件、网络、定时器、控制台和模块解析 | 由明确实现的宿主能力满足 |
| 故障路径 | 主动抛出异常、非法输入、超限脚本 | JavaScript 异常能映射为可处理的 Go 错误 |
兼容性验证还应覆盖第三方 npm 包和前端 bundle。一个包可能语法完全受支持,却因为依赖 process、文件系统、定时器或 Node 模块解析器而无法运行;浏览器构建产物也可能依赖 DOM。语言层通过与业务层通过,应当记录成两个不同结论。
Go 与 JavaScript 来回传值时,真实成本在哪里?
Goja 提供的最小集成路径包括:使用 vm.Set 注入 Go 值或函数,使用 vm.RunString 执行脚本,使用 vm.ExportTo 把结果导出到 Go,以及用 goja.AssertFunction 获取可从 Go 调用的 JavaScript 函数。
下面的示例同时展示了三个方向:
package main
import (
"fmt"
"log"
"github.com/dop251/goja"
)
func main() {
vm := goja.New()
if err := vm.Set("name", "Goja引擎"); err != nil {
log.Fatal(err)
}
if err := vm.Set("version", "1.0"); err != nil {
log.Fatal(err)
}
if err := vm.Set("add", func(a, b float64) float64 {
return a + b
}); err != nil {
log.Fatal(err)
}
if _, err := vm.RunString(`
function greet(name) {
return "你好," + name + "!";
}
`); err != nil {
log.Fatal(err)
}
result, err := vm.RunString(`greet(name + " " + version) + ",和为 " + add(20, 22)`)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.String())
jsResult, err := vm.RunString("Math.PI * 2")
if err != nil {
log.Fatal(err)
}
var goResult float64
if err := vm.ExportTo(jsResult, &goResult); err != nil {
log.Fatal(err)
}
fmt.Printf("JavaScript 计算结果:%f\n", goResult)
greetValue, err := vm.Get("greet")
if err != nil {
log.Fatal(err)
}
greetFunc, ok := goja.AssertFunction(greetValue)
if !ok {
log.Fatal("greet 不是可调用函数")
}
greeting, err := greetFunc(goja.Undefined(), vm.ToValue("开发者"))
if err != nil {
log.Fatal(err)
}
fmt.Println(greeting.String())
}
安装依赖:
go get github.com/dop251/goja
压测应覆盖 Go 与 JavaScript 的边界转换。只测一段纯 JavaScript,无法反映这部分成本:
| 边界操作 | 可能产生的成本 | 常见优化方式 |
|---|---|---|
| 基本类型传入与导出 | 接口装箱、Value 包装、类型校验 | 优先传数字、布尔值和字符串,减少包装层 |
| 结构体、映射、切片传入 | 属性映射、反射或对象包装 | 传稳定 ID,在 JavaScript 侧按需查询 |
| 大型对象图反复传入 | 属性复制、临时分配和 GC | 批量处理,或保持数据在主要运行的一侧 |
| Go 函数被 JavaScript 调用 | 参数转换、返回值包装、异常映射 | 缓存 Callable,减少调用次数,控制参数数量 |
| JavaScript 异常返回 Go | 错误包装、堆栈整理 | 保留原始错误信息,避免重复解析错误文本 |
| Promise、微任务或异步函数 | 宿主事件循环和任务调度 | 明确由谁驱动微任务,避免等待无人推进的任务 |
如果业务以高频小调用为主,Go 与 JavaScript 的边界成本很可能比两个引擎的执行速度差异更大。比较前可以先做两项优化:一是把对象图转换成 ID 或紧凑数组;二是把多个小调用合并成一次批量调用。优化后仍然存在的差距,才更接近引擎本身的差距。
并发方面,不应假定同一个 Runtime 可以被多个 goroutine 无锁并发复用。稳妥做法是为独立工作单元创建 Runtime,或者在宿主层串行化访问,并用 Go 的 race detector 检查相关路径。若脚本不可信,仅创建一个独立 Runtime 并不等于完成安全隔离;CPU、内存、文件系统和网络权限仍应放在明确的边界内,必要时采用独立进程。
不依赖 CGO 的收益,是否足以抵消生产环境代价?
Goja 无需 CGO,可以直接跟随 Go 项目构建,并减少本地编译和容器交付中的原生工具链依赖。 对 Go 服务而言,这项工程收益通常比局部基准中的速度优势更有价值:
CGO_ENABLED=0 go build -trimpath -o app ./cmd/app
这条命令可以用于验证目标包是否能够在关闭 CGO 的条件下完成构建。./cmd/app 应替换为实际入口路径。
但“不依赖 CGO”和“运行速度更快”是两件事。前者影响构建、交叉编译、静态交付和依赖管理;后者还取决于对象模型、分配策略、垃圾回收、正则、宿主接口以及工作负载。
生产评估至少应同时记录:
| 指标 | 为什么重要 |
|---|---|
| 二进制大小 | 影响容器镜像分发和 CLI 下载 |
| 冷启动时间 | 影响短生命周期任务和按需启动 |
| 常驻内存 | 影响多 Runtime 和多租户部署 |
| 每操作分配量 | 决定长期 GC 压力 |
| GC 暂停与尾延迟 | 决定 P95/P99 是否稳定 |
| 稳态吞吐 | 决定规则、转换和批处理任务的单位时间成本 |
| 并发隔离成本 | 决定能否安全地服务多个脚本执行单元 |
| 升级与维护成本 | 决定规范跟进、安全修复和故障定位的长期投入 |
JavaScript 引擎选型还要考虑语法支持、调试体验、平台规范、包体积和内存,不能只比较速度。 纯 Go 可以降低一部分集成成本,其他成本仍需单独评估。
移动端还要单独处理平台约束。参考资料提到 iOS 平台对集成带 JIT 的虚拟机存在限制。 这条信息来自 2021 年面向特定平台的资料,不应直接当成今天的通用规则,但足以说明:桌面服务器上的引擎结论不能原样复制到移动应用。当前项目仍应核对目标系统版本、应用商店规则和引擎许可。
维护层面也需要评估错误栈是否可读、profiling 是否顺畅、依赖升级是否稳定,以及 Goja 与 JavaScript 规范之间的同步维护负担。纯 Go 消除了 C/C++ 桥接,并不消除规范兼容性成本。
放到真实项目中,哪些场景会加分?
Goja、QuickJS 和带 JIT 的引擎需要按场景取舍。下表是基于工程目标的初步方向,不构成固定排名:
| 场景 | Goja | QuickJS | V8、JSC 等 JIT 引擎 |
|---|---|---|---|
| Go 服务内嵌动态插件 | 集成路径直接,适合优先验证 | 能嵌入,但需要处理 C 工具链和跨语言接口 | 能力强,但部署与桥接复杂度通常更高 |
| 规则、模板扩展、配置计算 | 适合用真实业务脚本验证 | 同样是候选方案 | 只有在规模或语义需求明确时再引入 |
| 现代前端工具链 | 兼容性通过后才可采用 | ES2023 语言层覆盖更明确。 | 可作为浏览器语义和性能参照 |
| 长时间 CPU 密集计算 | 必须观察分配和尾延迟 | 无 JIT,资料中的选型文章提醒其与 JIT 引擎可能存在明显差距。 | 应纳入最终基准,不能只与解释器比较 |
| 内存敏感服务 | 以实际 Runtime 数量和对象图测量 | 以实际构建和宿主开销测量 | 不能仅凭语言标签判断,必须测常驻内存 |
| 短生命周期 CLI | 重点看冷启动和交付体积 | 同时比较原生库交付和启动成本 | 需把引擎及运行时体积计入总成本 |
| 受限平台或无 CGO 环境 | 值得优先验证 | 受构建链约束 | 还需检查平台规则和许可 |
| 不可信脚本 | 必须补齐资源限制和隔离 | 同样需要隔离 | 同样不能把引擎对象当成安全沙箱 |
在 Go 服务内嵌插件、运行规则引擎以及频繁传递数据的场景中,Goja 的优势主要体现为集成摩擦较小;现有证据不足以证明所有脚本都更快。若主要成本来自边界调用,应先优化调用频率和数据布局。
对于依赖模块、异步生成器、Proxy、BigInt 或完整 npm 生态的项目,兼容性门槛应先于性能比较。QuickJS 的资料明确给出了 ES2023 覆盖范围,因此它在这一维度上提供了更清晰的语言层基线;但如果项目只使用 Goja 已经验证通过的功能,也没有必要为了“更新”强行更换。
对于浏览器或移动端替代方案,还必须把 DOM、网络、存储和系统能力单独列项。一个能执行 JavaScript 的引擎,不等于一个浏览器运行环境。JSC 是 WebKit 的默认内嵌引擎,V8 也拥有更完整的长期优化体系;把二者作为参照,有助于校准纯 Go 方案的能力边界。
下一步:怎样用三组实验完成最终选择?
最终选择需要同时完成三类验证:基准实验测量性能,兼容实验确认业务能否正确运行,部署实验评估是否适合长期运行。三组实验应使用同一套业务输入和预期输出。
基准实验应锁定 Goja 模块版本、源码提交、QuickJS 提交、Go 版本、操作系统、CPU、编译参数和测试数据;先验证结果等价,再分别测冷启动、预热稳态、长时间运行、正则、对象分配和 Go/JavaScript 边界调用。保留每次原始输出,不只发布平均值,同时记录分配量、GC、P95/P99 和常驻内存:
go version
go env GOOS GOARCH CGO_ENABLED
git rev-parse HEAD
go test -run '^$' -bench . -benchmem -count=10 ./bench
go test -run '^$' -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out ./bench
go tool pprof -top cpu.out
go tool pprof -top mem.out
兼容实验则从真实项目提取语法、内建对象、正则、第三方依赖、前端产物、错误路径和宿主 API,形成通过/失败清单。对每个失败项记录是语言能力缺失、模块加载器缺失,还是宿主 API 未实现,避免把不同原因混成“Goja 不兼容 JavaScript”。
部署实验验证无 CGO 构建,记录二进制大小、冷启动、常驻内存、GC 和尾延迟;再测试多个 Runtime 的隔离、并发访问、异常传播、超时和资源上限:
CGO_ENABLED=0 go build -trimpath -o app ./cmd/app
Linux 环境下还可以用系统工具观察进程的峰值资源:
/usr/bin/time -v ./app
最终选择要核对三个结果:兼容性是否通过;业务负载下的性能是否在可接受的波动范围内领先;部署与维护成本是否可接受。如果 Goja 只在冷启动上获胜,却在稳态吞吐或尾延迟上持续落后,可以把它用于轻量规则,把重任务交给其他引擎;如果现代特性不通过,也不应依靠大量补丁把语言缺口伪装成兼容性。
应先从真实项目中挑出三到五段代表性脚本,固定版本完成这三组实验。热榜标题只能作为线索,最终选型应由自己的输入、自己的预期输出和可复核的运行数据决定。
[email protected]
。
我们承诺在核实信息后,将第一时间删除相关内容或断开链接。
























暂无评论内容