Files
jscriptx/esm/loader.go
T
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

227 lines
7.6 KiB
Go
Raw 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
import (
"fmt"
"sync"
"git.fsdpf.net/go/jscriptx"
"github.com/evanw/esbuild/pkg/api"
)
// outDir 只是给 esbuild 算相对路径用的虚拟目录,不落盘。
const outDir = "__jsx_out"
// DefaultGlobs 是默认的入口规则:根目录第一层子目录下的 js/ts 文件。
// 对应 app/PkgVersion/PkgImportController.ts 这样的结构。
var DefaultGlobs = []string{"*/*.js", "*/*.ts", "*/*.mjs"}
// Loader 从一个目录加载 ESM/TypeScript 源码,打包成 goja 能执行的代码。
// 它实现 jscriptx.Loader,交给 jscriptx.WithLoader 使用。
type Loader struct {
dir string
globs []string
target api.Target
define map[string]string
nodePaths []string
alias map[string]string
exts []jscriptx.Extension
label string // WithVersion 给这一层贴的版本标签,调用时用 name@label 点名
mu sync.Mutex
bundles map[string]*bundle
built bool
}
// Option 是 NewLoader 的配置项。
type Option func(*Loader)
// WithVersion 给这一层贴一个版本标签。多层叠放时,调用点可以用 name@版本 点名
// 要哪一层的实现,不点名还是照旧"上层盖下层":
//
// v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
// v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
// e, _ := jscriptx.New(jscriptx.WithLoader(v1, v2))
//
// e.New(ctx, "Resource/ResCreateController") // 不点名:拿到 v2(最上层)
// e.New(ctx, "Resource/ResCreateController@v1") // 点名:要 v1 那一层的
//
// 标签只在叠层里有意义,单个 Loader 用不上;各层的标签不能重名。详见 jscriptx.Overlay。
func WithVersion(v string) Option {
return func(l *Loader) { l.label = v }
}
// Version 返回 WithVersion 贴的版本标签,没贴过是空串。
func (l *Loader) Version() string { return l.label }
// WithGlobs 自定义入口规则(相对根目录的 glob,用 / 分隔)。
// 不设时用 DefaultGlobs:第一层子目录下的所有 js/ts 文件。
//
// 只作为入口的文件才能按名字取到;被 import 的模块不需要是入口。
func WithGlobs(globs ...string) Option {
return func(l *Loader) { l.globs = globs }
}
// WithExtensions 让脚本能 import 扩展的 TS 模块拿到类型:
//
// import store from "@jscriptx/store"
//
// 传进来的扩展只用于**打包阶段**——提供模块源码和类型。运行时真正注入哪些扩展,
// 由每次调用的 ctx 决定(jscriptx.WithScope + ScopeExtensions)。所以这里传的
// 应该是"脚本可能用到的全部扩展",通常跟运行时那份是同一批。
//
// 不传也能用扩展,只是脚本得直接写全局变量(没有类型提示)。
func WithExtensions(exts ...jscriptx.Extension) Option {
return func(l *Loader) { l.exts = append(l.exts, exts...) }
}
// WithTarget 设置输出的 ECMAScript 版本,默认 ES2017。
// goja 对 ES6+ 的覆盖不是 100%,遇到脚本里用了 goja 不认识的语法时,
// 调低这个值让 esbuild 把它降级掉。
func WithTarget(t api.Target) Option {
return func(l *Loader) { l.target = t }
}
// WithDefine 设置编译期常量替换,比如 WithDefine(map[string]string{"__DEV__": "false"})。
func WithDefine(define map[string]string) Option {
return func(l *Loader) { l.define = define }
}
// WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。
//
// 不设时按 Node 的默认规则来:从 import 所在文件的目录逐级往上找 node_modules
// 所以公共库放在加载目录里(app/node_modules)或它的任意上层目录都能被找到。
// 只有公共库放在完全不相干的路径下时才需要这个选项:
//
// esm.NewLoader("app", esm.WithNodePaths("/opt/fsdpf/js-libs"))
//
// 传进来的目录本身相当于一个 node_modules:包直接放在它下面(<dir>/tinylib/),
// 不要再套一层 node_modules。多个目录按先后顺序查找。
func WithNodePaths(paths ...string) Option {
return func(l *Loader) { l.nodePaths = append(l.nodePaths, paths...) }
}
// WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找:
//
// esm.WithAlias(map[string]string{"@fsdpf/util": "/opt/fsdpf/util/index.ts"})
//
// 适合把某个名字钉死到一份实现上,或者给旧名字做转发。
func WithAlias(alias map[string]string) Option {
return func(l *Loader) {
if l.alias == nil {
l.alias = map[string]string{}
}
for k, v := range alias {
l.alias[k] = v
}
}
}
// NewLoader 创建一个目录加载器,立刻打包一次,源码有语法错误会在这里就报出来。
func NewLoader(dir string, opts ...Option) (*Loader, error) {
l := &Loader{
dir: dir,
globs: DefaultGlobs,
target: api.ES2017,
}
for _, opt := range opts {
opt(l)
}
if err := l.Rebuild(); err != nil {
return nil, err
}
return l, nil
}
// Load 实现 jscriptx.Loader。返回打包好的自包含代码,以及一个随任何参与打包的
// 源文件变化而变化的版本号——配合 jscriptx.WithAutoReload,改了 .ts 就会自动重编译。
func (l *Loader) Load(name string) (string, string, error) {
l.mu.Lock()
b, ok := l.bundles[name]
built := l.built
l.mu.Unlock()
if ok && b.version == l.versionOf(b.inputs) {
return b.code, b.version, nil // 依赖没变,直接给缓存
}
// 名字没见过:先确认目录里到底有没有这个入口,有才值得重新打包。
// 不做这一步的话,每次问一个不存在的名字都要把整个目录重打一遍——
// 叠层时下层被问到"上层专属的脚本"是常事,代价会很显眼。
if !ok && built && !l.mightHave(name) {
return "", "", l.notFound(name)
}
// 新增了文件,或者依赖变了:重新打包整个目录
if err := l.Rebuild(); err != nil {
return "", "", err
}
l.mu.Lock()
b, ok = l.bundles[name]
l.mu.Unlock()
if !ok {
return "", "", l.notFound(name)
}
return b.code, b.version, nil
}
// mightHave 扫一遍目录,看有没有这个名字的入口。只是 WalkDir,比重新打包便宜得多。
func (l *Loader) mightHave(name string) bool {
entries, err := l.findEntries()
if err != nil {
return true // 扫不动就当它可能在,走重打包让错误浮出来
}
for _, rel := range entries {
if toName(rel) == name {
return true
}
}
return false
}
func (l *Loader) notFound(name string) error {
return fmt.Errorf("%w: %s(目录 %s 下没有这个入口)",
jscriptx.ErrScriptNotFound, name, l.dir)
}
// Rebuild 重新扫描目录并打包。
func (l *Loader) Rebuild() error {
bundles, err := l.buildAll()
if err != nil {
return err
}
l.mu.Lock()
l.bundles = bundles
l.built = true
l.mu.Unlock()
return nil
}
// Names 返回当前所有入口的名字(如 PkgVersion/PkgImportController),顺序不定。
func (l *Loader) Names() []string {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]string, 0, len(l.bundles))
for name := range l.bundles {
out = append(out, name)
}
return out
}
// Dir 返回加载的根目录。
func (l *Loader) Dir() string { return l.dir }
// Prepared 实现 jscriptx.Prepared:这里给出的源码已经过 esbuild 打包成 IIFE
// Engine 必须跳过打包——再打一遍会被当死代码摇空。
func (l *Loader) Prepared() bool { return true }
// 编译期钉住:Loader 必须满足这两个接口。
//
// Prepared 尤其要紧:它是靠类型断言识别的,方法签名改了不会有编译错误,
// 而这里给出的产物是 IIFE,被 Engine 再打包一遍就会当死代码摇空——
// 脚本什么都不剩。这行断言就是防这个。
var (
_ jscriptx.Loader = (*Loader)(nil)
_ jscriptx.Prepared = (*Loader)(nil)
)