Go Goja 怎么预编译脚本并在多个运行时执行
来源:17golang原创
时间:2026-10-13 15:23:56 306浏览 收藏
如果一段 JavaScript 规则会被反复用于不同请求,直接对每个请求调用 RunString 会把解析和编译也带进每次执行。Goja 提供的 goja.Compile 可以先生成一个与运行时无关的 *goja.Program,之后把这个 Program 交给多个独立的 Runtime 使用。
官方地址:https://github.com/dop251/goja
正确的复用单位是“共享只读的 Program,隔离每个 Runtime 的状态”。Program 可以被多个 Runtime 使用,但 Runtime 本身不能被多个 goroutine 同时操作;请求数据也应通过各自 Runtime 的输入绑定注入。
先分清 Program 与 Runtime 的职责
Compile(name, src, strict) 负责把脚本文本转换成 Goja 的内部编译表示;Runtime.RunProgram 再在某个 Runtime 的全局上下文中执行它。Program 不绑定某一个 Runtime,因此可以缓存或被多个 Runtime 读取;变量、对象、全局函数和执行结果则属于具体 Runtime。
这一区分很重要:如果把 Runtime 放进全局变量后让多个请求同时调用,问题不是“Program 没有缓存”,而是运行时状态被并发访问。Goja 官方资料明确说明,一个 Runtime 同时只能由一个 goroutine 使用。

准备模块并编译一次脚本
先创建一个最小 Go module,并安装 Goja。官方仓库当前 README 还列出了它的 Go 版本要求,实际项目应以仓库当前说明和自身锁定的依赖版本为准。
# 创建实验目录并初始化 Go module。 mkdir goja-program-demo cd goja-program-demo go mod init example.com/goja-program-demo # 引入 Goja;版本由当前 module 的依赖解析结果记录到 go.mod。 go get github.com/dop251/goja
把脚本的职责限制在“读取 input 并返回一个对象”。这样可以清楚看到:编译只依赖源代码,业务数据在 Runtime 中注入。
package main
import (
"fmt"
"github.com/dop251/goja"
)
const script = `
function quote(input) {
// 只读取当前 Runtime 中的 input,不把请求数据写入共享 Program。
return {
id: input.id,
total: input.price * input.quantity
};
}
quote(input);
`
// compileProgram 只负责编译脚本文本;语法错误应在初始化或加载阶段暴露。
func compileProgram() (*goja.Program, error) {
return goja.Compile("quote.js", script, false)
}
func main() {
program, err := compileProgram()
if err != nil {
// 编译失败时没有可执行的 Program,调用方应停止发布或加载。
panic(err)
}
fmt.Printf("compiled program: %T\n", program)
}
这里使用 Compile 而不是 MustCompile,是因为脚本可能来自配置、数据库或管理端输入。初始化阶段可以把错误记录清楚;只有源码随程序一起发布且编译失败代表开发错误时,才适合考虑 MustCompile。
在多个 Runtime 中传入数据并执行
编译得到 Program 后,每次执行都新建一个 Runtime,把当前请求的 input 放入该 Runtime,再调用 RunProgram。下面的辅助函数不共享 Runtime,因此既可以顺序调用,也可以由多个 goroutine 分别调用。
// runProgram 为一次执行创建独立 Runtime,避免跨请求共享全局对象。
func runProgram(program *goja.Program, input map[string]any) (any, error) {
vm := goja.New()
if err := vm.Set("input", input); err != nil {
// 输入转换失败时返回错误,不让错误数据继续进入脚本。
return nil, fmt.Errorf("set input: %w", err)
}
value, err := vm.RunProgram(program)
if err != nil {
// JavaScript 异常属于运行错误,与 Compile 阶段的语法错误分开处理。
return nil, fmt.Errorf("run program: %w", err)
}
return value.Export(), nil
}
func main() {
program, err := compileProgram()
if err != nil {
panic(err)
}
inputs := []map[string]any{
{"id": "A-100", "price": 12.5, "quantity": 2},
{"id": "B-200", "price": 8.0, "quantity": 3},
}
for _, input := range inputs {
result, err := runProgram(program, input)
if err != nil {
fmt.Println(err)
continue
}
// 结果属于本次 Runtime,导出后交给 Go 业务层继续处理。
fmt.Printf("%v\n", result)
}
}
这个例子里只编译一次脚本,两个输入分别进入两个生命周期独立的 Runtime。value.Export() 把脚本返回的对象转换为 Go 值;如果希望得到严格的结构体,可以在导出阶段使用 Runtime.ExportTo,而不是把 Runtime 或 JavaScript 对象跨请求保存。

