万字 esbuild 源代码批判,让你能够直接 PR

2,582 阅读29分钟

康德的门徒可能已经知道,“批判”在这里的含义是进行深入的考察和分析。这一点在康德的三大著作《纯粹理性批判》、《实践理性批判》和《判断力批判》中得到了深入的体现。

序言

众所周知,Vite 使用 esbuild 来进行依赖的预构建。esbuild 是用 Go 编写的构建工具,其性能是使用 JavaScript 编写的打包器的10到100倍。

image.png

最近在使用 Vite 时,遇到一个与 esbuild 相关的问题,在阅读 esbuild 代码和 debug 的时候,由于不了解 esbuild 源码的整体结构,被搞晕了。故梳理此文,分析 esbuild 代码的整体结构,便于遇到问题时定位代码位置。

目录结构

esbuild 项目的目录结构非常清晰和有组织,下面列举的是一些比较重要的目录。请注意,esbuild 的核心代码位于"internal"目录下,其中的每个子目录都对应一个与其同名的 Go 包(package)。

  • internal 核心代码
    • ast 定义了与模块导入相关的数据结构,在 JavaScript 和 CSS 模块间共用
    • bundler 实现 Build 和 Transform API 的核心
    • cache 模块解析结果的缓存
    • css_ast CSS AST
    • css_lexer CSS 词法解析器
    • css_parser CSS 语法解析器
    • css_printer CSS 代码生成器
    • fs 文件系统 API
    • graph 模块依赖图
    • js_ast JavaScript AST
    • js_lexer JavaScript 词法解析器
    • js_parser JavaScript 语法解析器
    • js_printer JavaScript 代码生成器
    • linker 通过模块依赖图生成输出文件
    • logger 提供日志记录功能
    • resolver 模块路径解析器
  • pkg 公开的 API
    • api Go API
    • cli 命令行 API
  • Makefile 开发工作流程围绕 Makefile 开展,将其用作脚本运行器

在 Go 项目中,"internal"是一个特殊的目录名。它提供了一种机制,该机制规定,定义在"internal"目录下的包,只能被"internal"的父目录(或其子目录)下的文件导入。这是Go的一种包的封装策略,用于防止包的不适当使用。

从 Build 方法开始

在 esbuild 对外提供的所有 API 中,Build 方法是最重要的,它接收一个或多个入口文件以及选项来进行打包,最终将结果写回文件系统。该方法通过 pkg/api 包提供,下面是一个简单的使用示例:

package main

import "github.com/evanw/esbuild/pkg/api"
import "os"

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outdir:      "dist",
  })
  if len(result.Errors) != 0 {
    os.Exit(1)
  }
}

Build 方法内部处理流程如下,仅考虑处理 JavaScript 的情况:

image.png

bundler 包 - 整体流程

bundler 是实现 Build API 的核心。它包含两个阶段,第一个阶段是 ScanBundle 方法,扫描获取模块依赖图。第二个阶段是 Compile 方法,通过模块依赖图生成输出文件。

ScanBundle 方法

该方法从入口文件开始扫描,对每个模块都调用 js_parser 包的 Parse 方法进行语法分析获得 AST,再通过分析其中的导入导出语句构建整体的模块依赖图,最终返回一个 Bundle 实例。

Bundle 结构体

image.png

type Bundle struct {
    // 生成的随机字符串,保证每次 bundle 时都是唯一的。
    // 在 link 期间分配给每个 chunk,用作唯一键的前缀。
    // 在计算最终输出路径之前,用这些唯一键来识别每个 chunk。
    uniqueKeyPrefix string

    // 与文件系统进行交互的方法。
    fs          fs.FS
    // 解析 import 的路径为模块标识符
    res         *resolver.Resolver
    // 从入口文件开始扫描,触达到的所有模块文件,包含入口文件
    files       []scannerFile
    // 记录入口文件
    entryPoints []graph.EntryPoint
    // 编译配置
    options     config.Options
}

scannerFile 结构体

type scannerFile struct {
    // 当传入 Build 方法的配置 Metafile 为 true 时,创建 JSON 格式的元数据文件。
    jsonMetadataChunk string

    // 此属性用于插件,可由加载此文件的 on-load 回调设置,传递给文件的 on-resolve 回调使用。
    pluginData interface{}
    // 输入文件。
    inputFile  graph.InputFile
}

InputFile 结构体

type InputFile struct {
    // 根据文件类型的不同,分为 JSRepr、CSSRepr 和 CopyRepr。
    Repr           InputFileRepr
    // SourceMap
    InputSourceMap *sourcemap.SourceMap

    // 给 file 和 copy loader 使用。
    AdditionalFiles            []OutputFile
    UniqueKeyForAdditionalFile string

    SideEffects SideEffects
    Source      logger.Source
    Loader      config.Loader
}

JSRepr 结构体

JSRepr 实现了 InputFileRepr 接口,存储 JavaScript 文件相关的数据。实现了 InputFileRepr 接口的还有 CSSReprCopyRepr

type JSRepr struct {
    // 用于 linker 的元数据
    Meta JSReprMeta
    // 抽象语法树
    AST  js_ast.AST

    // 在一个 JavaScript 导入一个 CSS 文件时,会生成一个 JSRepr 实例来代替该这个 CSS 文件,
    // 确保在后序处理中 JavaScript 文件中导入的都是 JavaScript 文件。
    // 当这个属性存在时,表示当前的这个 JSRepr 承担这个职责。
    CSSSourceIndex ast.Index32
}

// 实现 InputFileRepr 接口中的方法,获取文件中的导入信息。
func (repr *JSRepr) ImportRecords() *[]ast.ImportRecord

ImportRecord 结构体

