refactor: 文件按主类型划分,一个 struct 一个功能
约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。
engine.go 355 → 231 行。原来混了三件不相干的事
engine_globals.go ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
engine_console.go ← console.go,跟上面是同一主题
script.go 325 → 100 行,只留公开方法
script_vm.go ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
script_static.go ← static.go
errors.go 364 → 150 行
errors_goja.go ← goja 错误的翻译层
errors_hints.go ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
两回事;拆出来之后 missing_global_test.go 才有对应源文件
bundle_finalize.go ← esmwrap.go
bundle.go 收下 validIdent / hashVersion(原来住在 engine.go)
Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。
classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。
顺带修四处注释漂移:
Session/会话 代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
没有「会话」这个概念
Extension 文档示例写 Module() string,接口是 Module() (path, source string)。
这是唯一一段教人写扩展的文档,照抄编译不过
doc.go 的 freeze 说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
对象**的——扩展走的正是这条路,不该被当成隔离保证
This commit is contained in:
@@ -2,8 +2,6 @@ package jscriptx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -176,6 +174,20 @@ func (e *Engine) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 自己做过了),跳过这一步——
|
||||
@@ -217,139 +229,3 @@ func (e *Engine) compileLocked(name, source, version string, prepared bool) (*Sc
|
||||
e.scripts[name] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bind 把白名单全局对象注入到一个新建的 VM 里。
|
||||
// extra 是这个 VM 专属的额外全局(作用域带来的扩展),可以为 nil;
|
||||
// 它跟白名单同样按只读注入,同名时以 extra 为准。
|
||||
//
|
||||
// 注入是**惰性**的:这里只装一个 getter,脚本第一次读到那个名字才把值转成
|
||||
// JS 对象。一个脚本通常只用得上少数几个扩展,而 freeze 要把每个方法都包装成
|
||||
// JS 函数——用不到的那些不该在每次建 VM 时都付一遍这个成本。见 lazyGlobal。
|
||||
func (e *Engine) bind(rt *goja.Runtime, script string, extra map[string]any) error {
|
||||
global := rt.GlobalObject()
|
||||
for name, val := range e.globals {
|
||||
if _, overridden := extra[name]; overridden {
|
||||
continue
|
||||
}
|
||||
if err := e.lazyGlobal(rt, global, name, val); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入全局对象 %q 失败", name)
|
||||
}
|
||||
}
|
||||
for name, val := range extra {
|
||||
if err := e.lazyGlobal(rt, global, name, val); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入会话全局对象 %q 失败", name)
|
||||
}
|
||||
}
|
||||
if e.logger != nil {
|
||||
_, taken := e.globals["console"]
|
||||
if _, t2 := extra["console"]; t2 {
|
||||
taken = true
|
||||
}
|
||||
if !taken {
|
||||
if err := e.lazyGlobal(rt, global, "console", newConsole(e.logger, script)); err != nil {
|
||||
return newError(KindBind, script, "", err, "注入 console 失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lazyGlobal 装一个惰性只读全局:脚本第一次读它才 freeze,之后复用。
|
||||
//
|
||||
// 为什么惰性:freeze 要把 map 里每个方法都包装成 JS 函数对象,而一个脚本通常只用
|
||||
// 得上少数几个扩展。急切注入的话,每建一个 VM 都要为**所有**扩展付这份成本——
|
||||
// 实测这是脚本层剩余开销里最大的一块。
|
||||
//
|
||||
// 缓存放在闭包里,不加锁:getter 只在脚本执行期间被调用,而那时这个 VM 是被独占的
|
||||
// (实例持着自己的锁,池化的 VM 同时只有一个借用者)。goja.Value 也跨不了 Runtime,
|
||||
// 所以这份缓存天然是每 VM 一份。
|
||||
//
|
||||
// 只给 getter 不给 setter,效果等同原来的 writable=false:脚本赋值时没有 setter 可调。
|
||||
// 产物跑在非严格模式下(见 compileLocked 那里的说明),所以赋值是**静默失败**——
|
||||
// 不抛错,值也不变。configurable 同样保持 false,删不掉也重定义不了。
|
||||
//
|
||||
// 代价是 freeze 的错误从"建 VM 时返回 Go 错误"变成"脚本读它时抛 JS 异常"。
|
||||
// freeze 只在属性名不合法时才会失败,而 New() 里的 validIdent 已经挡过一道,
|
||||
// 实际碰不到。
|
||||
func (e *Engine) lazyGlobal(rt *goja.Runtime, global *goja.Object, name string, val any) error {
|
||||
var (
|
||||
cached goja.Value
|
||||
failed error
|
||||
)
|
||||
getter := rt.ToValue(func(goja.FunctionCall) goja.Value {
|
||||
if cached == nil && failed == nil {
|
||||
cached, failed = e.freeze(rt, val)
|
||||
}
|
||||
if failed != nil {
|
||||
panic(rt.NewGoError(failed))
|
||||
}
|
||||
return cached
|
||||
})
|
||||
return global.DefineAccessorProperty(name, getter, nil, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||
}
|
||||
|
||||
// freeze 把 map[string]any 递归转成只读的 JS 对象,其他值原样交给 goja 包装。
|
||||
//
|
||||
// 直接 rt.Set(name, someMap) 会把同一个 Go map 暴露给每个 VM:脚本一句
|
||||
// db.C = null 既能污染别的 VM,又是实打实的数据竞争。这里每个 VM 都拿到
|
||||
// 自己的一份不可写、不可重定义的对象。
|
||||
func (e *Engine) freeze(rt *goja.Runtime, val any) (goja.Value, error) {
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return rt.ToValue(val), nil
|
||||
}
|
||||
obj := rt.NewObject()
|
||||
for k, v := range m {
|
||||
child, err := e.freeze(rt, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := defineReadOnly(obj, k, child); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func defineReadOnly(obj *goja.Object, name string, v goja.Value) error {
|
||||
return obj.DefineDataProperty(name, v, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE)
|
||||
}
|
||||
|
||||
// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。
|
||||
func validIdent(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch {
|
||||
case r == '_' || r == '$':
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
if i == 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hashVersion(source string) string {
|
||||
sum := sha256.Sum256([]byte(source))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user