有些包把东西放在子路径下(es-toolkit 的 toString 只在 compat 里,主入口没有),
而摊平之后原包的 exports 映射就没了,import "es-toolkit/compat" 解析不到。
现在直接写子路径就行:
esm.Install(ctx, "es-toolkit/compat", "app/node_modules")
拉的是根包,摊平的是子路径,落到 node_modules/es-toolkit/compat/。根包和子路径
可以共存——子路径目录嵌在根包目录里,而最小 package.json 不写 exports,
所以解析器认得出来。
npm.SplitPath 负责拆名字,scoped 包名自带一个斜杠所以前两段才是包名。
824 lines
33 KiB
Markdown
824 lines
33 KiB
Markdown
# jscriptx
|
||
|
||
用嵌入式 JS 引擎承载业务回调,让业务逻辑变更不必重新编译发布 Go 程序。
|
||
|
||
脚本用 **ESM + TypeScript** 编写、按目录组织,Go 侧按路径把它们当普通对象实例化并调用方法。
|
||
底层是 [goja](https://github.com/dop251/goja)(纯 Go 的 JS 引擎)+
|
||
[esbuild](https://github.com/evanw/esbuild)(纯 Go 的打包器),**依赖就这两个**,公开 API
|
||
不暴露任何 goja 类型。
|
||
|
||
goja 的反射会自动包装 Go 对象,框架里的链式 API 在脚本里照原样写,不需要为每个方法写胶水代码:
|
||
|
||
```ts
|
||
resource.GetDBTable(user, req.WithPermission(req.ResAll))
|
||
.Where(db.C("name").Eq("测试产品"))
|
||
.Select("name", "cost_price")
|
||
```
|
||
|
||
## 文档
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| [docs/call-flow.md](docs/call-flow.md) | 执行流程:源码怎么变成可执行的、一次调用经过哪些环节、错误怎么分类 |
|
||
| [docs/lifecycle.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
|
||
```
|
||
|
||
```ts
|
||
// 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) }
|
||
}
|
||
```
|
||
|
||
```go
|
||
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 module**:`import`/`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_modules` 和 `tsconfig.json` 在上层,esbuild 逐级往上都能找到。
|
||
|
||
`tsconfig.json` 是完整生效的,实测过这两项:
|
||
|
||
```jsonc
|
||
{
|
||
"compilerOptions": {
|
||
"baseUrl": ".",
|
||
"paths": { "@lib/*": ["./src/lib/*"] }, // 路径别名
|
||
"experimentalDecorators": true // 装饰器,@Route("/api") 之类
|
||
}
|
||
}
|
||
```
|
||
|
||
## 多层覆盖
|
||
|
||
`WithLoader` 可以给多个 Loader,它们叠成一层层的,**后面的盖前面的**——
|
||
取脚本时从最后一层往前找,谁先有就用谁的:
|
||
|
||
```go
|
||
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` 位置、
|
||
扩展模块都可以不一样;来源也不必相同,一层来自磁盘目录、另一层来自数据库都行:
|
||
|
||
```go
|
||
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` 那层对齐。
|
||
|
||
### 点名要哪一层
|
||
|
||
给层贴上版本标签,调用时就能指定要哪个版本的实现:
|
||
|
||
```go
|
||
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` 贴标签:
|
||
|
||
```go
|
||
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 的模块解析算法:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```go
|
||
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` 指着那个目录:
|
||
|
||
```go
|
||
res, err := esm.Vendor(".", "qs", "app/node_modules")
|
||
```
|
||
|
||
包把东西放在**子路径**下的(`es-toolkit/compat`、`lodash/isString`),直接写子路径:
|
||
|
||
```go
|
||
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/compat`(lodash 兼容 API) | 48 KB | 0.11 ms |
|
||
| `ramda` | 70 KB | 0.48 ms |
|
||
| `lodash-es` | 132 KB | 0.66 ms |
|
||
| **`lodash`(CommonJS)** | **419 KB** | **2.69 ms** |
|
||
|
||
**CommonJS 包摇不动**——`import { isString } from "lodash"` 只挑一个函数,产物照样 411 KB。
|
||
要用 lodash 就装 `lodash-es`,或者只从子路径 import(`lodash/isString`,8 KB)。
|
||
|
||
`remeda` 的 `clone` 依赖 `structuredClone`,在 goja 里直接报错。`ramda` 是唯一整包
|
||
零定时器引用的,但体积是 es-toolkit 的十倍、API 是柯里化风格,除非确实需要那套写法,
|
||
否则不值得。
|
||
|
||
## 入口形态
|
||
|
||
产物是自包含的立即执行函数,所以**脚本必须有 `export`**——没有导出的顶层代码会被当死代码
|
||
摇掉(真忘了写会得到一句明确的报错,不是莫名其妙的 `undefined`)。
|
||
|
||
```ts
|
||
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` 调:
|
||
|
||
```ts
|
||
export default class PkgImportController {
|
||
static Startup() { store.Set("pkg.registry", "https://…") }
|
||
|
||
constructor() { } // 每次调用的准备
|
||
Execute(g) { }
|
||
}
|
||
```
|
||
|
||
```go
|
||
_, 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;只想"有就调"的话直接 `CallStatic` 判 `ErrFuncNotFound` 更省。
|
||
|
||
## 脚本跑在非严格模式下
|
||
|
||
打包产物里**没有** `"use strict"`——esbuild 输出 ESM 格式时不加这个指令,包成 IIFE 时也没加。
|
||
|
||
一个后果是给只读全局赋值会**静默失败**而不是抛错:
|
||
|
||
```ts
|
||
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 异常 |
|
||
|
||
```ts
|
||
const r = sd.ToSQL() // r[0] 是 SQL,r[1] 是参数数组
|
||
try { risky() } catch (e) { /* Go 侧返回的 error 在这里 */ }
|
||
```
|
||
|
||
脚本不 catch 的话,异常会冒泡成 Go 侧的 `*jscriptx.Error`,原始的 Go error 挂在 `Cause` 上,
|
||
调用方的 `errors.Is` 照样匹配得到。
|
||
|
||
另外 Go 的 `nil` 到脚本里是 **`null` 不是 `undefined`**。
|
||
|
||
## async 可以用,但没有事件循环
|
||
|
||
`async` 方法返回的 Promise 由本库自动解包,用起来跟同步方法一样:
|
||
|
||
```ts
|
||
async Load(id: string): Promise<string> {
|
||
const raw = await Promise.resolve(id) // 纯计算的 await 没问题
|
||
return raw
|
||
}
|
||
```
|
||
|
||
但 **goja 没有事件循环**,脚本里等不了真正的异步(定时器、网络、IO)——那种 Promise 永远
|
||
pending,会得到 `ErrPromisePending` 和一句说明。异步的活交给 Go 侧做,脚本只写同步逻辑。
|
||
Promise 被 reject 则转成普通的 Go error(`ErrPromiseRejected`)。
|
||
|
||
微任务(`.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.New` → `Instance` | 独占一个 VM | 由你 `Close` 决定 | 跨调用保持 |
|
||
|
||
```go
|
||
// 需要状态: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 的写法一致:
|
||
|
||
```go
|
||
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 传递,只携带一段业务流程里要共享的东西,**不管任何生命周期**:
|
||
|
||
```go
|
||
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
|
||
```
|
||
|
||
```ts
|
||
// 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 走:
|
||
|
||
```go
|
||
type Extension interface {
|
||
Name() string // 脚本里的全局名
|
||
Bindings() map[string]any // 暴露的方法,大写开头
|
||
Module() (path, source string) // 配套 TS 模块,脚本可以 import
|
||
}
|
||
```
|
||
|
||
**本库不带任何内置扩展**——扩展该由用引擎的人按自己的场景定义,引擎只给接口。
|
||
(`ext/store` 曾经在这里,2026-09-05 搬到了 framework 侧,那里才知道"进程级共享状态"
|
||
对业务意味着什么。)写一个是这样:
|
||
|
||
```go
|
||
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 侧和脚本读写的天然是同一份,不用再取回来:
|
||
|
||
```go
|
||
st := store.New()
|
||
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
|
||
// …脚本里 store.Set("k", v)…
|
||
st.Get("k")
|
||
```
|
||
|
||
### 脚本可以 import 扩展拿类型
|
||
|
||
```go
|
||
loader, _ := esm.NewLoader("app/src", esm.WithExtensions(store.New()))
|
||
```
|
||
|
||
```ts
|
||
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"`,写脚本一路飘红。落盘一份就好了:
|
||
|
||
```go
|
||
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`:
|
||
|
||
```go
|
||
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
|
||
})
|
||
```
|
||
|
||
完整实现(三种回调签名自动分派)见 `ExampleCaller`,`go doc` 里能看到。
|
||
本库**不预设脚本回调该长什么样**——那是框架的约定,各家不同。
|
||
|
||
## 值不能跨出脚本边界
|
||
|
||
`Call` 的返回值和 `CallInto` 的目标都不允许是 JS 函数/闭包:那种值绑在 VM 上,跨出边界
|
||
就失效了,本库会直接拒绝(`ErrValueEscape`)。需要回调语义用 `WithCall`。
|
||
|
||
---
|
||
|
||
# 安全边界
|
||
|
||
脚本能访问的全局对象,只有 `WithGlobals` 显式放行的那些,加上 JS 语言自带的内置对象。
|
||
goja 不提供文件、网络、`require`,也没有 `setTimeout`。
|
||
|
||
白名单对象注入时会逐层拷贝成**只读** JS 对象:脚本改不动它,多个 VM 之间也不会共享同一个
|
||
可变的 Go map(否则脚本一句 `db.C = null` 既污染别的 VM,又是实打实的数据竞争)。
|
||
|
||
## 白名单该怎么定
|
||
|
||
本库不预设放行哪些 API——那取决于你的框架。下面是一份可以照抄的参考,思路是
|
||
**只放行"不带数据库连接、不能自己发起查询"的纯构造器和常量**:
|
||
|
||
```go
|
||
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`:
|
||
|
||
```go
|
||
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 sourcemap,goja 自带 sourcemap 支持。
|
||
|
||
哨兵错误:`ErrTimeout`、`ErrFuncNotFound`、`ErrScriptNotFound`、`ErrValueEscape`、
|
||
`ErrClosed`、`ErrUnsupportedSignature`、`ErrPromiseRejected`、`ErrPromisePending`、`ErrBadGlobal`。
|
||
|
||
## 热更新
|
||
|
||
版本号由参与打包的**所有**源文件的 mtime 算出,改了被 `import` 的公共模块也会触发重编译;
|
||
新增文件会被自动发现。换掉旧脚本时,已经拿着旧 `*Script` 的调用会继续跑完旧版本。
|
||
|
||
```go
|
||
e, _ := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithAutoReload(true))
|
||
```
|
||
|
||
---
|
||
|
||
# 性能
|
||
|
||
Apple M4 Pro,`go test -run XXX -bench . -benchmem`。每行都标了对应的基准名,
|
||
数字过时了可以自己重跑。
|
||
|
||
| 场景 | 基准 | 耗时 | 分配 |
|
||
| --- | --- | --- | --- |
|
||
| 纯 Go 基准线 | `BenchmarkNative` | 96 ns | 1 |
|
||
| `Instance.Call`(状态在 JS 实例里) | `BenchmarkInstanceCall` | 454 ns | 10 |
|
||
| `Script.Call`(同脚本同参数,架构对照) | `BenchmarkCall_同脚本对照` | 417 ns | 10 |
|
||
| `Instance.Call` + 作用域扩展 | `BenchmarkStoreExtension` | 1.05 μs | 29 |
|
||
| `Script.Call` + 传 Go 对象当参数 | `BenchmarkCall` | 1.76 μs | 57 |
|
||
| 同上,但传可取消的 context | `BenchmarkCall_带可取消context` | 4.33 μs | 63 |
|
||
| `Script.New`(建一个实例) | `BenchmarkInstanceNew` | 3.49 μs | 110 |
|
||
| 重新编译 + 建全新 VM | `BenchmarkVM新建` | 224 μs | 1687 |
|
||
|
||
几点说明:
|
||
|
||
- **池化和独占 VM 的架构开销几乎一样**(417 vs 454 ns)。差别大的是状态放哪:放 JS 实例
|
||
只要 0.45 μs,放作用域扩展 1.05 μs,靠参数把 Go 对象反射包装过去要 1.76 μs——
|
||
那个反射往返才是大头。
|
||
- 打包和编译只在**加载和热更新**时发生,不在调用路径上。
|
||
- 传可取消的 `context` 明显变贵(1.76 → 4.33 μ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.Name`(Go 结构体字段) | `绑定_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×** |
|
||
|
||
```ts
|
||
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`,
|
||
它内部同样走 esbuild(TypeScript 照写),区别只是没有文件系统上下文,默认不能 `import`:
|
||
|
||
```go
|
||
s, _ := e.Compile("user_approval.ts", src)
|
||
out, _ := s.Call(ctx, "handle", arg)
|
||
```
|
||
|
||
要在这段源码里 `import`,给一个解析基准目录:
|
||
|
||
```go
|
||
jscriptx.New(jscriptx.WithBundleOptions(jscriptx.WithResolveDir("app/src/Resource")))
|
||
```
|
||
|
||
## 自定义脚本来源
|
||
|
||
脚本存数据库表或配置中心时,实现这个接口交给 `WithLoader`:
|
||
|
||
```go
|
||
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 靠类型断言识别,方法签名写错不会有编译错误。
|
||
|
||
## 测试
|
||
|
||
```bash
|
||
go test -race ./...
|
||
```
|
||
|
||
不需要任何 build tag,依赖只有 goja 和 esbuild。
|
||
|
||
## 为什么用 esbuild 而不是 rollup
|
||
|
||
在 goja 里跑 rollup 这条路验证过,走不通:
|
||
|
||
- **rollup 4** 依赖 `WebAssembly.instantiate` 和 wasm-bindgen 那套 glue,goja 没有 WASM 支持。
|
||
Go 侧确实能跑 WASM(wazero,纯 Go),但要在 goja 里实现 `WebAssembly` JS API、桥接线性内存、
|
||
实现几十个 wasm-bindgen 回调、再 polyfill `TextDecoder`/`fetch`——数周工程且性能很差。
|
||
- **rollup 3** 是纯 JS 能跑,但要 polyfill `TextDecoder`/`TextEncoder`/`fetch`/`Buffer`/`process`
|
||
(实测缺 6 处),且不处理 `.ts`,还得再叠一个转译器。
|
||
|
||
esbuild 是 Go 原生的同类工具,一行依赖,原生支持 TypeScript,实测快两个数量级。
|