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。
232 lines
6.6 KiB
Go
232 lines
6.6 KiB
Go
package jscriptx
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"runtime"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/dop251/goja"
|
||
)
|
||
|
||
const (
|
||
// DefaultTimeout 是单次脚本调用的默认时限,超过就中断脚本。
|
||
DefaultTimeout = 5 * time.Second
|
||
// DefaultMaxCallStackSize 限制脚本的调用栈深度,防止递归打爆 Go 栈。
|
||
DefaultMaxCallStackSize = 2000
|
||
)
|
||
|
||
// Engine 是脚本引擎,持有全局白名单、执行策略和脚本缓存。
|
||
// 一个进程通常只需要一个 Engine,它本身并发安全。
|
||
type Engine struct {
|
||
globals map[string]any
|
||
timeout time.Duration
|
||
maxVMs int
|
||
maxStack int
|
||
logger *slog.Logger
|
||
|
||
loader Loader
|
||
autoReload bool
|
||
|
||
// WithLoader 收下的层,New 里合成 loader;Option 没有出错的地方,
|
||
// 校验只能推迟到那时候。
|
||
loaderLayers []Loader
|
||
|
||
bundleOpts []BundleOption
|
||
|
||
mu sync.Mutex
|
||
scripts map[string]*Script
|
||
closed bool
|
||
}
|
||
|
||
// New 创建引擎。全局白名单里的名字不合法时返回错误。
|
||
func New(opts ...Option) (*Engine, error) {
|
||
e := &Engine{
|
||
globals: map[string]any{},
|
||
timeout: DefaultTimeout,
|
||
maxVMs: runtime.GOMAXPROCS(0) * 2,
|
||
maxStack: DefaultMaxCallStackSize,
|
||
logger: slog.Default(),
|
||
scripts: map[string]*Script{},
|
||
}
|
||
for _, opt := range opts {
|
||
opt(e)
|
||
}
|
||
if e.maxVMs < 1 {
|
||
e.maxVMs = 1
|
||
}
|
||
if err := e.resolveLoader(); err != nil {
|
||
return nil, err
|
||
}
|
||
for name := range e.globals {
|
||
if !validIdent(name) {
|
||
return nil, fmt.Errorf("%w: 全局名 %q 不是合法的 JS 标识符", ErrBadGlobal, name)
|
||
}
|
||
}
|
||
return e, nil
|
||
}
|
||
|
||
// resolveLoader 把 WithLoader 收下的层合成一个 Loader。
|
||
func (e *Engine) resolveLoader() error {
|
||
switch len(e.loaderLayers) {
|
||
case 0:
|
||
return nil
|
||
case 1:
|
||
e.loader = e.loaderLayers[0]
|
||
if e.loader == nil {
|
||
return errors.New("jscriptx: WithLoader 收到 nil")
|
||
}
|
||
default:
|
||
o, err := Overlay(e.loaderLayers...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
e.loader = o
|
||
}
|
||
e.loaderLayers = nil
|
||
return nil
|
||
}
|
||
|
||
// Compile 用一段 ESM/TypeScript 源码注册脚本:先经 esbuild 打包,再交给 goja 编译,
|
||
// 结果进缓存,之后 Script(name) 能取到。同名脚本会被替换,旧的 VM 池随即释放
|
||
// (已经借出去的调用不受影响)。
|
||
//
|
||
// 源码必须有 export——产物是 IIFE,没有导出的顶层代码会被当死代码摇掉。
|
||
// 源码里要写 import 的话,得用 jscriptx/esm 子包按目录加载,或者配 WithResolveDir
|
||
// 给一个解析基准目录。
|
||
func (e *Engine) Compile(name, source string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
return e.compileLocked(name, source, hashVersion(source), false)
|
||
}
|
||
|
||
// Script 按名字取脚本:命中缓存直接返回;没命中就走 Loader 加载并编译。
|
||
// 打开了 WithAutoReload 时,每次都会跟 Loader 核对版本号,变了就重编译。
|
||
func (e *Engine) Script(name string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
|
||
cached, ok := e.scripts[name]
|
||
if ok && !e.autoReload {
|
||
return cached, nil
|
||
}
|
||
if e.loader == nil {
|
||
if ok {
|
||
return cached, nil
|
||
}
|
||
return nil, newError(KindNotFound, name, "", ErrScriptNotFound,
|
||
"没有配置 Loader,也没有通过 Compile 注册过这个脚本")
|
||
}
|
||
return e.loadLocked(name, cached)
|
||
}
|
||
|
||
// Reload 强制重新从 Loader 加载并编译,不管版本号有没有变。
|
||
func (e *Engine) Reload(name string) (*Script, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if e.closed {
|
||
return nil, newError(KindClosed, name, "", ErrClosed, "引擎已关闭")
|
||
}
|
||
if e.loader == nil {
|
||
return nil, newError(KindLoad, name, "", ErrScriptNotFound, "没有配置 Loader,无法重新加载")
|
||
}
|
||
return e.loadLocked(name, nil)
|
||
}
|
||
|
||
// Invalidate 把脚本从缓存里剔除并释放它的 VM 池,下次 Script 会重新加载。
|
||
func (e *Engine) Invalidate(name string) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if s, ok := e.scripts[name]; ok {
|
||
delete(e.scripts, name)
|
||
s.Close()
|
||
}
|
||
}
|
||
|
||
// Names 返回当前缓存里的脚本名。
|
||
func (e *Engine) Names() []string {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
out := make([]string, 0, len(e.scripts))
|
||
for name := range e.scripts {
|
||
out = append(out, name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Close 关闭引擎,释放所有脚本的 VM 池。之后再取脚本会报 ErrClosed。
|
||
func (e *Engine) Close() {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
e.closed = true
|
||
for name, s := range e.scripts {
|
||
s.Close()
|
||
delete(e.scripts, name)
|
||
}
|
||
}
|
||
|
||
// New 是 Script(name) + Script.New(ctx, args...) 的快捷方式:按名字取脚本,
|
||
// 实例化它导出的 class,构造参数直接传给 constructor。
|
||
//
|
||
// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController")
|
||
// defer ctrl.Close()
|
||
// got, err := ctrl.Call(ctx, "Init")
|
||
func (e *Engine) New(ctx context.Context, name string, ctorArgs ...any) (*Instance, error) {
|
||
s, err := e.Script(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.New(ctx, ctorArgs...)
|
||
}
|
||
|
||
// compileLocked 编译并替换缓存里的同名脚本。调用方必须持有 e.mu。
|
||
//
|
||
// prepared 为 true 表示源码已经过打包(Loader 自己做过了),跳过这一步——
|
||
// 对已经是 IIFE 的产物再打包一次是纯浪费。
|
||
func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Script, error) {
|
||
if !prepared {
|
||
// 脚本名同时当文件名传给打包器:扩展名(.ts/.json)决定按什么语法解析,
|
||
// 也是 sourcemap 和报错里显示的位置。
|
||
bundled, err := Bundle(name, source, e.bundleOpts...)
|
||
if err != nil {
|
||
return nil, newError(KindCompile, name, "", err, "脚本打包失败")
|
||
}
|
||
source = bundled
|
||
}
|
||
|
||
// 传 false = 不强制严格模式。
|
||
//
|
||
// 注意产物里**没有** "use strict":esbuild 输出 ESM 格式时不加这个指令,
|
||
// wrapESM 包成 IIFE 时也没加。所以脚本跑在非严格模式下,后果之一是给只读
|
||
// 全局赋值会**静默失败**而不是抛错(见 engine_globals_test.go 的断言)。
|
||
//
|
||
// 想改成严格模式就把这里传 true,但那是行为变更:脚本里任何依赖非严格语义的
|
||
// 写法(给未声明变量赋值、with、八进制字面量……)都会开始报错。
|
||
prog, err := goja.Compile(name, source, false)
|
||
if err != nil {
|
||
return nil, newError(KindCompile, name, "", err, "脚本编译失败")
|
||
}
|
||
|
||
s := &Script{
|
||
engine: e,
|
||
name: name,
|
||
version: version,
|
||
prog: prog,
|
||
pool: make(chan *vmHandle, e.maxVMs),
|
||
}
|
||
if old, ok := e.scripts[name]; ok {
|
||
old.Close()
|
||
}
|
||
e.scripts[name] = s
|
||
return s, nil
|
||
}
|