# 执行流程
一段脚本从源码走到 Go 侧拿到结果,中间经过三个阶段:**打包编译**、**取 VM**、**调用**。
```mermaid
flowchart TD
A["源码
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["esbuild:TS 转译 · import 内联
· tree-shaking · ESM 格式输出"]
C1 --> D["FinalizeBundle
改写 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:
```js
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` 改写掉:
```js
// 源码 // 产物
// 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 时才走这条路:
```mermaid
flowchart TD
A["goja.New()"] --> B["engine.bind:注入白名单"]
B --> B1["逐层拷贝成只读对象
+ console + 作用域的扩展/全局"]
B1 --> C["装超时哨兵"]
C --> D["rt.RunProgram(prog)
执行打包产物"]
D --> E["取完成值 v"]
E --> F{"v 是什么"}
F -->|"class"| G["construct(v, ctorArgs)
→ exports"]
F -->|"函数"| H["defFn
(DefaultFunc 调用)"]
F -->|"对象"| I["exports
(按方法名调用)"]
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、怎么还":
```mermaid
flowchart TD
A["invoke(ctx, runner, fn, args, do)"] --> B{"脚本已关闭?"}
B -->|"是"| Z1["ErrClosed"]
B -->|"否"| C["runner.acquire(ctx)
池借 / 加锁"]
C --> D["lookup(fn)
找方法 + 绑定 this"]
D -->|"找不到"| Z2["ErrFuncNotFound
+ 没写 export 的提示"]
D --> E["guard:装中断哨兵"]
E --> F["defer recover:兜 panic"]
F --> G["do(frame) → 真正调用"]
G --> H["unwrapPromise
async 结果解包"]
H --> I["Export / ExportTo
转成 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 会被放回池子。
### 超时哨兵
```mermaid
flowchart LR
A{"ctx.Done() 存在?"} -->|"否(快路径)"| B["time.AfterFunc
约 80ns"]
A -->|"是"| C["起 goroutine
select ctx.Done()"]
B --> D["触发 → rt.Interrupt()"]
C --> D
D --> E["stop():先关哨兵
再 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 侧 panic(recover 到) | `KindPanic` | ❌ 丢弃 |
| `Exception`(脚本 throw) | `KindRuntime` | ✅ 回池 |
| `StackOverflowError` | `KindRuntime` | ❌ 丢弃 |
脚本里没 catch 的 Go error 会顺着 `Exception.Unwrap()` 取回来挂在 `Cause` 上,
调用方的 `errors.Is` 照样能匹配到自己的哨兵错误。
---
## 自定义调用约定
`Call`/`CallInto` 表达不了的模式——典型是「回调 + next」中间件——走 `WithCall`:
```mermaid
flowchart LR
A["WithCall(ctx, fn, do)"] --> B["借 VM、装哨兵、兜 panic"]
B --> C["do(Caller)"]
C --> D["c.Arity()
看声明了几个形参"]
D --> E["c.Call(value, next)
next 是 Go 闭包"]
E --> F["res.IsEmpty() / Into(&out)"]
F --> G["归还 VM、分类错误"]
```
`next` 能传给脚本,靠的是 goja 会把 Go 函数包装成 JS 函数——所以不需要单独暴露"值转换"的概念。
但这也意味着它**只在这次 VM 借出期间有效**,`Caller` 和 `Result` 都不能存下来跨调用用
(`Result` 还会被同一个 `Caller` 的下次 `Call` 复用)。
完整实现见 `ExampleCaller`。本库不预设脚本回调该长什么样——那是框架的约定,各家不同。
---
## 值的跨界规则
```mermaid
flowchart LR
subgraph Go
A["Go 对象"]
D["Go 值"]
end
subgraph JS["VM 内部"]
B["反射包装的对象"]
C["返回值"]
end
A -->|"rt.ToValue
反射包装"| B
C -->|"Export / ExportTo"| D
C -.->|"函数/闭包
❌ 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 永远不出现在返回值里**,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。