Files
jscriptx/README.md
T
what 98eb734caa docs: 把能力面摆出来,更新性能数字
起因是「不知道有什么功能」——公开 API 有 14 个类型、17 个 With* 选项,全部平铺
在同一层文档里,看不出哪些是必须懂的。

doc.go 和 README 顶部都加了分层的能力清单:核心五个概念(Engine / Script /
Instance / Scope / Extension,覆盖九成用法),加上「用不到就不用看的」四类。
同时写清楚明确**不做**的三件事:不代管实例生命周期、没有事件循环、不提供沙箱隔离
(脚本能拿到你放行的 Go 对象,它们的方法是真能调的)。

性能数字重测了一遍。InstanceCall 454 → 300 ns——删掉 lastUsed 省下的两次
time.Now() 兑现了。

顺带加了一句测量方法的提醒:这些数字取的是多轮**最小值**。我这轮一度以为
Script.Call 退化了 27%,逐个 commit 二分下去发现跳变落在一个只删死代码和改注释
的 commit 上——那不可能影响调用路径。加大样本后两边最小值持平,是机器噪声。
中位数会被离群值带偏,微基准在有别的负载时能飘 30%。
2026-09-10 15:44:19 +08:00

35 KiB
Raw Blame History

jscriptx

用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。

脚本用 ESM + TypeScript 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。 底层是 goja(纯 Go 的 JS 引擎)+ esbuild(纯 Go 的打包器),依赖就这两个,公开 API 不暴露任何 goja 类型。

goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,不需要为每个方法写胶水代码:

resource.GetDBTable(user, req.WithPermission(req.ResAll))
    .Where(db.C("name").Eq("测试产品"))
    .Select("name", "cost_price")

这个库能做什么

核心就五个概念,覆盖九成用法:

入口
Engine 引擎。管编译缓存和配置,一个进程一个 jscriptx.New(...)
Script 一份编译好的脚本 + 它的 VM 池,热更新时整体顶替 e.Script(name)
Instance 独占一个 VM 的实例,脚本里 this 上的状态跨调用保持 s.New(ctx, args...)
Scope 作用域。让同一个 ctx 下的多个脚本共享 Go 侧对象 jscriptx.WithScope(ctx, ...)
Extension 扩展。给脚本添一个全局对象,并附上 TS 类型 实现 Extension 接口

用不到就不用看的:

入口
脚本从别处来 默认从目录加载。要从数据库、配置中心取就实现 Loader;多来源叠加、后面盖前面用 Overlay Loader / Tag / Overlay
自己打包 只有一段源码、不走目录时用 Bundle / FinalizeBundle
自定义调用约定 本库不预设脚本回调该长什么样。要按形参个数分派、要传 Go 闭包当 next,用它 WithCall / Caller
观测与排错 VM 池统计;错误带齐「哪个脚本、哪个函数、脚本里哪一行」 Stats / Error
装第三方库 拉依赖树、摊平成单文件,目标机器不需要 node esm.Install / esm.Vendor

明确不做的:不代管实例生命周期(没有按 key 复用、没有空闲回收)、没有事件循环 (定时器、网络、真正的异步都没有)、不提供沙箱隔离(脚本能拿到你放行的 Go 对象, 它们的方法是真能调的)。

文档

docs/call-flow.md 执行流程:源码怎么变成可执行的、一次调用经过哪些环节、错误怎么分类
docs/lifecycle.md 生命周期:Engine / Script / VM / Instance / 作用域各活多久,状态该放哪

快速开始

app/
├── package.json      npm 配置
├── tsconfig.json     esbuild 自动往上找到并生效
├── node_modules/     依赖
└── src/              ← NewLoader 指这里
    ├── PkgVersion/
    │   └── PkgImportController.ts
    └── Resource/
        ├── ResCreateController.ts
        └── ResExecuter.ts        ← 可以被同目录的 controller import
// app/src/Resource/ResCreateController.ts
import ResExecuter from "./ResExecuter"

export default class ResCreateController {
    private ex = new ResExecuter()
    private count = 0

    constructor(private resName: string) {}

    Init(): string { return "ready: " + this.resName }
    Store(cfg: string): string { this.count++; return this.ex.Store(cfg) }
}
loader, err := esm.NewLoader("app/src")
e, err := jscriptx.New(
    jscriptx.WithLoader(loader),
    jscriptx.WithAutoReload(true),      // 改了 .ts 不用重启
    jscriptx.WithGlobals(myWhitelist),  // 脚本能看见什么
    jscriptx.WithTimeout(3*time.Second),
)

obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
defer obj.Close()

got, err := obj.Call(ctx, "Init")

脚本怎么写

为什么需要打包

goja 的 ES6+ 支持相当完整——class(含私有字段、静态块)、async/await、generator、 解构、可选链、Proxy、BigInt 实测都能直接跑。但它没有 ES moduleimport/export 在 goja 的 token 表里是保留字,解析阶段就报错;TypeScript 更不在它的职责范围内。

esbuild 只补这两件事:

goja 靠 esbuild
class / async / 解构 / 可选链… 原生 不需要
import / export 保留字 打包时内联,产物里一个不剩
TypeScript 转译掉
node_modules 公共库 解析 + 内联

交给 goja 的最终产物是普通 JS 语法,不含任何模块系统的东西。

目录与入口

默认入口规则是第一层子目录下的所有 .js/.ts*/*.js*/*.ts),按 目录/文件名 寻址。 被 import 的模块不必是入口;想把辅助模块挡在入口之外,用 esm.WithGlobs("*/*Controller.ts") 收窄——挡在入口外不影响它被 import。

加载器指向 src 而不是 app,寻址就还是干净的 Resource/ResCreateController 不带 src/ 前缀;node_modulestsconfig.json 在上层,esbuild 逐级往上都能找到。

tsconfig.json 是完整生效的,实测过这两项:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@lib/*": ["./src/lib/*"] },   // 路径别名
    "experimentalDecorators": true             // 装饰器,@Route("/api") 之类
  }
}

多层覆盖

WithLoader 可以给多个 Loader,它们叠成一层层的,后面的盖前面的—— 取脚本时从最后一层往前找,谁先有就用谁的:

base, _ := esm.NewLoader("app/src")
custom, _ := esm.NewLoader("custom/src")

e, _ := jscriptx.New(jscriptx.WithLoader(base, custom))   // custom 盖 base

// custom/src/Resource/ResCreateController.ts 会盖掉 app/src 里的同名脚本
obj, _ := e.New(ctx, "Resource/ResCreateController")

把「定制层」放最后,业务侧放一份同名脚本就能改写默认实现,不用动被覆盖的那一份。 撤掉定制层的文件、重新打包后会自动落回下面那层。

每层是独立的 Loader,各有各的配置——入口规则、目标版本、node_modules 位置、 扩展模块都可以不一样;来源也不必相同,一层来自磁盘目录、另一层来自数据库都行:

disk, _ := esm.NewLoader("app/src")
db := jscriptx.PreparedFunc(func(name string) (string, string, error) {
    row, err := queryScript(name)          // 找不到时返回包着 ErrScriptNotFound 的错误
    if err != nil {
        return "", "", err
    }
    bundled, err := jscriptx.Bundle(name, row.Source)   // 自己打包
    if err != nil {
        return "", "", err
    }
    return bundled, row.UpdatedAt, nil
})

e, _ := jscriptx.New(jscriptx.WithLoader(disk, db))   // 数据库里的脚本盖住磁盘上的

各层的 Prepared 必须一致(要么都自己打好包,要么都交出原始源码),否则 New 报错—— 引擎只能对整个 Loader 做一次判断,没法分脚本区别对待。上面那段就是靠 PreparedFunc

  • Bundle 把数据库那层也变成「打好包的」,好跟 esm.Loader 那层对齐。

点名要哪一层

给层贴上版本标签,调用时就能指定要哪个版本的实现:

v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
e, _ := jscriptx.New(jscriptx.WithLoader(v1, v2))

e.New(ctx, "Resource/ResCreateController")      // 不点名:上层盖下层,拿到 v2
e.New(ctx, "Resource/ResCreateController@v1")   // 点名:要 v1 那一层的

点名是只认这一层:那层没有这个脚本就直接报 ErrScriptNotFound,不会掉到别的层去—— 不然点名要 v1 却跑了 v2 的实现,比报错难查得多。

各版本在引擎里是各自独立的缓存条目,热更新互不干扰。标签之间不能重名, 标签里也不能带 @

不是 esm.Loader 的层用 jscriptx.Tag 贴标签:

jscriptx.WithLoader(disk, jscriptx.Tag("hotfix", dbLoader))   // Load("Foo/Bar@hotfix")

不经过 Engine 也能直接用这套叠放:jscriptx.Overlay(base, custom) 返回一个 *OverlayLoader,除了 Load 还有 Names()(汇总各层入口,去重排序)、 Versions()(各层的版本标签)和 Rebuild()(挨个让各层重建)。

公共库:node_modules

esbuild 原生实现了 Node 的模块解析算法:

import { upper } from "tinylib"           // 裸模块名
import { money } from "@fsdpf/util"       // scoped 包,入口可以是 .ts
import ResExecuter from "./ResExecuter"   // 相对 import 混用没问题

从入口文件所在目录逐级往上node_modules,所以放在 app/node_modules 或它的任意 上层目录都能找到。公共库在完全不相干的路径下时用 esm.WithNodePaths("/opt/fsdpf/js-libs") 指过去(传进去的目录本身相当于一个 node_modules,包直接放在它下面,不要再套一层); 要把某个模块名钉死到一份实现上用 esm.WithAlias

两个细节:node_modules 里的文件不会被当成入口;它们也不参与热更新的版本计算—— 依赖包是装出来的,改动总伴随显式的安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。装完或换版本后调 loader.Rebuild()

esbuild 只解析 node_modules,不下载。包怎么进去(npm/pnpm 安装、直接提交进仓库)由你决定。

目标机器上没有 node 怎么办

node_modules 对 esbuild 来说就是一个约定好的目录布局,谁摆进去的不重要。所以:

零依赖的包直接下 tarball 就行,全程只用 curl 和 tar:

curl -sL https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz -o /tmp/x.tgz
mkdir -p app/node_modules/es-toolkit
tar -xzf /tmp/x.tgz -C app/node_modules/es-toolkit --strip-components=1

npm 的 tarball 里固定是一个 package/ 目录,--strip-components=1 剥掉就是标准布局。

有依赖的包得先解析依赖树——那正是 npm 真正在干的活。Install 把这件事也做了, 所以整条链不需要 node:

res, err := esm.Install(ctx, "qs", "app/node_modules")
// qs v6.16.0  打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB

它等于 npm.Fetch(拉依赖树到临时目录)加 Vendor(摊平成一个文件)。 包已经在本地装好了就直接用 Vendor 指着那个目录:

res, err := esm.Vendor(".", "qs", "app/node_modules")

包把东西放在子路径下的(es-toolkit/compatlodash/isString),直接写子路径:

esm.Install(ctx, "es-toolkit/compat", "app/node_modules")

拉的是根包,摊平的是那个子路径,落到 node_modules/es-toolkit/compat/必须这么写——摊平之后原包的 exports 映射就没了,import "es-toolkit/compat" 在只有根包的情况下解析不到。

esm/npm 只做「把包和依赖弄到磁盘上」,刻意不是 npm:不跑安装脚本(供应链攻击 的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 ^/~/精确/ x/>= 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会明确 报错并让你改用 npm + Vendor,而不是猜一个版本装上去。

产物是最小布局,脚本照常 import qs from "qs"

app/node_modules/qs/
├── index.mjs      依赖全内联
└── package.json   只为让解析器找到入口

发布物里带上这两个文件,目标机器不需要 node、不需要 npm、不需要联网。

两个 package.json 别混:脚本根目录那个不需要esbuild 不看它,type: module 也不影响);包自己那个必需——解析器靠 main/module 定位入口,拿掉就打包失败。 零依赖的包那个跟着 tarball 自带,Vendor 摊平的那个由它生成。

挑第三方库要看两件事

一、有没有用 goja 没有的全局。 esbuild 的 target 只降级语法,不补全局对象。 所以库的语法多新都无所谓,但只要它调了下面这些,就是运行期才炸:

goja 有:  Promise  Proxy  BigInt  Object.fromEntries  Object.hasOwn
          Array#at / #flat / #findLast  String#replaceAll  RegExp#matchAll

goja 没有:setTimeout  setInterval  queueMicrotask   ← 没有事件循环
          structuredClone  Object.groupBy
          TextEncoder  Intl  WeakRef  Symbol.asyncIterator

碰到这些时本库会在 ReferenceError 后面补一句说明,不至于让人以为是打包漏了依赖。

二、能不能 tree-shake。 每建一个 VM 都要把 bundle 跑一遍,体积直接变成并发成本。 同样一组功能(groupBy / sum / chunk / get / isString / cloneDeep)实测下来:

写法 打包产物 建 VM
手写原生 1.8 KB 0.02 ms
radash 3.9 KB 0.04 ms
es-toolkit 7.0 KB 0.03 ms
es-toolkit/compatlodash 兼容 API 48 KB 0.11 ms
ramda 70 KB 0.48 ms
lodash-es 132 KB 0.66 ms
lodashCommonJS 419 KB 2.69 ms

CommonJS 包摇不动——import { isString } from "lodash" 只挑一个函数,产物照样 411 KB。 要用 lodash 就装 lodash-es,或者只从子路径 importlodash/isString8 KB)。

remedaclone 依赖 structuredClone,在 goja 里直接报错。ramda 是唯一整包 零定时器引用的,但体积是 es-toolkit 的十倍、API 是柯里化风格,除非确实需要那套写法, 否则不值得。

入口形态

产物是自包含的立即执行函数,所以脚本必须有 export——没有导出的顶层代码会被当死代码 摇掉(真忘了写会得到一句明确的报错,不是莫名其妙的 undefined)。

export default class DeviceHandler {       // 由本库实例化,构造参数从 Go 侧传
    constructor(deviceId: string) {}
    onMessage(payload: string) {}
}

export default new DeviceHandler()         // 直接用这个实例
export default { onMessage(p) {} }         // 对象当实例,按方法名调用
export default function handle(x) {}       // 单函数入口,用 jscriptx.DefaultFunc 调用
export function Options() {}               // 只有命名导出时,整个模块当实例

拿到实例的几种形态都是按方法名调用,this 绑定到实例,继承来的方法也找得到

脚本级的钩子:静态方法

有些事情不属于任何一次调用——比如脚本第一次被启用时配置一下 Go 侧的扩展。写成 class 的静态方法,用 CallStatic 调:

export default class PkgImportController {
    static Startup() { store.Set("pkg.registry", "https://…") }

    constructor() { }        // 每次调用的准备
    Execute(g) { }
}
_, err := script.CallStatic(ctx, "Startup")
if err != nil && !errors.Is(err, jscriptx.ErrFuncNotFound) {
    return err     // ErrFuncNotFound 表示脚本没写这个钩子,是常态
}

Call 有两点不同,都是"静态"这个语义要求的:

  • 不构造实例——constructor 不会跑。它做的是"这一次调用的准备",跟脚本级的初始化无关
  • VM 用完就丢,不回池

所以静态方法建不出能存活的 JS 对象——VM 一丢就没了。它的用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边,后续每次调用都读得到。ScopeExtensions 在静态方法里照常可用,这正是它的用武之地。

HasStatic(fn) 能查有没有,但它也要建一个 VM;只想"有就调"的话直接 CallStaticErrFuncNotFound 更省。

脚本跑在非严格模式下

打包产物里没有 "use strict"——esbuild 输出 ESM 格式时不加这个指令,包成 IIFE 时也没加。

一个后果是给只读全局赋值会静默失败而不是抛错:

store = {}      // 什么都不会发生,也不报错

白名单和扩展仍然是真正只读的(改不动、删不掉),只是脚本改它时得不到任何提示。

多返回值:error 不出现在返回值里,它变成异常

这是脚本作者唯一需要额外理解的"非直觉"行为,是 goja 的转换规则,本库沿用:

Go 函数签名 脚本里拿到什么
func() T 裸值 T
func() (T, error) error 为 nil:裸值 T;非 nil:抛 JS 异常
func() (A, B) 数组 [A, B]
func() (A, B, error) error 为 nil:数组 [A, B];非 nil:抛 JS 异常
const r = sd.ToSQL()        // r[0] 是 SQLr[1] 是参数数组
try { risky() } catch (e) { /* Go 侧返回的 error 在这里 */ }