type ImportRecord struct {
    Assertions *ImportAssertions
    Path       logger.Path
    Range      logger.Range

    // 用于异常报告。
    ErrorHandlerLoc logger.Loc

    // 导入的模块在 Bundle 实例的 files 属性中的索引。
    // 若该属性 isValid() 为 false 时,表示为外部(external)导入,不包含在 Bundle 中。
    SourceIndex Index32

    // 通过 copy loader 导入的文件使用这个属性而不非 `SourceIndex`,
    // 因为它们有点像外部导入,不包含在 Bundle 中。
    CopySourceIndex Index32

    Flags ImportRecordFlags
    Kind  ImportKind
}

EntryPoint 结构体

type EntryPoint struct {
    // 该属性的值可能是绝对路径也可以是相对路径。
    // 如果是绝对路径,则计算相对于 outbase 目录的路径,将其转换为相对路径。
    // 然后,这个相对路径将被连接到 outdir 目录,以生成这个入口文件的最终输出路径。
    OutputPath string

    // 这是入口文件的数组索引。
    SourceIndex uint32

    // 当未设置输出路径时,该属性设置为 true,将自动生成输出路径。
    OutputPathWasAutoGenerated bool
}

模块依赖图

Bundle 结构体的 files 中存储所有的 JavaScript 文件的信息,entryPoints 中通过数组索引记录了 files 中哪些 JavaScript 文件为入口文件,这就是模块依赖图的入口。

files 数组中的每一项都可以通过调用 file.inputFile.Repr.ImportRecords() 方法来得到对应 JavaScript 文件中的所有导入,这就是模块依赖图中各模块间的导入关系。

该方法返回的数组中的每一项都表示一个导入,导入的文件也是通过数组索引的形式来记录,表示该文件位于 files 数组中位置,这让我们能够递归地触达到所有文件。

image.png

Compile 方法

该方法通过上述的模块依赖图生成最终的输出文件。

方法中先调用 findReachableFiles 方法来遍历模块依赖图,获得模块依赖图的后序遍历列表。这一步是为了获得一个确定性的所有 JavaScript 文件的序列,因为 ScanBundle 方法中对所有文件的解析都是并行的,所以 files 属性中文件的顺序是不确定的。

接着调用 computeDataForSourceMapsInParallel 方法成各模块的 SourceMap,这与链接过程并行,因为链接过程基本是串行的,有额外的资源用于并行。

最后调用 linker 包的 Link 方法,传入模块依赖图、模块依赖图的后序遍历列表和 SourceMap 任务列表:

link(
    &options,
    timer,
    log,
    b.fs,
    b.res,

    // 模块依赖图
    files,
    b.entryPoints,
    
    b.uniqueKeyPrefix,
    
    // 模块依赖图的后序遍历列表
    allReachableFiles,
    // 所有文件的 SourceMap 任务
    dataForSourceMaps
)

至此 esbuild 的 Build 方法的整个生命周期结束。

js_parser 包 - 解析 JavaScript

ScanBundle 方法方法中会调用 js_parser 包的 Parse 方法对 JavaScript 文件进行语法解析,获取 AST。

func Parse(log logger.Log, source logger.Source, options Options) (result js_ast.AST, ok bool)

JavaScript 的语法分析器,处理过程分为两个阶段:

  1. 将源代码解析为 AST,创建作用域树,创建符号表。
  2. 遍历 AST,将声明的标识符记录到 declaredSymbols 属性中,进行常量折叠(constant folding),替换编译时变量,并根据配置适当地对某些语法进行降级。

由于 esbuild 希望最小化全量 AST 的传递次数以提高性能,所以在这么少的传递中处理了很多东西。然而,处理变量提升至少需要两个单独的阶段。

bundler 包会调用 js_parser 包中的 Parse 方法来解析 JavaScript 模块,该方法返回模块的 AST。

AST 结构体

这里只关注 JavaScript 的 AST 结构体,它被定义在 js_ast 包中。esbuild 中还定义了 CSS 的 AST 结构体,它被定义在 css_ast 包中。

image.png

type AST struct {
    // 模块原始信息。
    ModuleTypeData ModuleTypeData
    // 抽象语法树。
    Parts          []Part
    // 符号表
    Symbols        []Symbol
    ExprComments   map[logger.Loc][]string
    // 作用域树
    ModuleScope    *Scope
    CharFreq       *CharFreq

    // 内部数据,用于实现 Yarn PnP
    ManifestForYarnPnP Expr

    Hashbang  string
    Directive string
    URLForCSS string
    
    TopLevelSymbolToPartsFromParser map[Ref][]uint32

    // 包含所有顶级导出的 TypeScript 枚举常量。
    // 用于枚举常量跨模块内联。
    TSEnums map[Ref]map[string]TSEnumValue

    // 它包含所有检测到的可内联常量的值。
    // 用于常量跨模块内联。
    ConstValues map[Ref]ConstValue

    // 这里的属性表示为符号,而非字符串,允许重命名为更短的名字。
    MangledProps map[string]Ref

    ReservedProps map[string]bool

    // 导入语句。
    ImportRecords []ast.ImportRecord

    // 用于 bundle 阶段。
    NamedImports            map[Ref]NamedImport
    NamedExports            map[string]NamedExport
    ExportStarImportRecords []uint32

    SourceMapComment logger.Span

    // ESM 特性。
    // 这些属性是 Range 类型,而非布尔值,可被用于日志信息。使用 `Len > 0` 来进行检查。
    ExportKeyword            logger.Range // 不包含 TypeScript 特有语法
    TopLevelAwaitKeyword     logger.Range
    LiveTopLevelAwaitKeyword logger.Range // 不包含位不会被执行(dead branch)的顶级 await

    ExportsRef Ref
    ModuleRef  Ref
    WrapperRef Ref

    ApproximateLineCount  int32
    NestedScopeSlotCounts SlotCounts
    HasLazyExport         bool

    // CommonJS 特性。
    // 当一个文件使用 CommonJS,它无法展开,必须包装在自己的闭包中。
    // 注意 CommonJS 能够使用顶级 `return`,解析器会检查,不用在此进行标记。
    UsesExportsRef bool
    UsesModuleRef  bool
    ExportsKind    ExportsKind
}

