README 分四段:脚本怎么写、Go 侧怎么调、安全边界与可靠性、性能。 docs/call-flow.md 讲源码怎么变成可执行的、一次调用经过哪些环节; docs/lifecycle.md 讲 Engine / Script / VM / Instance / 作用域各活多久。 性能一节的每一行都标了对应的基准名,数字过时了可以自己重跑。原来有一张 「循环里访问 Go 对象」的表没有对应的基准测试,数字无法复现,换成了 Benchmark绑定_* 的实测结果。内存那张表仍是手工测的,已在旁边注明。 挑第三方库那节记了两条实测结论:esbuild 的 target 只降级语法、不补全局 对象,所以库只要用了 structuredClone 或定时器就是运行期才炸;CommonJS 包摇不动,同一组功能 lodash 打出 419 KB 而 es-toolkit 只要 7 KB。
9.8 KiB
生命周期
四层对象,从长到短:Engine → Script → VM → 调用。搞清楚谁活多久,就知道状态该放哪。
flowchart TD
E["Engine<br/>进程级"] --> S1["Script<br/>一份编译产物 + VM 池"]
E --> S2["Script"]
S1 --> P["VM 池<br/>无状态调用复用"]
S1 --> I1["Instance<br/>独占 VM,你 Close"]
S1 --> I2["Instance"]
P --> V1["vmHandle"]
P --> V2["vmHandle"]
I1 --> V3["vmHandle"]
I2 --> V4["vmHandle"]
| 层 | 活多久 | 谁结束它 |
|---|---|---|
Engine |
整个进程 | Close() |
Script |
到下次热更新 | 被同名脚本顶替,或 Engine.Close() |
| VM(池里) | 不确定 | 出错被丢弃、池满被丢弃、Script 关闭 |
VM(Instance) |
到你 Close() |
只有你——本库不代管 |
| 作用域(ctx 里) | 跟着 ctx | 没有生命周期,只是数据载体 |
Engine:只管配置,不追踪实例
Engine 持有白名单、超时策略、Loader、打包选项和编译产物缓存。它不知道任何 Instance 的存在—— 没有会话注册表、没有按 key 复用、没有空闲回收器。
这是有意的:New 出来的实例由你拿着,生命周期归你;Engine 追踪它反而是多余的耦合,还要处理
"调用方忘了 Close 但 Engine 还引用着"导致的泄漏。
Instance 也不直接持有 Engine,它通过 script.engine 拿配置。
Script:热更新时被顶替
stateDiagram-v2
[*] --> 已编译: Compile / Loader 加载
已编译 --> 已编译: 版本号没变,复用
已编译 --> 新Script: 版本号变了,重新编译
新Script --> [*]: 旧 Script.Close()
已编译 --> [*]: Engine.Close()
note right of 新Script
旧 Script 上正在跑的调用
会用旧版本跑完,不受影响
end note
热更新时 Engine 造一个新的 Script 顶替旧的,然后关掉旧的。已经拿着旧 *Script 指针的
调用会继续跑完旧版本;旧 Script 池里的 VM 被丢弃。
从旧 Script New 出来的实例不在这条链上——它们会因为 script.closed 而在下次调用时报
ErrClosed,VM 等 GC 回收。
版本号由 Loader 给。jscriptx/esm 用的是参与打包的所有源文件的 mtime+size 哈希——
所以改了被 import 的公共模块也会触发重编译。node_modules 里的文件不算在内,改依赖后要
显式 Rebuild()。
多层叠放(WithLoader 给多个)时,版本号会带上是第几层给的。这一步不能省:覆盖层的
脚本删掉后会落回下面那层,两层的版本号万一撞上,不带层号引擎就看不出脚本已经换了人,
会继续用旧的编译结果。
VM 池:无状态调用
Script.Call 走这条。VM 从池里借,用完还回去:
stateDiagram-v2
[*] --> 池中: newVM()
池中 --> 借出: borrow()
借出 --> 池中: release(healthy=true)
借出 --> 丢弃: release(healthy=false)
池中 --> 丢弃: Script.Close()
借出 --> 丢弃: 池满
note right of 丢弃
超时、取消、panic 过的 VM
一律不回池
end note
两个刻意的设计:
借不到不阻塞——池空时直接新建一个。池只是复用缓存,不承担限流职责;并发超过池容量时 宁可临时多造几个 VM,也不让请求在这里排队。用完如果池满了,多出来的直接丢弃。
坏 VM 不回池——被中断或 panic 过的 VM 状态不确定,重建远比拖着它划算。
这条路径下脚本里的 this.xxx 随时可能归零:同一个业务流程的连续调用大概率落在不同 VM 上。
而且丢得没规律——低负载时可能一直命中同一个 VM,看着正常,一上量就出问题。
Instance:当普通对象用
stateDiagram-v2
[*] --> 就绪: New(ctx, args...)
就绪 --> 执行中: acquire() 加锁
执行中 --> 就绪: finish(healthy=true) 解锁
执行中 --> 待重建: finish(healthy=false)
待重建 --> 就绪: 下次调用惰性重建
就绪 --> [*]: Close()
note right of 待重建
构造参数会重新传一遍
但 this 上攒的状态归零
Resets() 能查到次数
end note
New 会立刻建好 VM,所以 constructor 抛异常在 New 当场就报(Func 字段是
"constructor"),不用等到第一次调用。
acquire 拿到的锁一直持到 finish 才放——这就是"同一实例的调用串行执行"的保证。
goja 的 Runtime 不是并发安全的,多个 goroutine 同时调同一个实例会排队,不同实例之间并行。
出错会丢状态:一次超时或 panic 让 VM 被丢弃,下次调用用全新实例重建。这把"状态随时可能丢" 降低成"只在脚本出错时丢",不是绝不丢——真正不能丢的东西放作用域的扩展里(那是 Go 侧对象, 不随 VM 重建)。
长期持有由你管:本库没有按 key 复用和空闲回收。要按设备 ID 存着,业务侧自己一个 sync.Map,
跟 Go 版 controller 的写法一致。
作用域:没有生命周期的数据载体
作用域装在 ctx 里,只携带一段业务流程要共享的东西——它不持有 VM,也不管实例的生死。
flowchart TD
CTX["ctx ─ scope"] --> K["key(标识,脚本里 scope.key)"]
CTX --> G["ScopeGlobals(专属全局)"]
CTX --> X["Extensions(你创建的对象)"]
X --> V1["ctrlA 的 VM"]
X --> V2["ctrlB 的 VM"]
X --> V3["ctrlC 的 VM"]
note1["同一份 Go 对象引用<br/>注入到各个 VM"]
X -.- note1
扩展实例由你创建,所以 Go 侧和脚本读写的天然是同一份,不用再取回来:
st := store.New()
ctx = jscriptx.WithScope(ctx, jscriptx.ScopeExtensions(st))
// …脚本里 store.Set("k", v)…
st.Get("k")
扩展的存活期就是你手里那个变量的存活期——obj.Close() 只释放实例的 VM,不碰扩展。
脚本级钩子:跑在临时 VM 里
CallStatic 调的是脚本导出的 class 上的静态方法,它跟实例的生命周期是分开的:
flowchart LR
A["CallStatic(ctx, \"Startup\")"] --> B["建一个临时 VM"]
B --> C["跑模块顶层"]
C --> D["调静态方法<br/>**不构造实例**"]
D --> E["丢弃 VM"]
E --> F["副作用留在 Go 侧扩展里"]
两点跟 Call 不同,都是"静态"这个语义要求的:
- 不构造实例——
constructor是"这一次调用的准备",跟脚本级的初始化无关,跑它是白费 - VM 用完就丢,不回池——静态方法一般只在脚本生命周期里跑一两次
所以它建不出能存活的 JS 对象。用处是把配置写进 Go 侧的扩展,那些副作用留在 Go 那边, 后续每次调用都读得到。作用域扩展在静态方法里照常可用,这正是它的落点。
脚本没写这个静态方法时返回的错误能被 errors.Is(err, ErrFuncNotFound) 匹配——生命周期钩子
多半是可选的,用它区分"没写"和"写了但炸了"。
状态该放哪
flowchart TD
A["要跨调用保持的东西"] --> B{"丢了会怎样"}
B -->|"只是慢一点"| C["缓存"]
B -->|"会算错"| D["状态"]
C --> C1["可以放 this.xxx"]
D --> D1{"用哪种调用"}
D1 -->|"Script.Call 池化"| D2["必须放 Go 侧<br/>作用域扩展,或当参数传"]
D1 -->|"Instance"| D3["可以放 this.xxx<br/>但脚本出错会归零"]
D3 --> D4["绝对不能丢的<br/>放作用域扩展或落库"]
| 实例生命周期 | 脚本里的 this.xxx |
|
|---|---|---|
Script.Call |
= VM 生命周期 | 随时可能归零,只能当缓存 |
Instance |
由你 Close 决定 |
跨调用保持,出错时归零 |
| 作用域扩展 | 你手里的变量活多久 | 不受 VM 重建影响 |
还有一层容易忽略的:多个脚本 import 同一个模块时,模块级状态也不共享。模块被内联进各自的
产物是一层原因,更根本的是每个实例独占一个 VM,VM 之间不共享任何 JS 状态。公共的纯函数随便
import,公共的状态放作用域扩展。
并发粒度
| 场景 | 能并发吗 | 为什么 |
|---|---|---|
| 不同实例(不同请求、不同设备) | ✅ | 各自独占 VM |
| 同一作用域下的不同 controller | ✅ | 各有各的 VM,只共享 Go 侧的扩展对象 |
| 同一个实例的多次调用 | ❌ 串行 | Runtime 非并发安全,且要保住 this.xxx |
| 不带作用域的池化调用 | ✅ | 池里多个 VM |
只有第三种是串行的,那是必需的——跟 Go 侧用 sync.Mutex 保护 struct 字段是一回事。
共享数据靠扩展(Go 侧对象)而不是共用 Runtime:共用 Runtime 会让作用域内所有脚本被迫串行, 那才是真的并发瓶颈。
内存
每个实例独占一个 VM,3000 个实例的常驻内存实测:
| 配置 | 单实例 | 3000 个 |
|---|---|---|
| 裸 VM | 7.0 KB | 20.5 MB |
+ console |
12.5 KB | 36.6 MB |
+ console + store 扩展 |
21.8 KB | 64.0 MB |
白名单、console、扩展都只在建 VM 时注入,对每次调用零影响。但每个实例一个 VM,这些会
乘以实例数——实例多又不需要脚本日志时,WithLogger(nil) 关掉 console 能省约 44%。
而且注入是惰性的:建 VM 时只装一个只读的访问器属性,脚本第一次读到那个全局名才把方法集 转成 JS 对象,转完在这个 VM 里缓存。一个脚本通常只用得上少数几个扩展,用不到的那些不该在 每次建 VM 时都付一遍转换成本。所以上表是"全都读到"的上界,实际按脚本用到多少收费。
建一个实例约 4.8 μs。这个数字对"每请求一个实例"的场景直接相关:QPS 1000、每请求 3 个 controller,约 14 ms/s 的 CPU。