Files
jscriptx/docs/call-flow.md
T
what 6477b27ef4 docs: 校对 README 和 docs,改掉三处跟代码对不上的
不是全面重写——文档里不指向具体源文件,所以上一轮的改名没波及它们。逐条核对
「文档提到的 API」和「实际导出」之后,只有三处不符:

  README 的哨兵错误列表   多了 ErrUnsupportedSignature(这轮删了),
                          少了 ErrInterrupted(一直都有)
  call-flow.md 的 freeze  说白名单「逐层拷贝成只读对象,否则同一个 Go map 会被
                          所有 VM 共享」——那只对 map[string]any 成立。结构体
                          指针和 slice 走 rt.ToValue,是**共享同一个对象**的。
                          扩展走的正是这条路,不该被当成隔离保证。
                          (doc.go 里同一处上一个 commit 已经改过)
  lifecycle.md 的状态图   写着 [*] --> 池中: newVM(),但 newVM 产出的 VM 是
                          直接借出去用的,回池要等 release

错误分类那张表(KindTimeout/KindCanceled/KindPanic/KindRuntime)核对过,没有
KindSignature,不受这轮删除影响。
2026-09-10 15:51:48 +08:00

9.9 KiB
Raw Blame History

执行流程

一段脚本从源码走到 Go 侧拿到结果,中间经过三个阶段:打包编译取 VM调用

flowchart TD
    A["源码<br/>ESM / TypeScript"] --> B{"从哪来"}
    B -->|"Engine.Compile(name, src)"| C
    B -->|"Loader.Load(name)"| P{"实现了 Prepared"}
    P -->|"否(默认)"| C["Bundle(filename, source)"]
    P -->|"是(jscriptx/esm"| E

    C --> C1["esbuildTS 转译 · import 内联<br/>· tree-shaking · ESM 格式输出"]
    C1 --> D["FinalizeBundle<br/>改写 export → 立即执行函数"]
    D --> E["goja.Compile → *goja.Program"]
    E --> F["*Script(进 Engine 缓存)"]

    F --> G{"怎么调"}
    G -->|"Script.Call"| H1["从 VM 池借"]
    G -->|"Instance.Call"| H2["锁住独占的 VM"]
    H1 --> I
    H2 --> I["invoke()"]
    I --> J["结果 / *Error"]

*goja.Program无状态的,同一个脚本的所有 VM 共享同一份编译产物;每个 VM 只是各自 执行一遍它,产生自己的函数对象和实例。


阶段一:打包编译

只在脚本加载和热更新时发生,不在调用路径上(单段源码约 204 μs,一个目录 9 个入口约 1.5 ms)。

为什么必须打包

goja 的 ES6+ 支持很完整(class、async、解构、可选链、Proxy、BigInt 实测都能直接跑), 但它没有 ES module——import/export 在 goja 的 token 表里是保留字,解析阶段就挂; TypeScript 也不在它的职责范围。打包把这两件事在交给引擎之前抹平。

为什么用 ESM 格式而不是 IIFE

esbuild 按 IIFE 或 CJS 格式输出时,会附带一整套 CommonJS interop helper

var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __export = (target, all) => {  };
var __copyProps = (to, from, except, desc) => {  };
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

那套东西是为了模拟 __esModule 语义,本库根本用不上——我们只要拿到导出对象。而它的代价是 实打实的:每建一个 VM 都要重新创建那 7 个函数、再遍历一遍属性装 getter

只有 ESM 格式是零 helper 的(导出信息就在 export {...} 那句声明里,不做任何转换)。 代价是那句 goja 不认,得由 FinalizeBundle 改写掉:

// 源码                          // 产物
// p.ts                          (() => {// p.ts
var H = class {};       ───►    var H = class {};
export {                         return H;})()
  H as default
};

