Files
what 0c947dde70 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。
2026-09-10 16:24:18 +08:00

292 lines
10 KiB
Markdown
Raw Permalink 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.
# 函数流走向
一次调用从公开 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 差点回池。
---
## 谁提供 VMrunner 三态
`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`
---
## 建一个 VMnewVM 内部
```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` |