AST 中所有的标识符都通过 Ref 访问,Ref 中包含两个数组索引。一个是 InnerIndex 指向 AST 中的符号表。另一个是 SourceIndex,用于链接阶段时合并所有符号表时使用。

type Ref struct {
    SourceIndex uint32
    InnerIndex  uint32
}

符号表中存储 AST 中的顶级符号,这样可以在不遍历树的情况下获得它们。例如,可以在不遍历 AST 的情况下,通过遍历符号表来对标识符进行重命名。

image.png

AST 是不可变数据。这使 watch 模式下的增量编译变得容易,可以避免重新解析已解析的文件。任何在 AST 解析后对其进行的操作都应该创建变更部分的副本,而不是直接更改原始节点。

第一阶段

语法分析

Parse 方法中先构造词法分析器,然后依赖词法分析器构造语法分析器:

p := newParser(log, source, js_lexer.NewLexer(log, source, options.ts), &options)

之后调用语法分析器的 parseStmtsUpTo 方法对文件进行第一阶段的处理,该阶段不绑定符号:

stmts := p.parseStmtsUpTo(js_lexer.TEndOfFile, parseStmtOpts{
    isModuleScope:          true,
    allowDirectivePrologue: true,
})

parseStmtsUpTo 方法中使用经典递归下降分析,最终方法返回 Stmt 数组,Stmt 结构体表示一个 JavaScript 语句:

type Stmt struct {
    Data S
    Loc  logger.Loc
}

其中的 Data 属性,其类型为 S,是一个接口:

type S interface{ isStmt() }

接口中定义的 isStmt 方法永远不会被调用,它仅用于 Go 的类型系统,下面是部分实现了 S 接口的 JavaScript 语句:

// 严格模式指令 "use strict";
type SDirective struct {
    Value          []uint16
    LegacyOctalLoc logger.Loc
}

// TypeScript 类型
type STypeScript struct{}

// export {foo}
type SExportClause struct {
    Items        []ClauseItem
    IsSingleLine bool
}

// export {foo} from 'path'
type SExportFrom struct {
    Items             []ClauseItem
    NamespaceRef      Ref
    ImportRecordIndex uint32
    IsSingleLine      bool
}

// export default ...
type SExportDefault struct {
    Value       Stmt // 可以是一个 SExpr、SFunction 或 SClass
    DefaultName LocRef
}

// export * from 'path'
// export * as ns from 'path'
type SExportStar struct {
    Alias             *ExportStarAlias
    NamespaceRef      Ref
    ImportRecordIndex uint32
}

// TypeScript export = value;
type SExportEquals struct {
    Value Expr
}

// 将使用 "module.exports" 还是 "export default" 导出表达式的决定推迟到链接阶段
type SLazyExport struct {
    Value Expr
}

type SExpr struct {
    Value Expr

    // 对自动生成的表达式设置为 true,表明不影响 tree shaking。 
    // 例如,调用 esbuild 运行时中无副作用的函数时。
    DoesNotAffectTreeShaking bool
}

// TypeScript 枚举
type SEnum struct {
    Values   []EnumValue
    Name     LocRef
    Arg      Ref
    IsExport bool
}

// TypeScript 命名空间
type SNamespace struct {
    Stmts    []Stmt
    Name     LocRef
    Arg      Ref
    IsExport bool
}

// 方法
type SFunction struct {
    Fn       Fn
    IsExport bool
}

// 类
type SClass struct {
	Class    Class
	IsExport bool
}

在语法分析过程中,若分析到在全局声明的符号时会调用 newSymbol 方法在符号表中分配新的符号,符号通过 SymbolKind 枚举被分类为:

type SymbolKind uint8

const (
    // unbound symbol 是指不在引用它文件中声明的符号,例如 window。
    SymbolUnbound SymbolKind = iota

    // 这种符号具有特殊的覆盖行为。
    // 你可以在同一作用域内多次重新声明这些符号。
    // 这些符号会从其声明的作用域中移到最近的函数或模块作用域。
    // 此类符号包含:
    //
    // - 函数参数
    // - 函数声明语句
    // - 使用 var 声明的变量
    SymbolHoisted
    SymbolHoistedFunction

    // 这是一种特殊的情况,即在 catch 中使用 var 声明的标识符,会阻塞变量的提升:
    // var e = 0;
    // try { throw 1 } catch (e) {
    //   print(e) // 1
    //   var e = 2
    //   print(e) // 2
    // }
    // print(e) // 0(因为 catch 中声明的 e 提升到 catch 作用域就停止了)
    SymbolCatchIdentifier

    // 生成器和异步函数不能提升,但仍然具有特殊的行为,例如可以用相同的名称覆盖以前的函数。
    SymbolGeneratorOrAsyncFunction

    // 这是函数内部的特殊 arguments 变量
    SymbolArguments

    // 类可以覆盖 TypeScript 命名空间。
    SymbolClass

    // 类的私有标识符(如 "#foo")。
    SymbolPrivateField
    SymbolPrivateMethod
    SymbolPrivateGet
    SymbolPrivateSet
    SymbolPrivateGetSetPair
    SymbolPrivateStaticField
    SymbolPrivateStaticMethod
    SymbolPrivateStaticGet
    SymbolPrivateStaticSet
    SymbolPrivateStaticGetSetPair

    // 标记语句中的符号
    SymbolLabel

    // TypeScript 枚举可以覆盖 TypeScript 命令空间和其他 TypeScript 枚举。
    SymbolTSEnum

    // TypeScript 命名空间能够覆盖类、方法、TypeScript 枚举和其他 TypeScript 命名空间。
    SymbolTSNamespace

    // 在 TypeScript 中,允许导入的符号与文件内符号命名冲突。因为导入的符号可能仅为类型:
    //
    // import {Foo} from 'bar'
    // class Foo {}
    SymbolImport

    // 赋值给 const 符号将在运行时抛出 TypeError。
    SymbolConst

    // 注入符号可以被用户提供的定义内联。
    SymbolInjected

    // 属性符号可以被重命名为更短的名字。
    SymbolMangledProp

    // 所有其他没有特殊行为的符号。
    SymbolOther
)