脚本不 catch 的话,异常会冒泡成 Go 侧的 *jscriptx.Error,原始的 Go error 挂在 Cause 上, 调用方的 errors.Is 照样匹配得到。

另外 Go 的 nil 到脚本里是 null 不是 undefined

async 可以用,但没有事件循环

async 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样:

async Load(id: string): Promise<string> {
    const raw = await Promise.resolve(id)   // 纯计算的 await 没问题
    return raw
}

goja 没有事件循环,脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远 pending,会得到 ErrPromisePending 和一句说明。异步的活交给 Go 侧做,脚本只写同步逻辑。 Promise 被 reject 则转成普通的 Go errorErrPromiseRejected)。

微任务(.then 回调)会在调用返回给 Go 之前跑完,不会漏到下一次调用里——所以池化的 VM 不存在"上个请求的回调在这个请求里执行"这种串扰。

setTimeout 等定时器没有提供,也不打算提供:它需要一个拥有 VM 的事件循环,而 VM 是 池化复用的——定时器没触发时 VM 还不了池,回调真跑起来时请求上下文早没了。这几个全局 故意保持 undefined(而不是定义成"一调就报错"的桩),库里 typeof setTimeout !== "undefined" 的特性探测才能正常降级;真调用了会在错误里说明该怎么办。

