Files
jscriptx/docs/flow.md
T
what 0c947dde70 docs: 加一篇函数流走向
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。
2026-09-10 16:24:18 +08:00

10 KiB
Raw Blame History

函数流走向

一次调用从公开 API 走到 goja 要经过哪些函数、作用域和扩展在哪一步进入 VM。

call-flow.md 讲的是三个阶段各自在做什么(为什么要打包、错误怎么分类), 这一篇讲的是函数之间怎么串起来的——想改代码、想知道加一层该加在哪,看这篇。


全景

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 差点回池。

谁提供 VMrunner 三态

invoke 不关心 VM 从哪来,只认这个接口(invoke.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 用完就丢,永不回池

所以「同一个实例的调用是串行的」不是靠额外机制,就是 acquirefinish 之间一直持着 i.mu


建一个 VMnewVM 内部

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 全局变量一共五跳:

flowchart TB
    W["WithScope(ctx, ScopeExtensions(st), ScopeGlobals(m), ScopeKey(k))"]
    W --> SS["ctx 里存一个 *scope<br/>{key, exts, globals}"]
    SS --> SO["scopeOf(ctx) 取回来"]
    SO --> VG["**sc.vmGlobals()**<br/>拍平成 map[string]any"]
    VG --> BI["engine.bind(rt, name, extra)"]
    BI --> LG["lazyGlobal 逐个注入"]
    LG --> FZ["首次读取时才 freeze"]
    FZ --> JS["脚本里的全局变量"]

vmGlobals 的拍平规则

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 的优先级

flowchart LR
    A["e.globals<br/>WithGlobals 白名单)"] -->|"同名时被跳过"| B["extra<br/>(作用域的)"]
    B --> C["console<br/>(两边都没占用才注入)"]

顺序是刻意的:作用域的东西盖白名单,console 只在没被占用时才给。 WithLogger(nil) 关掉 logger 就完全不注入 console。

为什么注入是惰性的

lazyGlobalDefineAccessorProperty 装一个只有 getter 没有 setter 的属性:

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 深拷贝

flowchart TB
    A["freeze(rt, val)"] --> B{"val 是 map[string]any"}
    B -->|是| C["递归 freeze 每个值<br/>+ defineReadOnly"]
    C --> D["各 VM 各持一份只读副本"]
    B -->|否| E["rt.ToValue(val)"]
    E --> F["**各 VM 共享同一个 Go 对象**"]

这一点常被误解成隔离保证。放行结构体指针、slice、接口时,各个 VM 拿到的是同一个 Go 对象,脚本通过它的方法改到的东西是真改了。扩展走的正是这条路(Bindings() 返回的 map 会被拷,但里面的函数闭包捏着同一个 Go 实例),要跨 VM 共享可变状态就该这么用。


一次调用:invoke 内部

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<br/>+ notFoundHint"]
    E --> F["**guard(ctx, rt, timeout)**<br/>装超时哨兵"]
    F --> G["defer stop()"]
    G --> H["defer recover()<br/>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 的顺序很关键

注册顺序是 finishstoprecover,执行是后进先出

  1. recover 先跑,把 healthy 置成 false
  2. stop 清掉超时哨兵
  3. finish 最后才交还 VM——这时 healthy 已经是正确的值

顺序错了的后果是:panic 过的 VM 带着不确定的状态回到池子里。

lookup 的三级查找

flowchart TB
    A["lookup(fn)"] --> B{"fn == DefaultFunc"}
    B -->|是| C["defFn<br/>export default function"]
    B -->|否| D{"exports 上有"}
    D -->|有| E["实例方法<br/>this = exports"]
    D -->|没有| F{"ctor 上有"}
    F -->|有| G["**静态方法**<br/>this = ctor"]
    F -->|没有| H["全局函数兜底"]

这个顺序解释了两件事:

  • Script.Has("Startup") 对一个静态方法也返回 true——它走的是同一个 lookup
  • HasStatic 要额外判 ctor != nil,就是为了排除最后那个全局兜底分支

编译链:源码到 *Program

flowchart TB
    A["e.Script(name)"] --> B{"缓存里有 && !autoReload"}
    B -->|是| C["直接返回"]
    B -->|否| D["**loadLocked**"]
    D --> E["loader.Load(name)<br/>→ source, version"]
    E --> F{"version 跟缓存一样"}
    F -->|是| C
    F -->|否| G["**compileLocked**"]
    G --> H{"loader 实现了 Prepared"}
    H -->|"是(esm.Loader"| I["源码已打包,直接用"]
    H -->|否| J["**Bundle**<br/>esbuild 内联 import + 转译 TS<br/>末尾自己调 FinalizeBundle<br/>把 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