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。
This commit is contained in:
+291
@@ -0,0 +1,291 @@
|
||||
# 函数流走向
|
||||
|
||||
一次调用从公开 API 走到 goja 要经过哪些函数、作用域和扩展在哪一步进入 VM。
|
||||
|
||||
`call-flow.md` 讲的是**三个阶段各自在做什么**(为什么要打包、错误怎么分类),
|
||||
这一篇讲的是**函数之间怎么串起来的**——想改代码、想知道加一层该加在哪,看这篇。
|
||||
|
||||
---
|
||||
|
||||
## 全景
|
||||
|
||||
```mermaid
|
||||
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 差点回池。
|
||||
|
||||
---
|
||||
|
||||
## 谁提供 VM:runner 三态
|
||||
|
||||
`invoke` 不关心 VM 从哪来,只认这个接口(`invoke.go`):
|
||||
|
||||
```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` | 用完就丢,永不回池 |
|
||||
|
||||
所以「同一个实例的调用是串行的」不是靠额外机制,就是 `acquire` 到 `finish`
|
||||
之间一直持着 `i.mu`。
|
||||
|
||||
---
|
||||
|
||||
## 建一个 VM:newVM 内部
|
||||
|
||||
```mermaid
|
||||
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 全局变量一共五跳:
|
||||
|
||||
```mermaid
|
||||
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` 的拍平规则
|
||||
|
||||
```go
|
||||
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` 的优先级
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["e.globals<br/>(WithGlobals 白名单)"] -->|"同名时被跳过"| B["extra<br/>(作用域的)"]
|
||||
B --> C["console<br/>(两边都没占用才注入)"]
|
||||
```
|
||||
|
||||
顺序是刻意的:作用域的东西盖白名单,`console` 只在没被占用时才给。
|
||||
`WithLogger(nil)` 关掉 logger 就完全不注入 console。
|
||||
|
||||
### 为什么注入是惰性的
|
||||
|
||||
`lazyGlobal` 用 `DefineAccessorProperty` 装一个**只有 getter 没有 setter** 的属性:
|
||||
|
||||
```mermaid
|
||||
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 深拷贝
|
||||
|
||||
```mermaid
|
||||
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 内部
|
||||
|
||||
```mermaid
|
||||
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 的顺序很关键
|
||||
|
||||
注册顺序是 `finish` → `stop` → `recover`,执行是**后进先出**:
|
||||
|
||||
1. `recover` 先跑,把 `healthy` 置成 false
|
||||
2. `stop` 清掉超时哨兵
|
||||
3. `finish` 最后才交还 VM——这时 `healthy` 已经是正确的值
|
||||
|
||||
顺序错了的后果是:panic 过的 VM 带着不确定的状态回到池子里。
|
||||
|
||||
### `lookup` 的三级查找
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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` |
|
||||
Reference in New Issue
Block a user