共享只到代码层面,不到状态层面

多个脚本 import 同一个模块时,各自拿到的是独立副本:模块被内联进各自的产物是一层原因, 更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。

公共的东西 能不能 import 共享
纯函数、工具类、常量、类型 随便用
模块级状态(缓存、计数器、连接) 各自一份,要共享得走作用域扩展

这是那种低负载看不出问题、一上量才暴露的坑。


Go 侧怎么调

两种执行方式

区别只有一个——脚本实例活多久

方式 取 VM 实例生命周期 脚本里的 this.xxx
Script.Call 从 VM 池借 = VM 生命周期 随时可能归零,只能当缓存
Script.NewInstance 独占一个 VM 由你 Close 决定 跨调用保持
// 需要状态:New 一个实例,用完 Close
obj, err := e.New(ctx, "Resource/ResCreateController", "产品")
defer obj.Close()
obj.Call(ctx, "Store", cfg)

// 不需要状态:直接 Call,走池
s, _ := e.Script("Resource/ResQueryController")
s.Call(ctx, "Query", id)

New 会立刻建好 VM,所以 constructor 抛异常在 New 当场就报(Func 字段是 "constructor")。

本库不代管实例的生命周期——没有按 key 复用、没有空闲回收。要长期持有(比如按设备 ID 存着), 业务侧自己拿 map 存,跟 Go 版 controller 的写法一致:

