feat(esm): 按目录加载脚本、打包与热更新

把一个目录当脚本仓库:按路径寻址、esbuild 打包、内容变了自动重编。

  - Loader    扫目录建索引,Load(name) 给出打好包的源码和版本号
  - bundler   esbuild 的封装。ESM 格式而不是 IIFE——IIFE 会附带一整套
              CommonJS interop helper,每建一个 VM 都要重跑一遍
  - plugin    把扩展的 TS 模块变成可以 import 的虚拟模块,磁盘上没有文件
  - typings   把这些虚拟模块的类型按 node_modules 布局落盘,编辑器才认识

esbuild 原生实现了 Node 的模块解析,所以脚本能直接 import node_modules
里的第三方库。写出来的类型文件是 index.ts 而不是 index.d.ts:扩展给的是
真正的模块源码,里面可能带实现,声明文件里不允许有实现。

node_modules 不参与热更新的版本计算——依赖包是装出来的,改动总伴随显式的
安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。
This commit is contained in:
2026-09-05 22:13:57 +08:00
parent 7fb8d0f966
commit 6ce9b483fd
34 changed files with 2403 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
package esm_test
import (
"context"
"os"
"path/filepath"
"testing"
"git.fsdpf.net/go/jscriptx/esm"
"github.com/evanw/esbuild/pkg/api"
)
// 推荐的项目布局:配置和依赖放在项目根,源码放在 src/,加载器指向 src/。
//
// app/
// ├── package.json ← npm 配置
// ├── tsconfig.json ← esbuild 会自动往上找到并生效
// ├── node_modules/ ← 依赖
// └── src/ ← NewLoader 指这里,寻址不带前缀
// ├── PkgVersion/
// └── Resource/
func TestLayout_src布局(t *testing.T) {
root := t.TempDir()
app := filepath.Join(root, "app")
w := func(p, s string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
w(filepath.Join(app, "package.json"), `{"name":"fsdpf-app","type":"module"}`)
w(filepath.Join(app, "tsconfig.json"), `{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@lib/*": ["./src/lib/*"] },
"experimentalDecorators": true
}
}`)
w(filepath.Join(app, "node_modules", "tinylib", "package.json"), `{"name":"tinylib","main":"index.js"}`)
w(filepath.Join(app, "node_modules", "tinylib", "index.js"), `export function hi() { return "nm" }`)
w(filepath.Join(app, "src", "lib", "helper.ts"), `export function help(): string { return "helper" }`)
// 依赖 + tsconfig 的 paths 别名
w(filepath.Join(app, "src", "Resource", "ResCreateController.ts"), `import { hi } from "tinylib"
import { help } from "@lib/helper"
export default class ResCreateController {
Get(): string { return hi() + "/" + help() }
}`)
// tsconfig 里开的 experimentalDecorators
w(filepath.Join(app, "src", "Decor", "DecorController.ts"), `function Route(path: string) {
return function (target: any, key: string, desc: PropertyDescriptor) {
const orig = desc.value
desc.value = function (...args: any[]) { return path + ":" + orig.apply(this, args) }
return desc
}
}
export default class DecorController {
@Route("/api")
Get(): string { return "decorated" }
}`)
src := filepath.Join(app, "src")
// 加载器指向 app 时,源码深了一层,默认 glob 匹配不到
if l, err := esm.NewLoader(app); err == nil && len(l.Names()) != 0 {
t.Errorf("默认 glob 只认第一层子目录,不该匹配到 src 下的文件: %v", l.Names())
}
loader, err := esm.NewLoader(src)
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
e := newEngineWith(t, loader)
ctx := context.Background()
for _, c := range []struct{ name, want string }{
// node_modules 在 src 的上层(app 下),逐级往上能找到;
// tsconfig.json 同样在上层,paths 别名照样生效
{"Resource/ResCreateController", "nm/helper"},
// tsconfig 里的 experimentalDecorators 也生效
{"Decor/DecorController", "/api:decorated"},
} {
obj, err := e.New(ctx, c.name)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
got, err := obj.Call(ctx, "Get")
if err != nil {
t.Errorf("%s: %v", c.name, err)
} else if got != c.want {
t.Errorf("%s = %#v, want %q", c.name, got, c.want)
}
obj.Close()
}
}
// 想把辅助模块挡在入口之外,用 glob 收窄。
func TestLayout_用glob只收Controller(t *testing.T) {
root := t.TempDir()
w := func(p, s string) {
t.Helper()
os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
w(filepath.Join(root, "Resource", "ResCreateController.ts"),
`import { help } from "../lib/helper"
export default class C { Get(): string { return help() } }`)
w(filepath.Join(root, "lib", "helper.ts"), `export function help(): string { return "helper" }`)
loader, err := esm.NewLoader(root, esm.WithGlobs("*/*Controller.ts"))
if err != nil {
t.Fatal(err)
}
names := loader.Names()
if len(names) != 1 || names[0] != "Resource/ResCreateController" {
t.Errorf("入口应该只有 Controller: %v", names)
}
// 挡在入口外不影响被 import
e := newEngineWith(t, loader)
obj, err := e.New(context.Background(), "Resource/ResCreateController")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(context.Background(), "Get"); got != "helper" {
t.Errorf("got = %#v", got)
}
}
// testdata 本身就是推荐布局,这里验证它的配置文件确实在起作用:
// node_modules 和 tsconfig.json 都在 src 的上层,靠逐级往上查找生效。
func TestLayout_testdata走的就是推荐布局(t *testing.T) {
e := newEngine(t, "testdata/app/src")
ctx := context.Background()
obj, err := e.New(ctx, "Resource/ResSchemaController")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
// tinylib / @fsdpf/util 来自 app/node_modulessrc 的上层)
got, err := obj.Call(ctx, "Describe", "widget", 12.5)
if err != nil {
t.Fatalf("node_modules 没生效: %v", err)
}
if got != "WIDGET ¥12.50 stored:s" {
t.Errorf("Describe = %#v", got)
}
// @lib/format 是 app/tsconfig.json 里配的 paths 别名
got, err = obj.Call(ctx, "Tag", "x")
if err != nil {
t.Fatalf("tsconfig 的 paths 别名没生效: %v", err)
}
if got != "[x]" {
t.Errorf("Tag = %#v", got)
}
}
// WithTarget / WithDefine:打包时的语法降级和编译期常量替换。
func TestLayout_打包选项(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "Mod")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "C.ts"), []byte(`declare const __MODE__: string
export default class C {
Mode(): string { return __MODE__ }
Pick(o: any): string { return o?.a?.b ?? "兜底" }
}`), 0o644); err != nil {
t.Fatal(err)
}
loader, err := esm.NewLoader(root,
esm.WithDefine(map[string]string{"__MODE__": `"生产"`}),
// 可选链和空值合并降级到 ES2015,goja 对新语法覆盖不全时的出路
esm.WithTarget(api.ES2015),
)
if err != nil {
t.Fatal(err)
}
e := newEngineWith(t, loader)
ctx := context.Background()
obj, err := e.New(ctx, "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(ctx, "Mode"); got != "生产" {
t.Errorf("WithDefine 没生效, got %#v", got)
}
if got, err := obj.Call(ctx, "Pick", nil); err != nil || got != "兜底" {
t.Errorf("降级后语法失效: got=%#v err=%v", got, err)
}
if got, _ := obj.Call(ctx, "Pick", map[string]any{"a": map[string]any{"b": "命中"}}); got != "命中" {
t.Errorf("got = %#v", got)
}
}