Files
jscriptx/docs/call-flow.md
T
what c2ce37ad2d docs: README 与执行流程、生命周期两篇
README 分四段:脚本怎么写、Go 侧怎么调、安全边界与可靠性、性能。
docs/call-flow.md 讲源码怎么变成可执行的、一次调用经过哪些环节;
docs/lifecycle.md 讲 Engine / Script / VM / Instance / 作用域各活多久。

性能一节的每一行都标了对应的基准名,数字过时了可以自己重跑。原来有一张
「循环里访问 Go 对象」的表没有对应的基准测试,数字无法复现,换成了
Benchmark绑定_* 的实测结果。内存那张表仍是手工测的,已在旁边注明。

挑第三方库那节记了两条实测结论:esbuild 的 target 只降级语法、不补全局
对象,所以库只要用了 structuredClone 或定时器就是运行期才炸;CommonJS
包摇不动,同一组功能 lodash 打出 419 KB 而 es-toolkit 只要 7 KB。
2026-09-05 22:13:57 +08:00

254 lines
9.6 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`:否则同一个 Go map 会被所有 VM 共享,
脚本一句 `db.C = null` 既污染别的 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 永远不出现在返回值里**,它只会变成异常。这是脚本作者唯一需要额外理解的"非直觉"行为。