var devices sync.Map   // deviceID -> *jscriptx.Instance

func onConnect(id string) {
    obj, _ := mqttCtrl.New(ctx, id)
    devices.Store(id, obj)
}
func onDisconnect(id string) {
    obj, _ := devices.LoadAndDelete(id)
    obj.(*jscriptx.Instance).Close()
}

并发粒度

场景 能并发吗
不同实例(不同请求、不同设备) 各自独占 VM
同一作用域下的不同 controller 各有各的 VM,只共享扩展
同一个实例的多次调用 串行
不带作用域的池化调用 池里多个 VM

只有第三种是串行的,而那是必需的——this.count++ 要跨调用保持,就必须保证同一时刻只有 一个 goroutine 在动这个实例,跟 Go 侧用 sync.Mutex 保护 struct 字段是一回事。

作用域:让多个脚本共享数据

作用域通过 ctx 传递,只携带一段业务流程里要共享的东西,不管任何生命周期

st := store.New()
ctx = jscriptx.WithScope(ctx,
    jscriptx.ScopeKey(requestID),                       // 可选,脚本里 scope.key
    jscriptx.ScopeExtensions(st),                       // 扩展
    jscriptx.ScopeGlobals(map[string]any{"user": u}),   // 作用域专属全局
)

obj1, _ := cartCtrl.New(ctx)     // 两个 controller
obj2, _ := orderCtrl.New(ctx)    // 同一个 ctx → 同一份 store
// CartController.ts
export default class CartController {
    Add(sku: string, qty: number) { store.Incr("total", qty) }
}
// OrderController.ts —— 另一个文件、另一个 VM,但读得到同一份 store
export default class OrderController {
    Checkout() { return store.Get("total") }
}

共享靠的是 Go 侧对象,不是共用 Runtime——共用 Runtime 会让作用域内所有脚本被迫串行, 那才是真的并发瓶颈。现在各脚本各跑各的,只是手里的 store 指向同一个 Go 对象。

扩展

扩展就是"给脚本添一个全局对象",实例由你创建,跟着 ctx 走:

type Extension interface {
    Name() string                      // 脚本里的全局名
    Bindings() map[string]any          // 暴露的方法,大写开头
    Module() (path, source string)     // 配套 TS 模块,脚本可以 import
}

