约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。
engine.go 355 → 231 行。原来混了三件不相干的事
engine_globals.go ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
engine_console.go ← console.go,跟上面是同一主题
script.go 325 → 100 行,只留公开方法
script_vm.go ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
script_static.go ← static.go
errors.go 364 → 150 行
errors_goja.go ← goja 错误的翻译层
errors_hints.go ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
两回事;拆出来之后 missing_global_test.go 才有对应源文件
bundle_finalize.go ← esmwrap.go
bundle.go 收下 validIdent / hashVersion(原来住在 engine.go)
Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。
classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。
顺带修四处注释漂移:
Session/会话 代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
没有「会话」这个概念
Extension 文档示例写 Module() string,接口是 Module() (path, source string)。
这是唯一一段教人写扩展的文档,照抄编译不过
doc.go 的 freeze 说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
对象**的——扩展走的正是这条路,不该被当成隔离保证
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
package jscriptx
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"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())
|
||
}
|
||
|
||
// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。
|
||
func validIdent(s string) bool {
|
||
if s == "" {
|
||
return false
|
||
}
|
||
for i, r := range s {
|
||
switch {
|
||
case r == '_' || r == '$':
|
||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||
case r >= '0' && r <= '9':
|
||
if i == 0 {
|
||
return false
|
||
}
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func hashVersion(source string) string {
|
||
sum := sha256.Sum256([]byte(source))
|
||
return hex.EncodeToString(sum[:8])
|
||
}
|