Files
jscriptx/script_vm.go
what 7e1893b246 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 是**共享同一个
                 对象**的——扩展走的正是这条路,不该被当成隔离保证
2026-09-10 15:28:59 +08:00

239 lines
8.3 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"
"fmt"
"github.com/dop251/goja"
)
// VM 的取、还、装载。
//
// 取 VM 只有 newVM 一个入口——三条路径(池化 borrow、实例 ensure、静态
// borrowStatic)都走它,作用域注入和 scoped 标记的规则才只有一份。这两件事
// 分散过一次,结果就是 borrowStatic 漏标了 scoped,带作用域的 VM 差点回池。
// borrow 从池里取一个 VM;池空就新建一个。
//
// 刻意不阻塞等待:并发量超过池容量时宁可临时多造几个 VM,也不让请求在这里排队。
// 池只是复用缓存,不承担限流职责。
//
// ctx 带了作用域时**不走池**:池是所有调用共享的,而作用域的扩展是这一次调用专属的
// (store、当前请求……)。混用要么让脚本看不见扩展(顶层 import 直接 ReferenceError),
// 要么把上一个作用域的对象漏给下一个。所以为它单造一个 VM,用完丢弃。
//
// 代价是这类调用每次约 5μs 建一个 VM。同一作用域下要反复调,用 New 拿实例更划算——
// 实例把 VM 攥在手里,不必每次重建。
func (s *Script) borrow(ctx context.Context) (*vmHandle, error) {
// 有作用域就必然要单造:池里的 VM 没装这个作用域的扩展
sc, scoped := scopeOf(ctx)
if !scoped {
select {
case inst := <-s.pool:
return inst, nil
default:
}
}
return s.newVM(ctx, sc, nil, false)
}
// release 归还 VM。healthy 为 false(被中断过或 panic 过)时直接丢弃:
// 脚本本来就不该有跨调用状态,重建一个 VM 远比拖着一个状态可疑的 VM 划算。
func (s *Script) release(inst *vmHandle, healthy bool) {
if inst == nil {
return
}
if !healthy || s.closed.Load() || inst.scoped {
// scoped 的 VM 带着某个作用域的扩展,回池就会漏给下一个调用
s.dropped.Add(1)
return
}
select {
case s.pool <- inst:
default:
s.dropped.Add(1) // 池满,丢弃
}
}
// newVM 造一个新 VM 并在里面加载这个脚本:注入白名单 → 跑一遍脚本顶层代码 →
// 记下求值结果。
//
// **取 VM 的唯一入口**。三条路径(池化 borrow、实例 ensure、静态 borrowStatic
// 都走它,作用域注入和 scoped 标记的规则才只有一份——这两件事分散过一次,
// 结果就是 borrowStatic 漏标了 scoped。
//
// sc 是这个 VM 属于哪个作用域,nil 表示不属于任何作用域。**显式传而不是从 ctx 嗅**:
// Instance 重建 VM 时要回到它**创建时**那个作用域,而不是当次调用 ctx 里的那个——
// 对一个作用域实例调 Call(context.Background()) 不该把它的扩展弄丢。
//
// ctorArgs 传给 constructornoInstance 为 true 时不构造实例,只想调静态方法时用,
// 避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) newVM(ctx context.Context, sc *scope, ctorArgs []any, noInstance bool) (*vmHandle, error) {
var extra map[string]any
if sc != nil {
extra = sc.vmGlobals()
}
rt := goja.New()
if s.engine.maxStack > 0 {
rt.SetMaxCallStackSize(s.engine.maxStack)
}
if err := s.engine.bind(rt, s.name, extra); err != nil {
return nil, err
}
vm, err := s.load(ctx, rt, ctorArgs, noInstance)
if err != nil {
return nil, err
}
// 带作用域的 VM 装着那个作用域的扩展,绝不能回池——release 靠这个标志拒绝它
vm.scoped = sc != nil
return vm, nil
}
// load 把脚本装进一个 VM。noInstance 为 true 时不构造实例——
// 只想调静态方法时用,避免白跑一遍 constructor(那是每次调用的准备工作)。
func (s *Script) load(ctx context.Context, rt *goja.Runtime, ctorArgs []any, noInstance bool) (h *vmHandle, err error) {
stop := guard(ctx, rt, s.engine.timeout)
defer stop()
defer func() {
if r := recover(); r != nil {
h, err = nil, s.panicError(DefaultFunc, nil, r)
}
}()
v, err := rt.RunProgram(s.prog)
if err != nil {
return nil, classify(err, s.name, DefaultFunc, nil)
}
h = &vmHandle{rt: rt}
// 打包产物末尾的入口表达式求值出来的东西决定了脚本的形态:
// class → 由这里 new 出实例,构造参数从 Go 侧传(export default class X {}
// 普通函数 → 单函数入口,用 DefaultFunc 调用(export default function(){}
// 非函数对象 → 直接当导出实例(export default {...},或只有命名导出时的模块对象)
if !empty(v) {
switch {
case isClass(rt, v):
h.ctor = v.ToObject(rt)
if noInstance {
break // 只要静态方法,不跑 constructor
}
obj, err := construct(rt, v, ctorArgs)
if err != nil {
return nil, classify(err, s.name, "constructor", ctorArgs)
}
h.exports = obj
case isCallable(v):
h.defFn = v
default:
if obj, ok := v.(*goja.Object); ok {
h.exports = obj
}
}
}
s.created.Add(1)
return h, nil
}
// classDetector 判断一个值是不是 class。goja 的 AssertConstructor 对普通 function
// 也返回 true,区分不了;靠规范保证的差别来判:class 的 prototype 属性不可写,
// 普通函数的可写,箭头函数和方法简写根本没有 prototype。
var classDetector = goja.MustCompile("jscriptx:isclass", `(function (x) {
if (typeof x !== "function") { return false }
var d = Object.getOwnPropertyDescriptor(x, "prototype")
return !!d && d.writable === false
})`, true)
func isClass(rt *goja.Runtime, v goja.Value) bool {
if _, ok := goja.AssertConstructor(v); !ok {
return false
}
dv, err := rt.RunProgram(classDetector)
if err != nil {
return false
}
detect, ok := goja.AssertFunction(dv)
if !ok {
return false
}
res, err := detect(goja.Undefined(), v)
if err != nil {
return false
}
return res.ToBoolean()
}
func isCallable(v goja.Value) bool {
_, ok := goja.AssertFunction(v)
return ok
}
// construct 从 Go 侧 new 一个 JS class 实例,构造参数按 goja 的规则转换。
func construct(rt *goja.Runtime, class goja.Value, args []any) (*goja.Object, error) {
ctor, ok := goja.AssertConstructor(class)
if !ok {
return nil, fmt.Errorf("不是构造器")
}
jsArgs := make([]goja.Value, len(args))
for i, a := range args {
jsArgs[i] = rt.ToValue(a)
}
return ctor(nil, jsArgs...)
}
// lookup 找要调用的函数,同时给出调用时该绑定的 this。
//
// fn 为空取默认导出本身(export default function 那种);否则找导出实例的方法,
// this 绑定到实例,这样 class 里的 this.xxx 才有意义。
//
// 最后那层全局查找基本只是兜底:脚本经打包后是 IIFE,顶层声明进不了全局,
// 只有脚本显式往 globalThis 上挂东西时才走得到。
func (i *vmHandle) lookup(fn string) (callable goja.Callable, fnVal goja.Value, this goja.Value, ok bool) {
if fn == DefaultFunc {
if i.defFn == nil {
return nil, nil, nil, false
}
callable, ok = goja.AssertFunction(i.defFn)
return callable, i.defFn, goja.Undefined(), ok
}
if i.exports != nil {
// Get 会走原型链,class 方法定义在 prototype 上也找得到
if v := i.exports.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.exports, true
}
}
}
// 静态方法挂在 class 自身上,不在实例的原型链上,所以实例那边找不到才轮到这里。
// this 绑定到 class 本身,跟 JS 里 C.Startup() 的语义一致。
if i.ctor != nil {
if v := i.ctor.Get(fn); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
if callable, ok = goja.AssertFunction(v); ok {
return callable, v, i.ctor, true
}
}
}
v := i.rt.Get(fn)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, nil, nil, false
}
if callable, ok = goja.AssertFunction(v); !ok {
return nil, nil, nil, false
}
return callable, v, goja.Undefined(), true
}
// notFoundHint 在找不到函数时给一句有用的话。最常见的原因是脚本压根没写 export:
// 打包产物是 IIFE,没有导出的顶层代码会被当死代码摇掉,什么都不剩。
func (s *Script) notFoundHint(inst *vmHandle) string {
if inst.exports == nil && inst.defFn == nil {
return "脚本没有任何导出。打包产物是 IIFE,不写 export 的顶层代码会被当死代码摇掉;" +
"请用 export default 指明入口,或者用命名导出"
}
return "脚本里没有这个函数,或者它不是函数"
}