本库不带任何内置扩展——扩展该由用引擎的人按自己的场景定义,引擎只给接口。 (ext/store 曾经在这里,2026-09-05 搬到了 framework 侧,那里才知道"进程级共享状态" 对业务意味着什么。)写一个是这样:

type tx struct{ conn *sql.Tx }

func (t *tx) Name() string { return "tx" }
func (t *tx) Bindings() map[string]any {
    return map[string]any{"commit": t.conn.Commit, "rollback": t.conn.Rollback}
}
func (t *tx) Module() (string, string) { return "@fsdpf/tx", txTypings }

Go 侧和脚本读写的天然是同一份,不用再取回来:

st := store.New()
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
// …脚本里 store.Set("k", v)…
st.Get("k")

脚本可以 import 扩展拿类型

loader, _ := esm.NewLoader("app/src", esm.WithExtensions(store.New()))
import store, { Store } from "@jscriptx/store"   // 有类型提示

export default class C {
    Put(k: string, v: string) { store.Set(k, v) }
}

模块是虚拟的——磁盘上没有这个文件,路径和源码由扩展的 Module() 提供,打包时内联。 WithExtensions 只用于打包阶段(提供模块和类型),运行时注入哪些扩展仍由 ctx 决定。 import 了却没注册,打包阶段就会报错,不会拖到运行时。

把类型落盘给编辑器

虚拟模块只有 esbuild 看得见,编辑器解析不了 import store from "@jscriptx/store",写脚本一路飘红。落盘一份就好了:

n, err := loader.WriteTypings("app/node_modules")
// app/node_modules/@jscriptx/store/{package.json,index.ts}

用 node_modules 的布局而不是 tsconfig 的 paths——编辑器和 tsc 本来就按 Node 规则往上找,不用改任何配置。写出来的只是类型,运行期用不到,随时可以删掉重写,也不该提交进版本库。

只要内容不落盘的话,loader.Typings() 返回 import 路径 -> 源码;不想先建 Loader 就用包级的 esm.Typings(exts...)

注入是惰性的

扩展的方法集不在建 VM 时转换,而是装一个只读的访问器属性,脚本第一次读到那个全局名才把它转成 JS 对象,转完在这个 VM 里缓存。

一个脚本通常只用得上少数几个扩展,而转换要把每个方法都包装成 JS 函数——用不到的那些不该在每次建 VM 时都付一遍。实测这曾是脚本层剩余开销里最大的一块。

对脚本完全透明:读到什么就转什么,只读性、可枚举性都跟以前一样。唯一能观测到的差别是属性描述符从 {value, writable} 变成 {get, set},业务代码不会碰到。

自定义调用约定

Call/CallInto 表达不了的调用模式——典型是「回调 + next」中间件——用 WithCall

err := target.WithCall(ctx, "handle", func(c jscriptx.Caller) error {
    switch c.Arity() {          // 脚本函数声明了几个形参
    case 2:
        res, err := c.Call(model, next)   // next 是 Go 闭包,脚本能直接调
        ...
    }
    return nil
})

完整实现(三种回调签名自动分派)见 ExampleCallergo doc 里能看到。 本库不预设脚本回调该长什么样——那是框架的约定,各家不同。

值不能跨出脚本边界

Call 的返回值和 CallInto 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界 就失效了,本库会直接拒绝(ErrValueEscape)。需要回调语义用 WithCall


安全边界

脚本能访问的全局对象,只有 WithGlobals 显式放行的那些,加上 JS 语言自带的内置对象。 goja 不提供文件、网络、require,也没有 setTimeout

白名单对象注入时会逐层拷贝成只读 JS 对象:脚本改不动它,多个 VM 之间也不会共享同一个 可变的 Go map(否则脚本一句 db.C = null 既污染别的 VM,又是实打实的数据竞争)。

白名单该怎么定

本库不预设放行哪些 API——那取决于你的框架。下面是一份可以照抄的参考,思路是 只放行"不带数据库连接、不能自己发起查询"的纯构造器和常量

func Safe() map[string]any {
    return map[string]any{
        "db": map[string]any{
            "C": db.C, "T": db.T, "V": db.V,   // 构造列名/表名/字面量,纯 SQL 片段
        },
        "req": map[string]any{
            "WithPermission": req.WithPermission,
            // 标志位按 int 暴露而不是自定义数值类型:goja 会把后者包装成 JS 对象,
            // 那样脚本里 req.ResRow | req.ResMask 这种按位运算就不成立了。
            // 传回 Go 侧时 goja 会自动转回原类型。
            "ResRow":  int(req.ResRow),
            "ResMask": int(req.ResMask),
            "ResAll":  int(req.ResAll),
        },
    }
}