作用域

语法分析器执行两次传递,我们需要将作用域树信息从第一次传递到第二次传递。这是通过在 scopeInOrder 的第一次传递过程中跟踪对 pushScopeForParsePass()popScope() 的调用来完成的。

然后,当第二个过程调用 pushScopeForVisitPass()popScop() 时,我们使用 scopeInOrder 中的条目,并确保它们的顺序相同。这样,第二遍可以有效地使用与第一遍相同的作用域树,而不必将作用域树附加到AST。

我们需要将其分为两个过程,因为声明符号的过程必须与将标识符绑定到声明符号以处理在嵌套作用域中声明挂起的var 符号以及在父作用域或同级作用域中为其绑定名称的过程分开。

第二阶段

esbuild 的作者在最初开发时并没有第二阶段,但是事实证明,在处理箭头函数时存在语法二义性,仅通过一次 AST 处理很难正确地做到这一点。

第二阶段遍历第一阶段获得的 Stmt 数组,在遍历过程中将数组中的 Stmt 拆分到不同的 Part 中。之所以引入 Part 结构体,是为了便于 three shaking,three shaking 通过丢弃 Part 来实现。

若未开启 three shaking,会将所有语句放到一个 Part 中,下面讨论的都是开启了 three shaking 的情况。

Part 结构体

image.png

type Part struct {
    // 拆分到该 Part 中的语句。
    Stmts  []Stmt
    // 作用域树。
    Scopes []*Scope

    // 文件级别导入记录列表的索引。
    ImportRecordIndices []uint32

    // 在该 Part 实例中声明的所有符号。
    // 注意,一个符号可能有多个声明,因此可能被声明为多个 Part 中。
    // 例如,多个具有相同名称的 var 声明。
    // 还要注意,此列表未进行去重,可能包含重复项。
    DeclaredSymbols []DeclaredSymbol

    // 该 Part 实例中使用的所有符号的使用次数的估计值。
    SymbolUses map[Ref]SymbolUse

    // 该 Part 实例中用作函数调用目标的所有符号的使用次数的估计值。
    SymbolCallUses map[Ref]SymbolCallUse

    // 用于标记导入符号的使用。在解析过程中,我们不知道导入的符号是否是内联枚举值。这仅在链接期间知道。
    // 因此,推迟添加对这些导入符号的依赖,直到知道它们是否为内联枚举值。
    ImportSymbolPropertyUses map[Ref]map[string]SymbolUse

    // 包含与本 Part 实例有关的,同一文件下其他 Part 实例的索引。
    Dependencies []Dependency

    // 如果为 true,外部模块未使用本 Part 实例中声明的符号,就可以删除本 Part 实例。
    // 导入一个文件时,必须包含所有此标志为 false 的所有 Part 实例。
    CanBeRemovedIfUnused bool

    // 强制进行 tree shaking。
    // 即使全局未开启 tree shaking,也会对这个 Part 实例进行 tree shaking。
    ForceTreeShaking bool

    // 如果该文件已被树抖动算法标记为活跃,则为 true。
    IsLive bool
}

Part 生成逻辑

在遍历 Stmt 生成 Part 的过程中,若顶级声明语句中包含多个变量,会根据变量拆分为多个 Parts

其他情况下,会为每个语句创建一个 Part,最终的 Part 数组会根据语句类型调整排序:

  1. 移动 import 语句(或 import-like 的 exports)到最顶部,确保它们被转换为 require() 时,导入操作在其他语句之前发生。
  2. 移动 TypeScript 的 export = value; 语句到最末尾,确保它们被转换为 module.exports = value; 时,其他语句在其之前。

image.png

记录标识符

在遍历树的过程中,会将声明的变量标识符、导入语句引入的变量标识符等,通过 recordDeclaredSymbol 方法记录到 declaredSymbols 属性中,它用于链接阶段:

func (p *parser) recordDeclaredSymbol(ref js_ast.Ref) {
    p.declaredSymbols = append(p.declaredSymbols, js_ast.DeclaredSymbol{
        Ref:        ref,
        IsTopLevel: p.currentScope == p.moduleScope,
    })
}

还会统计符号被使用的次数:

func (p *parser) recordUsage(ref js_ast.Ref) {
    // 存储符号的使用计数,用于在代码压缩期间生成符号名称。
    // 这些计数不应包括死代码区域内的引用,因为这些引用将被剔除。
    if !p.isControlFlowDead {
        p.symbols[ref.InnerIndex].UseCountEstimate++
        use := p.symbolUses[ref]
        use.CountEstimate++
        p.symbolUses[ref] = use
    }
}

常量折叠(constant folding)

常量折叠相关的方法都放置在 js_ast_helpers.go 文件中,包含折叠布尔类型的 ToBooleanWithSideEffects 方法:

expectPrinted(t, "x = !false", "x = true;\n")
expectPrinted(t, "x = !true", "x = false;\n")

折叠字符串的 FoldStringAddition 方法:

expectPrinted(t, "x = 'string' + `template`", "x = `stringtemplate`;\n")
expectPrinted(t, "x = 'string' + `a${foo}b`", "x = `stringa${foo}b`;\n")

替换编译时变量

用户可以通过 Build 方法传入 Define 配置,用于替换全局标识符或常量表达式。

package main

import "fmt"
import "github.com/evanw/esbuild/pkg/api"

func main() {
  js := "hooks = DEBUG && require('hooks')"

  result := api.Transform(js, api.TransformOptions{
    Define: map[string]string{"DEBUG": "true"},
  })
}

用户传入的 Define 配置会被处理为 ProcessedDefines 类型的实例,其定义如下:

type ProcessedDefines struct {
    IdentifierDefines map[string]DefineData
    DotDefines        map[string][]DotDefine
}

其中 IdentifierDefines 中记录标识符,当遍历到 EIdentifier 标识符类型的节点时,会用用户指定的定义替换未绑定或注入的符号。

DotDefines 中记录常量表达式,当遍历到 EDot 属性访问表达式类型的节点时,会用用户指定的定义替换未绑定或注入的符号。

根据配置的语言目标适当降级某些语法结构

在第二阶段还会根据配置的语言目标适当降级某些语法结构,相关的方法实现在 js_parser/js_parser_lower.go 文件中。例如遍历过程中发现了可选链语法,就会调用 lowerOptionalChain 方法来进行语法降级处理。

js_lexer 包 - 词法分析器

JavaScript 的词法分析器,将源文件转换为单词序列。与许多编译器不同,esbuild 不会在语法分析器启动之前就运行词法分析器。相反,在解析文件时,词法分析器被语法分析器重复调用。这是因为许多单词的解析是依赖上下文的,需要依赖语法分析器中的信息,例如正则表达式字面量和 JSX。

语法解析器依赖词法解析器,其基本用法如下:

// 传入源码文本,实例化一个词法解析器
lexer := NewLexer(log, test.SourceForTest(contents), config.TSOptions{})

// 解析下一个单词
lexer.Next()

// 获取当前解析的单词
lexer.Token

词法解析器的具体结构如下:

type Lexer struct {
    // 注释相关
    LegalCommentsBeforeToken     []logger.Range
    CommentsBeforeToken          []logger.Range
    AllComments                  []logger.Range
    // 标识符
    Identifier                   MaybeSubstring
    log                          logger.Log
    source                       logger.Source
    JSXFactoryPragmaComment      logger.Span
    JSXFragmentPragmaComment     logger.Span
    JSXRuntimePragmaComment      logger.Span
    JSXImportSourcePragmaComment logger.Span
    SourceMappingURL             logger.Span
    BadArrowInTSXSuggestion      string

    // 字符串文本中的转义序列被延迟解码,
    // 因为它们不会在有标记的字符串模板中解码,并且有标记的字符串模板可能包含无效的转义序列。
    // 如果解码数组为 nil,则应首先将编码值传递给 tryToDecodeEscapeSequences。
    decodedStringLiteralOrNil []uint16
    encodedStringLiteralText  string

    errorSuffix string
    tracker     logger.LineColumnTracker

    encodedStringLiteralStart int

    // 单词为 TNumericLiteral 时的值
    Number                          float64
    current                         int
    start                           int
    end                             int
    ApproximateNewlineCount         int
    // 尝试为错误地在 TSX 中使用箭头函数的人提供更好的错误消息
    CouldBeBadArrowInTSX            int
    BadArrowInTSXRange              logger.Range
    LegacyOctalLoc                  logger.Loc
    AwaitKeywordLoc                 logger.Loc
    FnOrArrowStartLoc               logger.Loc
    PreviousBackslashQuoteInJSX     logger.Range
    LegacyHTMLCommentRange          logger.Range
    codePoint                       rune
    prevErrorLoc                    logger.Loc
    json                            JSONFlavor
    // 单词类型
    Token                           T
    ts                              config.TSOptions
    // 单词前是否有换行
    HasNewlineBefore                bool
    HasPureCommentBefore            bool
    IsLegacyOctalLiteral            bool
    // 为 await 缺少 async 时提供友好的错误信息
    PrevTokenWasAwaitKeyword        bool
    rescanCloseBraceAsTemplateToken bool
    forGlobalName                   bool

    // 在回溯的推测扫描期间禁用日志
    IsLogDisabled bool
}

linker 包 - 链接过程

linker 包通过 Link 方法对外提供功能,接收模块依赖图返回输出文件。模块依赖图指的就是 bundler 包中提到的 entryPointsfiles,而输出文件是一个 OutputFile 类型的数组,OutputFile 类型的定义如下:

type OutputFile struct {
    JSONMetadataChunk string

    // 输出文件的绝对路径
    AbsPath      string
    // 输出文件中的内容
    Contents     []byte
    // 文件权限是否为可执行文件
    IsExecutable bool
}

JSReprMeta 结构体

这包含与绑定器的初始扫描阶段的“文件”结构相对应的链接器特定元数据。它被分离出来,因为它在概念上仅用于单个链接操作,并且因为多个链接操作可能与同一文件的不同元数据并行发生。

type JSReprMeta struct {
    // 仅用于 TypeScript 文件。如果导入符号在这个 map 中,意味着没有找到这个导入模块。
    // 这在 TypeScript 中不是错误,因为导入的可能是一个类型。
    //
    // 通常,我们在解析期间删除 TypeScript 文件中所有未使用的导入,这会自动删除仅类型的导入。
    // 但在某些再出口情况下,无法判断进口是否为某种类型:
    //
    //   import {typeOrNotTypeWhoKnows} from 'path';
    //   export {typeOrNotTypeWhoKnows};
    //
    // 应该设置 TypeScript 的 isolatedModules 标志来使用这样的 bundler。
    // 这样的 bundler 可以独立编译 TypeScript 文件,而无需进行类型检查。
    IsProbablyTypeScriptType map[js_ast.Ref]bool

    // 当匹配的导出实际绑定到导入时,导入与导出在一个单独的通道中匹配。
    // 这里的“绑定”意味着将导出文件中声明导出符号的部件的非本地依赖项添加到导入文件中使用导入符号的所有部件。
    //
    // 由于上面所说的导入的可能是 TypeScript 类型,这必须是一个单独的传递。在将导入与导出匹配之前,我们无法生成导出命名空间的部分,因为生成的代码必须在导出命名空间代码中省略仅类型导入。在导出命名空间的部分生成之前,我们无法将导出绑定到导入,因为该部分需要参与绑定。
    //
    // 该数组存储要绑定的延迟导入,因此可以将传递分成两个单独的传递。
    ImportsToBind map[js_ast.Ref]ImportData

    // 这里包含命名导出和重导出。
    //
    // 命名导出来源于源文件中显式的导出语句,从 AST 的 NamedExports 字段中拷贝得来。
    //
    // 重导出来自其他文件,是解析 export * 语句后的结果。
    ResolvedExports    map[string]ExportData
    ResolvedExportStar *ExportData

    // 切勿直接迭代 EesolvedExports,迭代这个数组。
    // 一些导出最终不会生成代码,该数组排除了这些导出。
    // 并且进行了排序,避免了由于随机迭代顺序而导致的不确定性。
    SortedAndFilteredExportAliases []string

    // 合并 AST 中的 DeclaredSymbols 维护顶级符号与 Part 间的映射。
    // 你应该调用 TopLevelSymbolToParts 来访问它而不是直接访问它。
    TopLevelSymbolToPartsOverlay map[js_ast.Ref][]uint32

    // 如果这是一个入口文件,则该数组包含对 SortedAndFilteredExportAliases 中每个条目的一个空闲临时符号的引用。
    // 这些可能需要在 ESM 中存储 CommonJS 重新导出的副本。
    CJSExportCopies []js_ast.Ref

    // 表示生成的 CommonJS 或 ESM 包装器所在 Part 的索引。
    // 这些 Part 是空的,用于 tree shaking 和代码拆分。
    WrapperPartIndex ast.Index32

    EntryPointPartIndex ast.Index32

    // 为 true 时,表示该文件被顶级 await 影响,
    // 无论是在此文件中存在顶级 await,还是导入其他被顶级 await 影响的文件。
    // 禁止通过 "require()" 导入这些文件,因为它们是异步求值的。
    IsAsyncOrHasAsyncDependency bool

    Wrap WrapKind

    // 如果为 true,需要插入 "var exports = {};"。
    NeedsExportsVariable bool

    // 如果为 true,将强制包含 "__export(exports, { ... })" 语句,即使没有任何部分引用 exports。
    // 它被用于入口文件,当配置的输出格式为 CommonJS 且有导出语句时,此时生成的代码需要引用 exports 变量。
    ForceIncludeExportsForEntryPoint bool

    // 当需要引入 runtime 的 __export 符号时,将此标志设置为 true,将其放到 NSExportPartIndex 指向的 Part 中。
    // 这不能在 createExportsForFile 方法中完成,因为会有并发危险,它必须在之后完成。
    NeedsExportSymbolFromRuntime bool

    // 文件已经保包装它的依赖。
    DidWrapDependencies bool
}
type WrapKind uint8

const (
    WrapNone WrapKind = iota

    // 模块将被包装为 CommonJS:
    //
    //   // foo.ts
    //   let require_foo = __commonJS((exports, module) => {
    //     exports.foo = 123;
    //   });
    //
    //   // bar.ts
    //   let foo = flag ? require_foo() : null;
    //
    WrapCJS

    // 模块将被包装为 ESM:
    //
    //   // foo.ts
    //   var foo, foo_exports = {};
    //   __export(foo_exports, {
    //     foo: () => foo
    //   });
    //   let init_foo = __esm(() => {
    //     foo = 123;
    //   });
    //
    //   // bar.ts
    //   let foo = flag ? (init_foo(), __toCommonJS(foo_exports)) : null;
    //
    WrapESM
)

第一步,从模块依赖图克隆得到 LinkerGraph

Link 方法会先调用 CloneLinkerGraph 方法,对输入的模块依赖图进行浅克隆,预先克隆了它可能修改的 AST 字段,来获得一个新的依赖图以用于链接阶段:

type LinkerGraph struct {
    Files       []LinkerFile
    entryPoints []EntryPoint
    Symbols     js_ast.SymbolMap

    // 用于 TypeScript 枚举常量的跨模块内联。
    TSEnums map[js_ast.Ref]map[string]js_ast.TSEnumValue

    // 用于检测到的可内联常量的跨模块内联。
    ConstValues map[js_ast.Ref]js_ast.ConstValue

    // 如果需要在链接操作中迭代所有文件,请迭代此数组。该数组也按确定性排序,以帮助确保确定性构建。
    ReachableFiles []uint32

    StableSourceIndices []uint32
}

该图中的 Files 属性的类型为 LinkerFile,其中除了包含模块依赖图中的 InputFile,还增加了一些别的属性用于模块间的链接:

type LinkerFile struct {
    // 保存可以访问此文件的所有入口点,它用于将此文件中的 Part 分配给 chunk。
    EntryBits helpers.BitSet

    lazyLineColumnTracker *logger.LineColumnTracker

    InputFile InputFile

    // 从该文件的入口点获取的模块图中的最小链接数。
    DistanceFromEntryPoint uint32

    EntryPointChunkIndex uint32

    entryPointKind entryPointKind

    // 如果该文件已被 tree shaking 算法标记为活动,则为 true。
    IsLive bool
}

CloneLinkerGraph 方法的流程如下:

image.png

扫描 import 和 export 语句

第一步,找到哪些模块必须转换为 CommonJS

该过程主要解决 ESM 和 CommonJS 的互操作方面的问题,esbuild 在 runtime 包中定义了许多 JavaScript 方法,例如 __toCommonJS 方法:

// 将 ESM 转换为 CommonJS。
// 该方法将克隆输入的模块对象,并设置一个值为 true 的不可枚举的 __esModule 属性。
export var __toCommonJS = mod => __copyProps(__defProp({}, '__esModule', { value: true }), mod)

当前模块若使用 require() 方法导入一个模块时,这个导入的模块会被增加一个标记,以此在代码生成阶段使用 __toCommonJS 方法包装该模块的导出对象。下面是一个例子:

// math.js
export function add(a, b) {
  return a + b;
}

// app.js
import { add } from './math'

console.log(add(1, 2))

转换结果:

// math.js
var math_exports = {};
__export(math_exports, {
    add: () => add
});
function add(a, b) {
    return a + b;
}
var init_math = __esm({
    "math.js"() {
    }
});

// app.js
var math = (init_math(), __toCommonJS(math_exports));

console.log(math.add(1, 2))

第二步,绑定导入与导出

type matchImportResult struct {
    alias            string
    kind             matchImportKind
    namespaceRef     js_ast.Ref
    sourceIndex      uint32
    nameLoc          logger.Loc // Optional, goes with sourceIndex, ignore if zero
    otherSourceIndex uint32
    otherNameLoc     logger.Loc // Optional, goes with otherSourceIndex, ignore if zero
    ref              js_ast.Ref
}

计算 chunk

computeChunks 方法中将计算出最终要拆分成哪些 chunk,先来看一下 chunk 的类型 chunkInfo 的定义:

type chunkInfo struct {
    // 随机字符串,用于在计算最终输出路径之前表示 chunk 的输出路径。
    uniqueKey string

    filesWithPartsInChunk map[uint32]bool
    entryBits             helpers.BitSet

    // 用于代码拆分
    crossChunkImports []chunkImport

    chunkRepr chunkRepr

    // 这是 chunk 相对于输出目录的最终路径,但没有替换最终哈希(因为尚未计算)。
    finalTemplate []config.PathTemplate

    // 这是 chunk 相对于输出目录的最终路径,它将最终的 hash 替换为 finalTemplate。
    finalRelPath string

    externalLegalComments []byte

    // 只包含此 chunk 的哈希,而不包含其他 chunk 的哈希信息。
    // 稍后在链接过程中,将通过合并该块的所有可传递依赖项的隔离哈希来构建该块的最终哈希。
    // 这分为两个阶段,以处理模块依赖图中的循环。
    waitForIsolatedHash func() []byte

    jsonMetadataChunkCallback func(finalOutputSize int) helpers.Joiner
    outputSourceMap           sourcemap.SourceMapPieces

    intermediateOutput intermediateOutput

    // 这些信息仅用于 isEntryPoint 为 true 时
    entryPointBit uint   // 在 c.graph.EntryPoints 数组中的索引
    sourceIndex   uint32 // 在 c.sources 数组中的索引
    isEntryPoint  bool

    isExecutable bool
}

其中最重要的属性是 filesWithPartsInChunk,表明最终该 chunk 中包含哪些模块。

生成 chunk

ast 包

这里定义了与模块导入相关的数据结构,在 JavaScript 和 CSS 模块间共用,让 bundler 和 linker 在进行处理时无需区分模块格式(JavaScript 还是 CSS)。

ImportRecord 结构体

它记录导入语句有关的信息,我们在 bundle 包中提到过它:

type ImportRecord struct {
    Assertions *ImportAssertions
    Path       logger.Path
    Range      logger.Range

    // 用于异常报告。
    ErrorHandlerLoc logger.Loc

    // 导入的模块在 Bundle 实例的 files 属性中的索引。
    // 若该属性 isValid() 为 false 时,表示为外部(external)导入,不包含在 Bundle 中。
    SourceIndex Index32

    // 通过 copy loader 导入的文件使用这个属性而不非 `SourceIndex`,
    // 因为它们有点像外部导入,不包含在 Bundle 中。
    CopySourceIndex Index32

    Flags ImportRecordFlags
    Kind  ImportKind
}

ImportRecordFlags 枚举

Flags 字段的类型为 ImportRecordFlags,标记模块中的导入语句具有哪些特征:

type ImportRecordFlags uint16

