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

232 lines
6.6 KiB
Go
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.
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
}