刻意不放行

不放行 原因
db.From 能凭空造出查询数据集,绕过资源层
db.L 能往 SQL 里塞裸片段,可以挂子查询探测别的表,等于绕过权限设计
engine / 任何数据库连接对象 同上

脚本要碰数据,由 Go 侧把已经过权限包装的 req.Resource 或 dataset 当参数传进去。 脚本自己拿不到裸连接,也就绕不开资源层的行级权限过滤和字段脱敏。


可靠性

失控脚本

每次调用都带超时(WithTimeout,默认 5 秒)。超时或调用方 context 取消时,会从另一个 goroutine 中断脚本,死循环也能断掉。被中断过或 panic 过的 VM 直接丢弃不回池。

脚本执行期间的 panic(脚本里的类型错误、注入进去的 Go 方法内部 panic)都会被 recover 成 error 返回,不会掀翻调用方的 goroutine。

错误

所有错误都是 *jscriptx.Error,带分类、脚本名、函数名、脚本侧调用栈(行列号)和调用参数摘要, 并实现了 slog.LogValuer

if err != nil {
    logger.Error("脚本执行失败", slog.Any("err", err))
    // kind=runtime script=Resource/ResCreateController func=Boom line=15 column=25
    // at="Boom (Resource/ResCreateController:15:25)" msg="Error: 创建失败"
}

行号指向 .ts 源文件而不是打包产物esbuild 输出 inline sourcemapgoja 自带 sourcemap 支持。

哨兵错误:ErrTimeoutErrFuncNotFoundErrScriptNotFoundErrValueEscapeErrClosedErrUnsupportedSignatureErrPromiseRejectedErrPromisePendingErrBadGlobal

热更新

版本号由参与打包的所有源文件的 mtime 算出,改了被 import 的公共模块也会触发重编译; 新增文件会被自动发现。换掉旧脚本时,已经拿着旧 *Script 的调用会继续跑完旧版本。

e, _ := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true))

性能

Apple M4 Progo test -run XXX -bench . -benchmem。每行都标了对应的基准名, 数字过时了可以自己重跑。

场景 基准 耗时 分配
纯 Go 基准线 BenchmarkNative 39 ns 1
Instance.Call(状态在 JS 实例里) BenchmarkInstanceCall 300 ns 11
Script.Call(同脚本同参数,架构对照) BenchmarkCall_同脚本对照 342 ns 11
Instance.Call + 作用域扩展 BenchmarkStoreExtension 935 ns 29
Script.Call + 传 Go 对象当参数 BenchmarkCall 1.91 μs 57
同上,但传可取消的 context BenchmarkCall_带可取消context 3.47 μs 63
Script.New(建一个实例) BenchmarkInstanceNew 3.00 μs 110
重新编译 + 建全新 VM BenchmarkVM新建 213 μs 1687

这些是多轮取最小值。微基准在有别的负载时能飘 30%,中位数会被离群值带偏—— 拿它对比改动前后时务必同机器同口径跑两遍。

几点说明:

  • 池化和独占 VM 的架构开销几乎一样342 vs 300 ns)。差别大的是状态放哪:放 JS 实例 只要 0.3 μs,放作用域扩展 0.94 μs,靠参数把 Go 对象反射包装过去要 1.9 μs—— 那个反射往返才是大头。
  • 打包和编译只在加载和热更新时发生,不在调用路径上。
  • 传可取消的 context 明显变贵(1.91 → 3.47 μs):哨兵要起 goroutine 盯 ctx.Done(); 只有超时限制时走定时器快路径。MQTT 那种高频路径建议传 context.Background()WithTimeout 兜底。

内存

每个实例独占一个 VM,3000 个实例的常驻内存:

配置 单实例 3000 个
裸 VM 7.0 KB 20.5 MB
+ console 12.5 KB 36.6 MB
+ console + 一个扩展 21.8 KB 64.0 MB

这组是一次性手工测的(建 N 个实例后读 runtime.MemStats),仓库里没有对应的 自动化测试,换机器或改了注入逻辑之后不保证还准。上面那张表的数字才是可重跑的。