改写有两条硬约束:

  • 开头的 (() => { 必须紧贴原第一行,不能另起一行,否则所有行号下移一位, sourcemap 就对不上,报错定位不回 .ts 源码
  • export 语句在产物末尾,把它整段换掉不影响前面任何行

只有命名导出时,改写成对象字面量 return {a:a, b:b}——仍然零 helper,而且是普通属性 (不是 getter),后续按方法名取的时候更快。

实测差距:

产物(代码部分) 建一个实例 每实例常驻
IIFE 格式(带 helper 1077 字节 22.1 μs / 472 allocs 25.7 KB
ESM + 改写 74 字节 4.8 μs / 110 allocs 7.0 KB

认不出 export 时的兜底

改写只认末尾那段 export {...};。格式对不上就当作"脚本没有导出",产物求值为 undefined, 调用时得到明确报错——不会静默产生错误结果。守护测试盯着三件事:产物不含任何 interop helper、 包装不让行号偏移(端到端验证第 3 行报错仍是第 3 行)、9 种导出形态都能正确改写。


阶段二:建 VM

池里没有可用 VM 时才走这条路:

flowchart TD
    A["goja.New()"] --> B["engine.bind:注入白名单"]
    B --> B1["逐层拷贝成只读对象<br/>+ console + 作用域的扩展/全局"]
    B1 --> C["装超时哨兵"]
    C --> D["rt.RunProgram(prog)<br/>执行打包产物"]
    D --> E["取完成值 v"]
    E --> F{"v 是什么"}
    F -->|"class"| G["construct(v, ctorArgs)<br/>→ exports"]
    F -->|"函数"| H["defFn<br/>DefaultFunc 调用)"]
    F -->|"对象"| I["exports<br/>(按方法名调用)"]
    G --> J["*vmHandle"]
    H --> J
    I --> J

白名单注入时不是直接 rt.Set,而是过一道 freeze。但它只对 map[string]any 逐层拷贝: 那种值会拷成只读 JS 对象,各个 VM 拿到各自的副本,脚本一句 db.C = null 影响不到别人。

放行的是结构体指针、slice 或别的 Go 对象时,freeze 直接 rt.ToValue——各个 VM 共享 同一个对象。要跨 VM 共享可变状态就该这么用(扩展走的正是这条路),但别把它当成隔离保证: 脚本通过那个对象的方法改到的东西是真改了。

顶层代码同样受超时保护——脚本在顶层写死循环不会把调用方卡住。


阶段三:调用

所有调用都汇到 invoke()*Script*Instance 的区别只在"怎么取 VM、怎么还"

flowchart TD
    A["invoke(ctx, runner, fn, args, do)"] --> B{"脚本已关闭?"}
    B -->|"是"| Z1["ErrClosed"]
    B -->|"否"| C["runner.acquire(ctx)<br/>池借 / 加锁"]
    C --> D["lookup(fn)<br/>找方法 + 绑定 this"]
    D -->|"找不到"| Z2["ErrFuncNotFound<br/>+ 没写 export 的提示"]
    D --> E["guard:装中断哨兵"]
    E --> F["defer recover:兜 panic"]
    F --> G["do(frame) → 真正调用"]
    G --> H["unwrapPromise<br/>async 结果解包"]
    H --> I["Export / ExportTo<br/>转成 Go 值"]
    I --> J{"出错了?"}
    J -->|"否"| K["healthy = true"]
    J -->|"是"| L["classify(err) 分类"]
    L --> M{"致命?"}
    M -->|"超时/取消/panic"| N["healthy = false"]
    M -->|"脚本异常"| K
    K --> O["runner.finish(inst, healthy)"]
    N --> O
    O --> P["结果 / *Error"]

defer 的顺序很关键

三个 defer 按注册顺序倒着执行,缺一不可:

注册:finish → stop → recover
执行:recover(置 healthy=false)→ stop(清中断标志)→ finish(归还/丢弃)

recover 必须最先跑,否则 panic 时 healthy 还是 true,一个状态可疑的 VM 会被放回池子。

超时哨兵

flowchart LR
    A{"ctx.Done() 存在?"} -->|"否(快路径)"| B["time.AfterFunc<br/>约 80ns"]
    A -->|"是"| C["起 goroutine<br/>select ctx.Done()"]
    B --> D["触发 → rt.Interrupt()"]
    C --> D
    D --> E["stop():先关哨兵<br/>再 ClearInterrupt"]

stop() 里的顺序有讲究:必须先确保哨兵不会再发信号,再清中断标志。反过来的话,一个迟到的 Interrupt 会落在已经清理过的 VM 上,毒死下一次用到它的调用。快路径用互斥量保证 stop 之后的 fire 一律丢弃;goroutine 路径等哨兵真正退出再清。

高频调用(MQTT 消息级)建议传 context.Background() 走快路径——传可取消的 context 会让每次 调用多起一个 goroutine,实测 0.45 μs 涨到 4.2 μs。

错误分类

classify() 把 goja 的各种错误翻译成统一的 *Error

goja 侧 Kind VM 还能用吗
InterruptedError + DeadlineExceeded KindTimeout 丢弃
InterruptedError + Canceled KindCanceled 丢弃
Go 侧 panicrecover 到) KindPanic 丢弃
Exception(脚本 throw KindRuntime 回池
StackOverflowError KindRuntime 丢弃

脚本里没 catch 的 Go error 会顺着 Exception.Unwrap() 取回来挂在 Cause 上, 调用方的 errors.Is 照样能匹配到自己的哨兵错误。


自定义调用约定

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

flowchart LR
    A["WithCall(ctx, fn, do)"] --> B["借 VM、装哨兵、兜 panic"]
    B --> C["do(Caller)"]
    C --> D["c.Arity()<br/>看声明了几个形参"]
    D --> E["c.Call(value, next)<br/>next 是 Go 闭包"]
    E --> F["res.IsEmpty() / Into(&out)"]
    F --> G["归还 VM、分类错误"]

next 能传给脚本,靠的是 goja 会把 Go 函数包装成 JS 函数——所以不需要单独暴露"值转换"的概念。 但这也意味着它只在这次 VM 借出期间有效CallerResult 都不能存下来跨调用用 Result 还会被同一个 Caller 的下次 Call 复用)。

完整实现见 ExampleCaller。本库不预设脚本回调该长什么样——那是框架的约定,各家不同。


值的跨界规则

flowchart LR
    subgraph Go
        A["Go 对象"]
        D["Go 值"]
    end
    subgraph JS["VM 内部"]
        B["反射包装的对象"]
        C["返回值"]
    end
    A -->|"rt.ToValue<br/>反射包装"| B
    C -->|"Export / ExportTo"| D
    C -.->|"函数/闭包<br/>❌ ErrValueEscape"| D

函数和闭包不能跨出脚本边界:那种值绑在 VM 上,VM 归还池子后再调用会出问题,所以 Call 的返回值和 CallInto 的目标都拒绝函数类型。需要回调语义走 WithCall——它在 VM 借出期间就把整个交互完成了。

Go 对象每次传进脚本都要反射包装一次,这是池化调用的主要开销:同一个业务(记录设备消息计数), 状态放 Go 侧靠参数传进去要 2.0 μs,状态放 JS 实例里只要 0.45 μs。

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


多返回值约定

Go 函数的多返回值到 JS 的转换规则(goja 的行为,本库沿用):

Go 签名 脚本里拿到
func() T 裸值
func() (T, error) error 为 nil:裸值;非 nil抛 JS 异常
func() (A, B) 数组 [A, B]
func() (A, B, error) error 为 nil[A, B];非 nil:抛异常

error 永远不出现在返回值里,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。