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

258 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 执行流程
一段脚本从源码走到 Go 侧拿到结果,中间经过三个阶段:**打包编译**、**取 VM**、**调用**。
```mermaid
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
```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["逐层拷贝成只读对象<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、怎么还"
```mermaid
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 会被放回池子。
### 超时哨兵
```mermaid
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`
```mermaid
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 借出期间有效**,`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<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 永远不出现在返回值里**,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。