白名单和 console 只在建 VM 时注入,对每次调用零影响。而且注入是惰性的(见「注入是惰性的」), 脚本没读到的全局根本不会被转换——上表是「全都读到」的上界。实例多又不需要脚本日志时, WithLogger(nil) 关掉 console 能省约 44%。

写脚本时的性能建议

循环里访问 Go 对象的方法,先取出来

goja 每次从 Go 对象上取方法都要新建一个函数包装,它不缓存。这里的「Go 对象」包括 传进脚本的参数、扩展、以及扩展返回的对象。

Benchmark绑定_* 在 JS 里循环 1000 次,下表已减掉空循环的基线、除以次数, 是单次访问的净开销:

写法 基准 耗时 分配次数 相对纯 JS
obj.name(纯 JS 对象) 绑定_纯JS属性 90 ns 0.9 1×
obj.NameGo 结构体字段) 绑定_Go结构体字段 128 ns 3.9 4.2×
fn("x")(绑定的 Go 函数) 绑定_Go函数 270 ns 7.9 8.5×
obj.Get("x")Go 对象的方法) 绑定_Go方法调用 675 ns 19.9 21×
循环外先取出来再调 绑定_Go方法提前取出 319 ns 10.0 10.6×
for (const row of rows) out.push(tpl.Render(row))   // ⚠️ 每轮都重新包装

const render = tpl.Render.bind(tpl)                 // ✅ 包装一次
for (const row of rows) out.push(render(row))

要点:

  • 贵在属性访问,不在调用本身obj.Get 这一下就要造函数包装,把它提出来能省掉一半。
  • 读 Go 结构体的字段便宜得多3.9 次分配),因为不用造包装。
  • 纯 JS 对象几乎免费0.9 次)——它不跨语言。

所以要同时满足循环Go 对象两个条件才值得改写。一次调用里访问三五次 (三次约 2 μs)别为它牺牲可读性。

别在模块顶层做重初始化

顶层每建一个 VM 就跑一遍。顶层建一张 2000 条的查找表,建一个实例要 2.17 ms / 1.74 MB, 而轻量脚本只要 10 μs / 9 KB。要预计算就放 Go 侧做成扩展。

少往脚本里传 Go 对象

每次传参都要反射包装一遍,方法多的接口尤其贵——上表里 Script.Call 传 Go 对象比 Instance.Call 贵近四倍,差的就是这个。


附录

不按目录加载时

esm 子包管的是"按目录加载 + 路径寻址 + 热更新"。只有一段源码时直接 Compile 它内部同样走 esbuildTypeScript 照写),区别只是没有文件系统上下文,默认不能 import

s, _ := e.Compile("user_approval.ts", src)
out, _ := s.Call(ctx, "handle", arg)

要在这段源码里 import,给一个解析基准目录:

jscriptx.New(jscriptx.WithBundleOptions(jscriptx.WithResolveDir("app/src/Resource")))

自定义脚本来源

脚本存数据库表或配置中心时,实现这个接口交给 WithLoader

type Loader interface {
    Load(name string) (source string, version string, err error)
}

version 用来判断脚本变没变(updated_at、源码哈希都行,返回空字符串则退化成按源码哈希算)。

给出的源码默认由 Engine 交给 esbuild 打包,直接返回原始源码即可。自己已经打过包的要再 实现 Prepared 接口——这不是优化开关而是正确性要求:打包产物里已经没有 export 了, 再打一遍会被当死代码整段摇空。记得钉一行 var _ jscriptx.Prepared = (*MyLoader)(nil), 因为 Engine 靠类型断言识别,方法签名写错不会有编译错误。

测试

go test -race ./...

不需要任何 build tag,依赖只有 goja 和 esbuild。

为什么用 esbuild 而不是 rollup

在 goja 里跑 rollup 这条路验证过,走不通:

  • rollup 4 依赖 WebAssembly.instantiate 和 wasm-bindgen 那套 gluegoja 没有 WASM 支持。 Go 侧确实能跑 WASMwazero,纯 Go),但要在 goja 里实现 WebAssembly JS API、桥接线性内存、 实现几十个 wasm-bindgen 回调、再 polyfill TextDecoder/fetch——数周工程且性能很差。
  • rollup 3 是纯 JS 能跑,但要 polyfill TextDecoder/TextEncoder/fetch/Buffer/process (实测缺 6 处),且不处理 .ts,还得再叠一个转译器。

esbuild 是 Go 原生的同类工具,一行依赖,原生支持 TypeScript,实测快两个数量级。