// 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
}
动机
众所周知,Vite 使用 esbuild 预构建依赖。esbuild 使用 Go 编写,比 JavaScript 编写的打包器预构建依赖快 10-100 倍。
最近在使用 Vite 时,遇到一个与 esbuild 相关的问题,在阅读 esbuild 代码和 debug 的时候,由于不了解 esbuild 源码的整体结构,被搞晕了。故梳理此文,分析 esbuild 代码的整体结构,便于遇到问题时定位代码位置。
目录结构
esbuild 项目整体的目录结构组织非常好,下面列出一些较为重要的目录。请注意 esbuild 的核心代码位于 internal 目录下,其每个子目录都是一个与目录同名的 Go 包(package)。
"internal" 是 Go 项目中一个特殊的目录名称,它提供一种机制,定义在该目录下的包,只能被 "internal" 父目录下的文件导入。
从 Build 方法开始
在 esbuild 对外提供的所有 API 中,
Build
方法是最重要的,它接收一个或多个入口文件以及选项来进行打包,最终将结果写回文件系统。该方法通过 pkg/api 包提供,下面是一个简单的使用示例:Build
方法内部处理流程如下,仅考虑处理 JavaScript 的情况:bundler 包 - 整体流程
bundler 是实现
Build
API 的核心。它包含两个阶段,第一个阶段是ScanBundle
方法,扫描获取模块依赖图。第二个阶段是Compile
方法,通过模块依赖图生成输出文件。ScanBundle
方法该方法从入口文件开始扫描,对每个模块都调用 js_parser 包的
Parse
方法进行语法分析获得 AST,再通过分析其中的导入导出语句构建整体的模块依赖图,最终返回一个Bundle
实例。Bundle
结构体scannerFile
结构体InputFile
结构体JSRepr
结构体JSRepr
实现了InputFileRepr
接口,存储 JavaScript 文件相关的数据。实现了InputFileRepr
接口的还有CSSRepr
和CopyRepr
。ImportRecord
结构体EntryPoint
结构体模块依赖图
Bundle
结构体的files
中存储所有的 JavaScript 文件的信息,entryPoints
中通过数组索引记录了files
中哪些 JavaScript 文件为入口文件,这就是模块依赖图的入口。而
files
数组中的每一项都可以通过调用file.inputFile.Repr.ImportRecords()
方法来得到对应 JavaScript 文件中的所有导入,这就是模块依赖图中各模块间的导入关系。该方法返回的数组中的每一项都表示一个导入,导入的文件也是通过数组索引的形式来记录,表示该文件位于
files
数组中位置,这让我们能够递归地触达到所有文件。Compile 方法
该方法通过上述的模块依赖图生成最终的输出文件。
方法中先调用
findReachableFiles
方法来遍历模块依赖图,获得模块依赖图的后序遍历列表。这一步是为了获得一个确定性的所有 JavaScript 文件的序列,因为ScanBundle
方法中对所有文件的解析都是并行的,所以files
属性中文件的顺序是不确定的。接着调用
computeDataForSourceMapsInParallel
方法成各模块的 SourceMap,这与链接过程并行,因为链接过程基本是串行的,有额外的资源用于并行。最后调用 linker 包的
Link
方法,传入模块依赖图、模块依赖图的后序遍历列表和 SourceMap 任务列表:至此 esbuild 的
Build
方法的整个生命周期结束。js_parser 包 - 解析 JavaScript
在
ScanBundle
方法方法中会调用 js_parser 包的Parse
方法对 JavaScript 文件进行语法解析,获取 AST。JavaScript 的语法分析器,处理过程分为两个阶段:
declaredSymbols
属性中,进行常量折叠(constant folding),替换编译时变量,并根据配置适当地对某些语法进行降级。由于 esbuild 希望最小化全量 AST 的传递次数以提高性能,所以在这么少的传递中处理了很多东西。然而,处理变量提升至少需要两个单独的阶段。
bundler 包会调用 js_parser 包中的
Parse
方法来解析 JavaScript 模块,该方法返回模块的 AST。AST
结构体这里只关注 JavaScript 的
AST
结构体,它被定义在 js_ast 包中。esbuild 中还定义了 CSS 的AST
结构体,它被定义在 css_ast 包中。AST
中所有的标识符都通过Ref
访问,Ref
中包含两个数组索引。一个是InnerIndex
指向AST
中的符号表。另一个是SourceIndex
,用于链接阶段时合并所有符号表时使用。符号表中存储
AST
中的顶级符号,这样可以在不遍历树的情况下获得它们。例如,可以在不遍历AST
的情况下,通过遍历符号表来对标识符进行重命名。AST
是不可变数据。这使 watch 模式下的增量编译变得容易,可以避免重新解析已解析的文件。任何在AST
解析后对其进行的操作都应该创建变更部分的副本,而不是直接更改原始节点。第一阶段
语法分析
在
Parse
方法中先构造词法分析器,然后依赖词法分析器构造语法分析器:之后调用语法分析器的
parseStmtsUpTo
方法对文件进行第一阶段的处理,该阶段不绑定符号:parseStmtsUpTo
方法中使用经典递归下降分析,最终方法返回Stmt
数组,Stmt
结构体表示一个 JavaScript 语句:其中的
Data
属性,其类型为S
,是一个接口:接口中定义的
isStmt
方法永远不会被调用,它仅用于 Go 的类型系统,下面是部分实现了S
接口的 JavaScript 语句:在语法分析过程中,若分析到在全局声明的符号时会调用
newSymbol
方法在符号表中分配新的符号,符号通过SymbolKind
枚举被分类为:作用域
语法分析器执行两次传递,我们需要将作用域树信息从第一次传递到第二次传递。这是通过在 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
结构体Part
生成逻辑在遍历
Stmt
生成Part
的过程中,若顶级声明语句中包含多个变量,会根据变量拆分为多个Parts
。其他情况下,会为每个语句创建一个
Part
,最终的Part
数组会根据语句类型调整排序:require()
时,导入操作在其他语句之前发生。export = value;
语句到最末尾,确保它们被转换为module.exports = value;
时,其他语句在其之前。记录标识符
在遍历树的过程中,会将声明的变量标识符、导入语句引入的变量标识符等,通过
recordDeclaredSymbol
方法记录到declaredSymbols
属性中,它用于链接阶段:还会统计符号被使用的次数:
常量折叠(constant folding)
常量折叠相关的方法都放置在 js_ast_helpers.go 文件中,包含折叠布尔类型的
ToBooleanWithSideEffects
方法:折叠字符串的 FoldStringAddition 方法:
替换编译时变量
用户可以通过
Build
方法传入Define
配置,用于替换全局标识符或常量表达式。用户传入的
Define
配置会被处理为ProcessedDefines
类型的实例,其定义如下:其中
IdentifierDefines
中记录标识符,当遍历到EIdentifier
标识符类型的节点时,会用用户指定的定义替换未绑定或注入的符号。DotDefines
中记录常量表达式,当遍历到EDot
属性访问表达式类型的节点时,会用用户指定的定义替换未绑定或注入的符号。根据配置的语言目标适当降级某些语法结构
在第二阶段还会根据配置的语言目标适当降级某些语法结构,相关的方法实现在 js_parser/js_parser_lower.go 文件中。例如遍历过程中发现了可选链语法,就会调用
lowerOptionalChain
方法来进行语法降级处理。js_lexer 包 - 词法分析器
JavaScript 的词法分析器,将源文件转换为单词序列。与许多编译器不同,esbuild 不会在语法分析器启动之前就运行词法分析器。相反,在解析文件时,词法分析器被语法分析器重复调用。这是因为许多单词的解析是依赖上下文的,需要依赖语法分析器中的信息,例如正则表达式字面量和 JSX。
语法解析器依赖词法解析器,其基本用法如下:
词法解析器的具体结构如下:
linker 包 - 链接过程
linker 包通过
Link
方法对外提供功能,接收模块依赖图返回输出文件。模块依赖图指的就是 bundler 包中提到的entryPoints
和files
,而输出文件是一个OutputFile
类型的数组,OutputFile
类型的定义如下:JSReprMeta
结构体这包含与绑定器的初始扫描阶段的“文件”结构相对应的链接器特定元数据。它被分离出来,因为它在概念上仅用于单个链接操作,并且因为多个链接操作可能与同一文件的不同元数据并行发生。
第一步,从模块依赖图克隆得到
LinkerGraph
Link
方法会先调用CloneLinkerGraph
方法,对输入的模块依赖图进行浅克隆,预先克隆了它可能修改的 AST 字段,来获得一个新的依赖图以用于链接阶段:该图中的
Files
属性的类型为LinkerFile
,其中除了包含模块依赖图中的InputFile
,还增加了一些别的属性用于模块间的链接:CloneLinkerGraph
方法的流程如下:扫描 import 和 export 语句
第一步,找到哪些模块必须转换为 CommonJS
该过程主要解决 ESM 和 CommonJS 的互操作方面的问题,esbuild 在 runtime 包中定义了许多 JavaScript 方法,例如
__toCommonJS
方法:当前模块若使用
require()
方法导入一个模块时,这个导入的模块会被增加一个标记,以此在代码生成阶段使用__toCommonJS
方法包装该模块的导出对象。下面是一个例子:转换结果:
第二步,绑定导入与导出
计算 chunk
在
computeChunks
方法中将计算出最终要拆分成哪些 chunk,先来看一下 chunk 的类型chunkInfo
的定义:其中最重要的属性是
filesWithPartsInChunk
,表明最终该 chunk 中包含哪些模块。生成 chunk
ast 包
这里定义了与模块导入相关的数据结构,在 JavaScript 和 CSS 模块间共用,让 bundler 和 linker 在进行处理时无需区分模块格式(JavaScript 还是 CSS)。
ImportRecord 结构体
它记录导入语句有关的信息,我们在 bundle 包中提到过它:
ImportRecordFlags 枚举
Flags
字段的类型为ImportRecordFlags
,标记模块中的导入语句具有哪些特征:Kind 字段
Kind
字段的类型为ImportKind
,表示当前导入语句的类型:js_ast
该包定义 JavaScript 的 AST。
AST 中所有的标识符都通过 Ref 访问,Ref 是一个指针,指向模块的符号表。符号表中存储 AST 中的顶级字段,这样可以在不遍历树的情况下获得它们。例如,可以在不遍历 AST 的情况下,通过遍历符号表来对标识符进行重命名。
AST 数据是不可变的。这使得构建具有“监视”模式的增量编译器变得容易,可以避免重新解析已解析的文件。任何在AST解析后对其进行操作的过程都应该创建树的变异部分的副本,而不是对原始树进行变异。
graph
graph 存储 linker 处理的文件集合。每个 linker 都有一个独立的 graph(当启用代码拆分时只有一个 linker,当禁用代码拆分时每个入口都创建一个 linker)。
传入 linker 构造函数的输入数据必须是不可变的,因为它在不同的 linker 间共享,并且还存储在缓存中以用于增量构建。
linker 构造函数对输入数据进行浅克隆,并预先克隆了它可能修改的 AST 字段。Go 语言没有任何用于不变性的类型系统特性,因此必须手动执行。请谨慎。
js_printer - 代码生成
linker 包会调用 js_printer 包中的
Print
方法,通过遍历 AST 所有节点,根据节点类型打印对应的 JavaScript 代码:在该阶段也会包含一些代码压缩的工作,例如打印数字时选择占用字节数量最小表示方式,代码在
printNonNegativeFloat
方法中,下面列出了一些转换示例。精简指数表示:
移除前导0:
尝试使用指数表示: