Files
jscriptx/esm/node_modules_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

189 lines
5.5 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"
"strings"
"testing"
"time"
"git.fsdpf.net/go/jscriptx/esm"
)
// 脚本可以从 node_modules 引用公共库:裸模块名、scoped 包、.ts 入口都支持。
func TestNodeModules_公共库(t *testing.T) {
e := newEngine(t, "testdata/app/src")
ctx := context.Background()
obj, err := e.New(ctx, "Resource/ResSchemaController")
if err != nil {
t.Fatalf("加载失败: %v", err)
}
defer obj.Close()
got, err := obj.Call(ctx, "Describe", "widget", 12.5)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
// upper 来自 tinylib、money 来自 @fsdpf/utilscoped + .ts)、ex 来自同目录相对 import
if got != "WIDGET ¥12.50 stored:s" {
t.Errorf("got = %#v", got)
}
}
// node_modules 里的文件不能被当成入口。
func TestNodeModules_不算入口(t *testing.T) {
l, err := esm.NewLoader("testdata/app/src")
if err != nil {
t.Fatal(err)
}
for _, n := range l.Names() {
if strings.Contains(n, "node_modules") {
t.Errorf("node_modules 里的文件被当成入口了: %s", n)
}
}
}
// 依赖包不参与版本计算:改了 node_modules 不会自动重编译,Rebuild 之后才生效。
// 这是有意的取舍——真实 npm 包成百上千个文件,每次取脚本都 stat 一遍太贵。
func TestNodeModules_不参与热更新(t *testing.T) {
dir := t.TempDir()
mod := filepath.Join(dir, "Mod")
lib := filepath.Join(dir, "node_modules", "mylib")
os.MkdirAll(mod, 0o755)
os.MkdirAll(lib, 0o755)
write := func(p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(lib, "package.json"), `{"name":"mylib","main":"index.js"}`)
write(filepath.Join(lib, "index.js"), `export function hi() { return "lib-v1" }`)
write(filepath.Join(mod, "C.ts"), `import { hi } from "mylib"
export default class C { Get(): string { return hi() } }`)
loader, err := esm.NewLoader(dir)
if err != nil {
t.Fatal(err)
}
e := newEngineWith(t, loader)
ctx := context.Background()
obj, _ := e.New(ctx, "Mod/C")
if got, _ := obj.Call(ctx, "Get"); got != "lib-v1" {
t.Fatalf("got = %#v", got)
}
obj.Close()
time.Sleep(10 * time.Millisecond)
write(filepath.Join(lib, "index.js"), `export function hi() { return "lib-v2" }`)
// 改依赖包不触发重编译
obj2, _ := e.New(ctx, "Mod/C")
if got, _ := obj2.Call(ctx, "Get"); got != "lib-v1" {
t.Errorf("依赖包不该参与热更新, got %#v", got)
}
obj2.Close()
// 显式 Rebuild 之后生效
if err := loader.Rebuild(); err != nil {
t.Fatal(err)
}
e.Invalidate("Mod/C")
obj3, err := e.New(ctx, "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj3.Close()
if got, _ := obj3.Call(ctx, "Get"); got != "lib-v2" {
t.Errorf("Rebuild 后应该生效, got %#v", got)
}
// 业务代码照常热更新
time.Sleep(10 * time.Millisecond)
write(filepath.Join(mod, "C.ts"), `import { hi } from "mylib"
export default class C { Get(): string { return hi() + "!" } }`)
obj4, err := e.New(ctx, "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj4.Close()
if got, _ := obj4.Call(ctx, "Get"); got != "lib-v2!" {
t.Errorf("业务代码该正常热更新, got %#v", got)
}
}
// 公共库可以放在加载目录之外,用 WithNodePaths 指过去。
func TestNodeModules_指定目录(t *testing.T) {
root := t.TempDir()
app := filepath.Join(root, "app", "Mod")
libs := filepath.Join(root, "shared-libs", "tinylib")
os.MkdirAll(app, 0o755)
os.MkdirAll(libs, 0o755)
write := func(p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(libs, "package.json"), `{"name":"tinylib","main":"index.js"}`)
write(filepath.Join(libs, "index.js"), `export function hi() { return "来自共享目录" }`)
write(filepath.Join(app, "C.ts"), `import { hi } from "tinylib"
export default class C { Get(): string { return hi() } }`)
appDir := filepath.Join(root, "app")
// 不指定时找不到:app 及其上层都没有 node_modules
if _, err := esm.NewLoader(appDir); err == nil {
t.Error("app 下没有 node_modules,不指定应该解析失败")
}
// 指过去就能用
loader, err := esm.NewLoader(appDir, esm.WithNodePaths(filepath.Join(root, "shared-libs")))
if err != nil {
t.Fatalf("WithNodePaths 没生效: %v", err)
}
e := newEngineWith(t, loader)
obj, err := e.New(context.Background(), "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(context.Background(), "Get"); got != "来自共享目录" {
t.Errorf("got = %#v", got)
}
}
// Alias 把模块名钉到具体文件上,绕过 node_modules 查找。
func TestNodeModules_Alias(t *testing.T) {
root := t.TempDir()
app := filepath.Join(root, "app", "Mod")
os.MkdirAll(app, 0o755)
os.MkdirAll(filepath.Join(root, "impl"), 0o755)
os.WriteFile(filepath.Join(root, "impl", "mylib.ts"),
[]byte(`export function hi(): string { return "来自 alias" }`), 0o644)
os.WriteFile(filepath.Join(app, "C.ts"), []byte(`import { hi } from "tinylib"
export default class C { Get(): string { return hi() } }`), 0o644)
loader, err := esm.NewLoader(filepath.Join(root, "app"), esm.WithAlias(map[string]string{
"tinylib": filepath.Join(root, "impl", "mylib.ts"),
}))
if err != nil {
t.Fatalf("WithAlias 没生效: %v", err)
}
e := newEngineWith(t, loader)
obj, err := e.New(context.Background(), "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(context.Background(), "Get"); got != "来自 alias" {
t.Errorf("got = %#v", got)
}
}