检查错误、并发边界与状态隔离
把执行路径拆开后,错误可以按来源处理:
| 错误位置 | 典型表现 | 处理建议 |
|---|---|---|
Compile | 脚本文法或编译选项不合法 | 拒绝缓存该 Program,并记录脚本来源 |
vm.Set | 输入无法转换到 Goja 值 | 在进入脚本前返回输入错误 |
RunProgram | 脚本抛异常或访问了不存在的数据 | 把它作为本次请求的运行失败处理 |
Export / ExportTo | 结果结构与 Go 目标类型不匹配 | 在边界层返回转换错误,不复用脏结果 |
如果要并发执行,同一个 program 可以被多个调用读取,但每个调用都应拥有自己的 vm := goja.New()。不要把同一个 Runtime 放入 sync.Pool 后直接交给多个 goroutine;即使通过池化降低创建频率,也必须保证一次只由一个 goroutine 使用,并在归还前清理会影响下一次请求的全局变量。
// 并发调用时共享 Program,但每个任务仍通过 runProgram 创建自己的 Runtime。
func runBatch(program *goja.Program, inputs []map[string]any) {
var wg sync.WaitGroup
for _, input := range inputs {
input := input
wg.Add(1)
go func() {
defer wg.Done()
// 不把 vm 提到循环外,避免多个 goroutine 共享 Runtime。
result, err := runProgram(program, input)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
}()
}
wg.Wait()
}
这段并发片段需要在文件导入 sync,并沿用前面的 runProgram。输出顺序不应作为业务协议,因为 goroutine 完成顺序是不固定的;如果业务需要稳定顺序,应在 Go 层给任务编号并收集结果。
扩展为缓存和受控复用
实际服务可以把脚本源文本映射到 *goja.Program 缓存,但缓存键要包含会改变语义的内容,例如脚本版本、严格模式选项或租户边界。编译成功后再写入缓存,编译失败不能用旧的半成品覆盖有效 Program。
Runtime 是否池化要单独决定。Program 复用通常只要求保存一个只读对象;Runtime 池化则要求完整清理全局状态、回调、定时器和业务注入对象,排查成本更高。若单次脚本很短,优先保持“一个执行一个 Runtime”的清晰模型,再用真实业务基准判断创建成本是否值得优化。
还要留意脚本执行本身可能耗时过长。Goja 官方仓库提供了 Runtime.Interrupt 相关能力,但中断策略需要结合服务的取消、超时和错误回收方案设计;不要只把 goroutine 停止当成脚本已经安全结束。
清理与总结
- 用
goja.Compile把稳定脚本文本转换成可缓存的*goja.Program。 - 每次请求创建独立
Runtime,通过Set注入输入,再调用RunProgram。 - Program 可以在多个 Runtime 间共享;Runtime 不能被多个 goroutine 同时使用。
- 分别处理编译错误、输入转换错误、JavaScript 运行错误和结果导出错误。
- 只有在能清理状态并有基准数据时,才把 Runtime 放进池化复用方案。
相关问题
Program 可以在多个 Runtime 中同时执行吗?
可以。Program 与 Runtime 解耦,多个 Runtime 可以分别调用 RunProgram;并发时仍需保证每个 goroutine 使用自己的 Runtime。
为什么不直接缓存 Runtime?
Runtime 内含全局对象和执行状态,缓存后必须处理并发互斥、请求数据残留和异常恢复。缓存 Program 只复用编译结果,隔离边界更清晰。
RunProgram 和 RunString 有什么区别?
RunString 接收脚本文本并执行,适合一次性脚本;RunProgram 接收已经由 Compile 生成的 Program,适合同一脚本被反复执行的路径。
-
174 收藏
-
119 收藏
-
435 收藏
-
405 收藏
-
311 收藏
-
111 收藏
-
312 收藏
-
298 收藏
-
389 收藏
-
285 收藏
-
305 收藏
-
272 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习