把一个目录当脚本仓库:按路径寻址、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 一遍太贵。
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
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)
|
||
)
|