const (
    // 有时解析器会创建一个 ImportRecord,并决定不需要它。
    // 例如,TypeScript 代码可能有导入语句,在分析整个文件后,这些语句仅为类型导入。
    IsUnused ImportRecordFlags = 1 << iota

    // 导入语句是否包含 `* as ns`。
    // 用于判断一个无导出的模块是否需要使用 CommonJS 包装器进行包装。
    ContainsImportStar

    // 导入语句是否导入 `default`。
    // `import x from` 或 `import {default as x} from`。
    ContainsDefaultAlias

    // 导入语句是否包含 `__esModule`。
    // `import {__esModule} from`。
    ContainsESModuleAlias

    // `export * from 'path'` 语句在运行时执行时,是否需要调用 `__reExport()` 方法。
    CallsRunTimeReExportFn

    // 告诉代码生成器使用 `__toESM(...)` 包装 `require()`
    WrapWithToESM

    // 告诉代码生成器使用 `__toCJS(...)` 包装 ESM 导出的对象
    WrapWithToCJS

    // 告诉代码生成器使用 `__require()` 代替 `require()`c
    CallRuntimeRequire

    // 导入语句为以下情况:
    //
    //   try { require('x') } catch { handle }
    //   try { await import('x') } catch { handle }
    //   try { require.resolve('x') } catch { handle }
    //   import('x').catch(handle)
    //   import('x').then(_, handle)
    //
    // 在这些情况下,当导入路径解析失败时不需要报错。
    HandlesImportErrors

    // 导入语句形如 `import 'file'`。
    WasOriginallyBareImport

    // 当导入语句不被使用时可被移除
    IsExternalWithoutSideEffects

    // 导入语句包含 `assert { type: 'json' }`
    AssertTypeJSON

    // CSS `@import` 一个空文件,应该被移除。
    WasLoadedWithEmptyLoader
)

Kind 字段

Kind 字段的类型为 ImportKind,表示当前导入语句的类型:

type ImportKind uint8

const (
    // 用户提供的入口文件
    ImportEntryPoint ImportKind = iota

    // ES6 的导入语句或重新导出语句
    ImportStmt

    // require()
    ImportRequire

    // import()
    ImportDynamic

    // require.resolve()
    ImportRequireResolve

    // CSS `@import` 规则
    ImportAt

    // 具有导入条件的 CSS `@import` 规则
    ImportAtConditional

    // CSS `url(...)` 属性值
    ImportURL
)

js_ast

该包定义 JavaScript 的 AST。

AST 中所有的标识符都通过 Ref 访问,Ref 是一个指针,指向模块的符号表。符号表中存储 AST 中的顶级字段,这样可以在不遍历树的情况下获得它们。例如,可以在不遍历 AST 的情况下,通过遍历符号表来对标识符进行重命名。

AST 数据是不可变的。这使得构建具有“监视”模式的增量编译器变得容易,可以避免重新解析已解析的文件。任何在AST解析后对其进行操作的过程都应该创建树的变异部分的副本,而不是对原始树进行变异。

type AST struct {
    ModuleTypeData ModuleTypeData
    // 代码的抽象语法树
    Parts          []Part
    // 符号表
    Symbols        []Symbol
    ExprComments   map[logger.Loc][]string
    // 作用域树
    ModuleScope    *Scope
    CharFreq       *CharFreq

    // 内部数据,用于实现 Yarn PnP
    ManifestForYarnPnP Expr

    Hashbang  string
    Directive string
    URLForCSS string
    
    TopLevelSymbolToPartsFromParser map[Ref][]uint32

    // 包含所有顶级导出的 TypeScript 枚举常量。
    // 用于枚举常量跨模块内联。
    TSEnums map[Ref]map[string]TSEnumValue

    // 它包含所有检测到的可内联常量的值。
    // 用于常量跨模块内联。
    ConstValues map[Ref]ConstValue

    // 这里的属性表示为符号,而非字符串,允许重命名为更短的名字。
    MangledProps map[string]Ref

    ReservedProps map[string]bool

    // 导入语句。
    ImportRecords []ast.ImportRecord

    // 用于 bundle 阶段。
    NamedImports            map[Ref]NamedImport
    NamedExports            map[string]NamedExport
    ExportStarImportRecords []uint32

    SourceMapComment logger.Span

    // ESM 特性。
    // 这些属性是 Range 类型,而非布尔值,可被用于日志信息。使用 `Len > 0` 来进行检查。
    ExportKeyword            logger.Range // 不包含 TypeScript 特有语法
    TopLevelAwaitKeyword     logger.Range
    LiveTopLevelAwaitKeyword logger.Range // 不包含位不会被执行(dead branch)的顶级 await

    ExportsRef Ref
    ModuleRef  Ref
    WrapperRef Ref

    ApproximateLineCount  int32
    NestedScopeSlotCounts SlotCounts
    HasLazyExport         bool

    // CommonJS 特性。
    // 当一个文件使用 CommonJS,它无法展开,必须包装在自己的闭包中。
    // 注意 CommonJS 能够使用顶级 `return`,解析器会检查,不用在此进行标记。
    UsesExportsRef bool
    UsesModuleRef  bool
    ExportsKind    ExportsKind
}

graph

graph 存储 linker 处理的文件集合。每个 linker 都有一个独立的 graph(当启用代码拆分时只有一个 linker,当禁用代码拆分时每个入口都创建一个 linker)。

传入 linker 构造函数的输入数据必须是不可变的,因为它在不同的 linker 间共享,并且还存储在缓存中以用于增量构建。

linker 构造函数对输入数据进行浅克隆,并预先克隆了它可能修改的 AST 字段。Go 语言没有任何用于不变性的类型系统特性,因此必须手动执行。请谨慎。

js_printer - 代码生成

linker 包会调用 js_printer 包中的 Print 方法,通过遍历 AST 所有节点,根据节点类型打印对应的 JavaScript 代码:

js := Print(tree, symbols, r, printOptions)

在该阶段也会包含一些代码压缩的工作,例如打印数字时选择占用字节数量最小表示方式,代码在 printNonNegativeFloat 方法中,下面列出了一些转换示例。

精简指数表示:

"e+05" => "e5"
"e-05" => "e-5"

移除前导0:

"0.5" => ".5"

尝试使用指数表示:

"0.001" => "1e-3"
"1000" => "1e3"