package jscriptx import ( "fmt" "strings" "github.com/evanw/esbuild/pkg/api" ) // BundleOption 配置源码的打包方式。 type BundleOption func(*bundleOptions) type bundleOptions struct { resolveDir string target api.Target define map[string]string nodePaths []string alias map[string]string } // WithResolveDir 给源码一个解析 import 的基准目录。不设时源码里不能有 import // (单段源码没有文件系统上下文,esbuild 解析不了相对路径)。 func WithResolveDir(dir string) BundleOption { return func(o *bundleOptions) { o.resolveDir = dir } } // WithBundleTarget 设置输出的 ECMAScript 版本,默认 ES2017。 func WithBundleTarget(t api.Target) BundleOption { return func(o *bundleOptions) { o.target = t } } // WithBundleDefine 设置编译期常量替换。 func WithBundleDefine(define map[string]string) BundleOption { return func(o *bundleOptions) { o.define = define } } // WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。 // 传进来的目录本身相当于一个 node_modules:包直接放在它下面,不要再套一层。 // // 没有同时配 WithResolveDir 时,会拿当前工作目录当解析起点——esbuild 需要一个起点 // 才会启动模块解析,真正的查找仍然走这里给的目录。 func WithNodePaths(paths ...string) BundleOption { return func(o *bundleOptions) { o.nodePaths = append(o.nodePaths, paths...) } } // WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找。 func WithAlias(alias map[string]string) BundleOption { return func(o *bundleOptions) { if o.alias == nil { o.alias = map[string]string{} } for k, v := range alias { o.alias[k] = v } } } // Bundle 把一段 ESM/TypeScript 源码打包成本库能直接执行的形式。 // // goja 没有 ES module 支持,也不认识 TypeScript,所有脚本都要先过这一步。 // Engine.Compile 内部就是调它;自定义 Loader 如果返回的是原始源码,也用它处理。 // // filename 是这段源码的文件名,有两个实际作用:**扩展名决定按什么语法解析** // (.ts/.mts 按 TypeScript,.json 按 JSON,其余按 TypeScript——它是 JS 的超集, // 普通 JS 照样能过),以及作为 sourcemap 和报错里显示的位置。 // // 打包后的代码是自包含的 IIFE,模块导出挂在 ModuleGlobal 上,末尾补一句入口表达式。 // 因为产物是 IIFE,**脚本必须有 export**——没有导出的顶层代码会被当成死代码摇掉。 func Bundle(filename, source string, opts ...BundleOption) (string, error) { o := &bundleOptions{target: api.ES2017} for _, opt := range opts { opt(o) } if o.resolveDir == "" && len(o.nodePaths) > 0 { // NodePaths 是"额外去哪儿找",esbuild 仍然需要一个起点才会启动解析流程, // 没有起点时连 NodePaths 都不查。用当前工作目录当锚点,真正的查找还是走 // NodePaths。(Alias 是直接映射,不受这个限制。) o.resolveDir = "." } res := api.Build(api.BuildOptions{ Stdin: &api.StdinOptions{ Contents: source, Sourcefile: filename, Loader: loaderFor(filename), ResolveDir: o.resolveDir, }, Bundle: true, // 用 ESM 格式而不是 IIFE:IIFE 会附带一整套 CommonJS interop helper, // 每建一个 VM 都要重跑一遍。拿到 ESM 产物后由 FinalizeBundle 自己包成 // 立即执行函数,见 esmwrap.go。 Format: api.FormatESModule, Target: o.target, Define: o.define, NodePaths: o.nodePaths, Alias: o.alias, Write: false, Sourcemap: api.SourceMapInline, SourcesContent: api.SourcesContentExclude, LogLevel: api.LogLevelSilent, }) if len(res.Errors) > 0 { return "", bundleError(filename, res.Errors, o.resolveDir) } if len(res.OutputFiles) == 0 { return "", fmt.Errorf("打包 %s 没有产出", filename) } return FinalizeBundle(string(res.OutputFiles[0].Contents)), nil } // FinalizeBundle 把 esbuild 的 ESM 产物改写成本库能直接执行的形式:一个立即执行函数, // 完成值就是脚本的导出(default 优先,只有命名导出时是整个模块对象)。 // // 自定义 Loader 自己调 esbuild 打包时,产物也要过这一步,格式才对得上。 // 怎么改写、为什么不直接用 esbuild 的 IIFE 格式,见 esmwrap.go。 func FinalizeBundle(code string) string { const marker = "//# sourceMappingURL=" i := strings.LastIndex(code, marker) if i < 0 { return wrapESM(code) } // sourcemap 注释留在最后,包装只作用于代码部分 return wrapESM(strings.TrimRight(code[:i], "\n")) + code[i:] } // loaderFor 按文件名的扩展名挑解析方式。 func loaderFor(filename string) api.Loader { switch { case strings.HasSuffix(filename, ".ts"), strings.HasSuffix(filename, ".mts"): return api.LoaderTS case strings.HasSuffix(filename, ".json"): return api.LoaderJSON default: // 没有扩展名或是 .js 时按 TS 解析:TS 是 JS 的超集,普通 JS 照样能过, // 顺便让不带扩展名的脚本名也能写类型注解。 return api.LoaderTS } } // bundleError 把 esbuild 的报错整理成一条带位置和出路的错误。 func bundleError(filename string, errs []api.Message, resolveDir string) error { var b strings.Builder fmt.Fprintf(&b, "打包 %s 失败", filename) for i, e := range errs { if i >= 5 { fmt.Fprintf(&b, "\n …还有 %d 条错误", len(errs)-i) break } b.WriteString("\n ") if loc := e.Location; loc != nil { fmt.Fprintf(&b, "%s:%d:%d: ", loc.File, loc.Line, loc.Column) } b.WriteString(e.Text) // 最常见的坑:单段源码里写了 import 却没有解析基准目录 if resolveDir == "" && strings.Contains(e.Text, "Could not resolve") { b.WriteString("(这段源码没有解析 import 的基准目录:" + "用 jscriptx/esm 子包按目录加载,或给 Compile 配 WithResolveDir)") } } return fmt.Errorf("%s", b.String()) }