From 0c947dde70758c1fa489fdc914c7e3834cd0df84 Mon Sep 17 00:00:00 2001 From: what Date: Thu, 10 Sep 2026 16:24:18 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=8A=A0=E4=B8=80=E7=AF=87=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E6=B5=81=E8=B5=B0=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-flow.md 讲的是三个阶段各自在做什么,缺的是**函数之间怎么串起来的**—— 想改代码、想知道加一层该加在哪,没有一处能查。 docs/flow.md 七张图: 全景 公开 API → 注册表 / 取 VM / 调用 三条链 runner 三态 Script / Instance / staticTarget 的 acquire+finish 差别, 「同一实例串行」就是 acquire 到 finish 之间一直持着 i.mu newVM 内部 为什么 scope 是显式参数而不是从 ctx 嗅 作用域进 VM WithScope → vmGlobals → bind → lazyGlobal → freeze 五跳, 这是全库最容易看不清的一条链 invoke 内部 含 defer 的注册顺序(finish → stop → recover,后进先出) lookup 三级 exports → ctor 静态 → 全局兜底,解释了 Script.Has 为什么 对静态方法也返回 true 编译链 Prepared 为什么不是优化开关而是正确性要求 写的时候实地核对了每条断言,抓出三处我自己写错的: 1. 图上把 Bundle → FinalizeBundle 画成两步,实际 Bundle 末尾自己就调了 2. 把「省 44% 常驻内存」归因给惰性注入——那是关掉 console 的收益 3. 「只读全局赋值静默失败」只对全局本身成立,freeze 拷出来的嵌套只读属性 (store.Set = null)实测是抛错的 顺带修一处上一轮改名的残留:engine.go 的注释还指着 lazyglobal_test.go。 --- README.md | 1 + doc.go | 3 + docs/flow.md | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++ engine.go | 2 +- 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 docs/flow.md diff --git a/README.md b/README.md index 8815c79..ab47cca 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ resource.GetDBTable(user, req.WithPermission(req.ResAll)) | | | | --- | --- | +| [docs/flow.md](docs/flow.md) | **函数流走向**:调用链怎么串的、作用域和扩展从哪一步进 VM、想加一层该加在哪 | | [docs/call-flow.md](docs/call-flow.md) | 执行流程:源码怎么变成可执行的、一次调用经过哪些环节、错误怎么分类 | | [docs/lifecycle.md](docs/lifecycle.md) | 生命周期:Engine / Script / VM / Instance / 作用域各活多久,状态该放哪 | diff --git a/doc.go b/doc.go index 85f50ae..1101122 100644 --- a/doc.go +++ b/doc.go @@ -38,6 +38,9 @@ // 没有事件循环(定时器、网络、真正的异步都没有)、不提供沙箱隔离 // (脚本能拿到你放行的 Go 对象,它们的方法是真能调的)。 // +// 想看函数之间怎么串起来的(调用链、作用域从哪一步进 VM、想加一层该加在哪), +// 见 docs/flow.md。 +// // # 基本用法 // // loader, err := esm.NewLoader("app/src") diff --git a/docs/flow.md b/docs/flow.md new file mode 100644 index 0000000..0b1ad3e --- /dev/null +++ b/docs/flow.md @@ -0,0 +1,291 @@ +# 函数流走向 + +一次调用从公开 API 走到 goja 要经过哪些函数、作用域和扩展在哪一步进入 VM。 + +`call-flow.md` 讲的是**三个阶段各自在做什么**(为什么要打包、错误怎么分类), +这一篇讲的是**函数之间怎么串起来的**——想改代码、想知道加一层该加在哪,看这篇。 + +--- + +## 全景 + +```mermaid +flowchart TB + subgraph 公开API + N["jscriptx.New(opts...)"] + C["e.Compile(name, src)"] + S["e.Script(name)"] + EN["e.New(ctx, name, args...)"] + SC["s.Call / CallInto / WithCall"] + SN["s.New(ctx, args...)"] + ST["s.CallStatic / HasStatic"] + IC["i.Call / CallInto / WithCall"] + end + + subgraph 脚本注册表 + LL["loadLocked"] + CL["compileLocked"] + BD["Bundle(含 FinalizeBundle)"] + GC["goja.Compile → *Program"] + end + + subgraph 取VM + BR["s.borrow"] + EQ["i.ensure"] + BS["s.borrowStatic"] + NV["**newVM**(唯一入口)"] + LD["s.load"] + end + + subgraph 调用 + CA["callAny / callInto"] + IV["invoke"] + LK["vm.lookup"] + FR["frame.callWith"] + end + + N --> S + C --> CL + S --> LL --> CL --> BD --> GC + EN --> S + EN --> SN + SN --> EQ + SC --> CA + IC --> CA + ST --> CA + CA --> IV + IV -->|"r.acquire"| BR + IV -->|"r.acquire"| EQ + IV -->|"r.acquire"| BS + BR -->|"池里没有"| NV + EQ --> NV + BS --> NV + NV --> LD + IV --> LK --> FR +``` + +两个要点: + +- **编译和取 VM 是分开的两条链**。`*Program` 是 goja 编译产物,不绑任何 Runtime, + 所有 VM 共用同一份。所以热更新(重编译)贵,建 VM 便宜——实测 213 μs vs 4 μs, + 编译占 99.7%。 +- **取 VM 只有 `newVM` 一个入口**。三条路径(池化、实例、静态)都汇到它, + 作用域注入和 `scoped` 标记的规则才只有一份。这两件事分散过,结果就是 + `borrowStatic` 漏标了 `scoped`,带作用域的 VM 差点回池。 + +--- + +## 谁提供 VM:runner 三态 + +`invoke` 不关心 VM 从哪来,只认这个接口(`invoke.go`): + +```go +type runner interface { + owner() *Script + acquire(ctx) (*vmHandle, error) + finish(inst *vmHandle, healthy bool) +} +``` + +三个实现,差别全在「用完怎么处理」: + +| | `acquire` | `finish` | 语义 | +| --- | --- | --- | --- | +| `*Script` | `borrow` | `release` | 从池借,好的还回去,坏的丢 | +| `*Instance` | 加锁 + `ensure` | 解锁,坏的置 nil | 独占一个 VM,**锁持到 finish**,所以同一实例串行 | +| `staticTarget` | `borrowStatic` | 只记 `dropped` | 用完就丢,永不回池 | + +所以「同一个实例的调用是串行的」不是靠额外机制,就是 `acquire` 到 `finish` +之间一直持着 `i.mu`。 + +--- + +## 建一个 VM:newVM 内部 + +```mermaid +flowchart TB + A["newVM(ctx, sc, ctorArgs, noInstance)"] --> B{"sc != nil"} + B -->|是| C["extra = sc.vmGlobals()"] + B -->|否| D["extra = nil"] + C --> E["goja.New()"] + D --> E + E --> F["SetMaxCallStackSize"] + F --> G["**engine.bind(rt, name, extra)**"] + G --> H["s.load(ctx, rt, ctorArgs, noInstance)"] + H --> I["vm.scoped = sc != nil"] + I --> J["*vmHandle"] +``` + +`sc` 是**显式参数,不是从 ctx 嗅的**。`Instance` 重建 VM 时要回到它**创建时** +那个作用域,而不是当次调用 ctx 里的——对一个作用域实例调 +`Call(context.Background())` 不该把它的扩展弄丢。有测试守这条 +(`TestScope_实例重建VM后仍在原作用域`)。 + +`vm.scoped` 决定 `release` 会不会把这个 VM 放回池子。带作用域的 VM 装着那个作用域的 +扩展,回池就会漏给下一个调用。 + +--- + +## 作用域和扩展怎么进 VM + +这是最容易看不清的一条链,从 `WithScope` 到 JS 全局变量一共五跳: + +```mermaid +flowchart TB + W["WithScope(ctx, ScopeExtensions(st), ScopeGlobals(m), ScopeKey(k))"] + W --> SS["ctx 里存一个 *scope
{key, exts, globals}"] + SS --> SO["scopeOf(ctx) 取回来"] + SO --> VG["**sc.vmGlobals()**
拍平成 map[string]any"] + VG --> BI["engine.bind(rt, name, extra)"] + BI --> LG["lazyGlobal 逐个注入"] + LG --> FZ["首次读取时才 freeze"] + FZ --> JS["脚本里的全局变量"] +``` + +### `vmGlobals` 的拍平规则 + +```go +for _, ext := range sc.exts { g[ext.Name()] = ext.Bindings() } // 扩展 +for k, v := range sc.globals { g[k] = v } // ScopeGlobals 盖扩展 +g["scope"] = map[string]any{"key": sc.key} // 身份 +``` + +扩展交出去的是 `Bindings()` 返回的那张 `map[string]any`——**方法集,不是扩展对象本身**。 +所以脚本看到的 `store` 是一个由 Go 函数组成的对象,那些函数闭包捏着同一个 Go 侧实例, +这才是「多个脚本共享状态」的实现方式:共享的是 Go 对象,不是共用 Runtime。 + +`scope.key` 总会有(`WithScope` 不传 key 就随机生成),所以**只要 ctx 里有作用域, +`extra` 就必然非空**——这也是「有作用域必然要单造 VM」的原因,池里的 VM 没装这些。 + +### `bind` 的优先级 + +```mermaid +flowchart LR + A["e.globals
(WithGlobals 白名单)"] -->|"同名时被跳过"| B["extra
(作用域的)"] + B --> C["console
(两边都没占用才注入)"] +``` + +顺序是刻意的:作用域的东西盖白名单,`console` 只在没被占用时才给。 +`WithLogger(nil)` 关掉 logger 就完全不注入 console。 + +### 为什么注入是惰性的 + +`lazyGlobal` 用 `DefineAccessorProperty` 装一个**只有 getter 没有 setter** 的属性: + +```mermaid +flowchart LR + A["脚本第一次读 db"] --> B{"cached == nil"} + B -->|是| C["freeze(rt, val)"] + C --> D["缓存并返回"] + B -->|否| D + E["脚本没读过 db"] --> F["freeze 从没跑过"] +``` + +- **没有 setter** = 脚本改不动。脚本跑在非严格模式下,给只读全局赋值是**静默失败**, + 不抛错(断言在 `engine_globals_test.go`)。注意这只说全局本身:`freeze` 拷出来的 + 嵌套只读属性(`store.Set = null`)实测是**抛错**的,两种行为不一样。 +- **惰性** = 脚本没读到的全局根本不转换。README 性能一节的内存表是「全都读到」的上界。 + +### `freeze` 只对 map 深拷贝 + +```mermaid +flowchart TB + A["freeze(rt, val)"] --> B{"val 是 map[string]any"} + B -->|是| C["递归 freeze 每个值
+ defineReadOnly"] + C --> D["各 VM 各持一份只读副本"] + B -->|否| E["rt.ToValue(val)"] + E --> F["**各 VM 共享同一个 Go 对象**"] +``` + +这一点常被误解成隔离保证。**放行结构体指针、slice、接口时,各个 VM 拿到的是同一个 +Go 对象**,脚本通过它的方法改到的东西是真改了。扩展走的正是这条路(`Bindings()` 返回的 +map 会被拷,但里面的函数闭包捏着同一个 Go 实例),要跨 VM 共享可变状态就该这么用。 + +--- + +## 一次调用:invoke 内部 + +```mermaid +flowchart TB + A["invoke(ctx, r, fn, args, do)"] --> B{"脚本已关闭"} + B -->|是| Z1["ErrClosed"] + B -->|否| C["**r.acquire(ctx)**"] + C --> D["defer r.finish(inst, healthy)"] + D --> E["**inst.lookup(fn)**"] + E -->|找不到| Z2["ErrFuncNotFound
+ notFoundHint"] + E --> F["**guard(ctx, rt, timeout)**
装超时哨兵"] + F --> G["defer stop()"] + G --> H["defer recover()
healthy = false"] + H --> I["do(frame)"] + I --> J["frame.callWith(args)"] + J --> K["unwrapPromise"] + K --> L{"返回值是 JS 函数"} + L -->|是| Z3["ErrValueEscape"] + L -->|否| M["res.Export() 或 res.Into(out)"] +``` + +### defer 的顺序很关键 + +注册顺序是 `finish` → `stop` → `recover`,执行是**后进先出**: + +1. `recover` 先跑,把 `healthy` 置成 false +2. `stop` 清掉超时哨兵 +3. `finish` 最后才交还 VM——这时 `healthy` 已经是正确的值 + +顺序错了的后果是:panic 过的 VM 带着不确定的状态回到池子里。 + +### `lookup` 的三级查找 + +```mermaid +flowchart TB + A["lookup(fn)"] --> B{"fn == DefaultFunc"} + B -->|是| C["defFn
(export default function)"] + B -->|否| D{"exports 上有"} + D -->|有| E["实例方法
this = exports"] + D -->|没有| F{"ctor 上有"} + F -->|有| G["**静态方法**
this = ctor"] + F -->|没有| H["全局函数兜底"] +``` + +这个顺序解释了两件事: + +- `Script.Has("Startup")` 对一个**静态**方法也返回 true——它走的是同一个 `lookup` +- `HasStatic` 要额外判 `ctor != nil`,就是为了排除最后那个全局兜底分支 + +--- + +## 编译链:源码到 *Program + +```mermaid +flowchart TB + A["e.Script(name)"] --> B{"缓存里有 && !autoReload"} + B -->|是| C["直接返回"] + B -->|否| D["**loadLocked**"] + D --> E["loader.Load(name)
→ source, version"] + E --> F{"version 跟缓存一样"} + F -->|是| C + F -->|否| G["**compileLocked**"] + G --> H{"loader 实现了 Prepared"} + H -->|"是(esm.Loader)"| I["源码已打包,直接用"] + H -->|否| J["**Bundle**
esbuild 内联 import + 转译 TS
末尾自己调 FinalizeBundle
把 ESM 改写成立即执行函数"] + I --> L["goja.Compile → *Program"] + J --> L + L --> M["*Script{prog, pool}"] +``` + +`Prepared` 不是优化开关而是**正确性要求**:打包产物里已经没有 `export` 了, +再打一遍会被当死代码整段摇空。所以自定义 Loader 如果自己打过包,必须实现它。 + +--- + +## 想加一层该加在哪 + +| 想做的事 | 该动哪 | +| --- | --- | +| 给脚本加一个全局对象 | 实现 `Extension`,通过 `ScopeExtensions` 进作用域 | +| 全进程都有的全局 | `WithGlobals`(走 `e.globals`,被作用域同名项覆盖) | +| 换脚本来源 | 实现 `Loader`;已自己打包的加 `Prepared` | +| 多来源叠加 | `Overlay`(后面盖前面) | +| 自定义回调签名 | `WithCall` + `Caller.Arity`,别改 `invoke` | +| 改 VM 的建法 | 只改 `newVM`——三条路径都走它 | +| 加一种新的 VM 来源 | 实现 `runner` 三个方法,别绕过 `invoke` | diff --git a/engine.go b/engine.go index d01648d..a3c6f93 100644 --- a/engine.go +++ b/engine.go @@ -207,7 +207,7 @@ func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Sc // // 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令, // wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读 - // 全局赋值会**静默失败**而不是抛错(见 lazyglobal_test.go 的断言)。 + // 全局赋值会**静默失败**而不是抛错(见 engine_globals_test.go 的断言)。 // // 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的 // 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。