把一个目录当脚本仓库:按路径寻址、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 一遍太贵。
212 lines
6.4 KiB
Go
212 lines
6.4 KiB
Go
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_modules(src 的上层)
|
||
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)
|
||
}
|
||
}
|