Files
jscriptx/esm/layout_test.go
what 6ce9b483fd 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 一遍太贵。
2026-09-05 22:13:57 +08:00

212 lines
6.4 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 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)
}
}