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:
2026-09-10 15:28:59 +08:00
parent 1f25425762
commit 7e1893b246
15 changed files with 622 additions and 585 deletions
+108
View File
@@ -0,0 +1,108 @@
package jscriptx
import (
"strings"
)
// esbuild 按 IIFE 格式输出时,会在产物里塞一整套 CommonJS interop helper
// __defProp / __export / __copyProps / __toCommonJS…)。那套东西是为了模拟
// __esModule 语义,本库根本用不上:我们只要拿到导出对象。
//
// 但它的代价是实打实的——每建一个 VM 都要重新创建那 7 个函数、再遍历一遍属性装
// getter。实测建一个实例 22μs / 472 allocs,而不带 helper 的等价产物只要 4μs / 119。
//
// 所以改成按 ESM 格式打包(import 照样内联),再自己把末尾那句 export 改写掉:
//
// // p.ts (() => {// p.ts
// var H = class {…}; ───► var H = class {…};
// export { return H;})()
// H as default
// };
//
// 改写有两条硬约束:
//
// - 开头的 (() => { 必须紧贴原第一行,不能另起一行,否则所有行号下移一位,
// sourcemap 就对不上了,报错定位不回 .ts 源码
// - export 语句在产物末尾,把它整段换掉不影响前面任何行
// wrapESM 把 esbuild 的 ESM 产物改写成 goja 能直接执行的形式:
// 一个立即执行函数,完成值就是脚本的导出。
//
// 认不出末尾的 export 语句时(脚本压根没有导出,或者 esbuild 换了输出格式),
// 返回的产物求值为 undefined——交给"脚本没有任何导出"那条报错去解释。
func wrapESM(code string) string {
body, entry, ok := splitESMExports(code)
if !ok {
// 没有导出:让它求值成 undefined,报错由 lookup 那边给
return "(() => {" + code + "\nreturn void 0;})()\n"
}
return "(() => {" + body + "return " + entry + ";})()\n"
}
// splitESMExports 从产物末尾切下 export 语句,返回前面的代码和入口表达式。
//
// esbuild 的 ESM 输出格式很规整,末尾总是这样:
//
// export {
// H as default,
// extra
// };
func splitESMExports(code string) (body, entry string, ok bool) {
i := strings.LastIndex(code, "\nexport {")
if i < 0 {
return "", "", false
}
j := strings.Index(code[i:], "\n};")
if j < 0 {
return "", "", false
}
body = code[:i+1]
tail := code[i+len("\nexport {") : i+j]
names := parseExportNames(tail)
if len(names) == 0 {
return "", "", false
}
// 有 default 就用它——这跟"脚本导出 class/函数/实例"的入口约定对得上;
// 只有命名导出时,把它们拼成一个对象,按方法名调用。
if local, has := names["default"]; has {
return body, local, true
}
var b strings.Builder
b.WriteByte('{')
first := true
for exported, local := range names {
if !first {
b.WriteByte(',')
}
first = false
b.WriteString(exported)
b.WriteByte(':')
b.WriteString(local)
}
b.WriteByte('}')
return body, b.String(), true
}
// parseExportNames 解析 export 语句体,返回 导出名 -> 本地名。
// 每项形如 "H as default" 或 "foo"(导出名跟本地名相同)。
func parseExportNames(tail string) map[string]string {
out := map[string]string{}
for _, item := range strings.Split(tail, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
local, exported := item, item
if k := strings.Index(item, " as "); k >= 0 {
local = strings.TrimSpace(item[:k])
exported = strings.TrimSpace(item[k+len(" as "):])
}
if !validIdent(local) || !validIdent(exported) {
return nil // 格式不认识,交给调用方走兜底
}
out[exported] = local
}
return out
}