什么是esbuild
esbuild是一款使用go编写的构建速度极快的js打包工具,可以为多种类型的文件提供构建能力,本系列文章将从bundle模式入手逐步分析其构建流程,后续会补充bundless相关内容,并分析esbuild构建速度为何远快于传统打包工具。
构建流程分析
bundle模式的构建流程主要包括:
- 解析命令行参数
- 解析入口文件并获取依赖
- 根据入口文件扫描所有依赖
- 解析js文件并生成ast
解析参数
这里简单看一下就可以了,等整个系列写完会写一篇参数相关的文章,esbuild的参数解析是分多个阶段进行的,每个阶段获取一部分参数,下面贴一部分代码简单看一下
// cmd/esbuild/main.go
for _, arg := range osArgs {
switch {
case arg == "-h", arg == "-help", arg == "--help", arg == "/?":
logger.PrintText(os.Stdout, logger.LevelSilent, os.Args, helpText)
os.Exit(0)
case arg == "--version":
fmt.Printf("%s\n", esbuildVersion)
os.Exit(0)
// some code
case strings.HasPrefix(arg, "--ping"):
sendPings = true
default:
if arg == "--watch=forever" {
arg = "--watch"
isWatchForever = true
}
osArgs[argsEnd] = arg
argsEnd++
}
}
后面的函数含有很多解析参数的内容,这里就不放了
解析入口文件
当执行 esbuild app.jsx –bundle –outfile=out.js 时,esbuild会从参数中解析出app.jsx这个文件作为入口文件(根据参数不同也可能时多个entryPoints),只有扫描这个entryPoint才能获取其他依赖的信息
处理entryPoint的核心函数叫addEntryPoint,在执行到scanBundle时会调用该函数,下面先看一下scanBundle
func ScanBundle(
log logger.Log,
fs fs.FS,
caches *cache.CacheSet,
entryPoints []EntryPoint,
options config.Options,
timer *helpers.Timer,
) Bundle {
timer.Begin("Scan phase")
defer timer.End("Scan phase")
applyOptionDefaults(&options)
s := scanner{
log: log,
fs: fs,
res: resolver.NewResolver(fs, log, caches, options),
caches: caches,
options: options,
timer: timer,
results: make([]parseResult, 0, caches.SourceIndexCache.LenHint()),
visited: make(map[logger.Path]visitedFile),
resultChannel: make(chan parseResult),
uniqueKeyPrefix: uniqueKeyPrefix,
}
// s.remaining代表剩余需要解析的文件的数量
// esbuild会开启多个goroutine解析文件,每解析一个文件就将remaining - 1
// 遇到新的imports说明有新的文件需要解析,此时remaining需要 + 1
s.results = append(s.results, parseResult{})
s.remaining++
// esbuild每次bundle都会插入一个runtime代码,并使用一个单独的goroutine进行处理,所有remaining + 1
go func() {
source, ast, ok := globalRuntimeCache.parseRuntime(&options)
s.resultChannel <- parseResult{
file: scannerFile{
inputFile: graph.InputFile{
Source: source,
Repr: &graph.JSRepr{AST: ast},
},
},
ok: ok,
}
}()
onStartWaitGroup.Wait()
timer.End("On-start callbacks")
// 这里就是解析入口文件的核心文件,前面解析出来的entryPoint会传递给该函数
entryPointMeta := s.addEntryPoints(entryPoints)
// 根据addEntryPoints的结果扫描并解析所有依赖(这里面会处理ast)
s.scanAllDependencies()
files := s.processScannedFiles(entryPointMeta)
return Bundle{
fs: fs,
res: s.res,
files: files,
entryPoints: entryPointMeta,
uniqueKeyPrefix: uniqueKeyPrefix,
options: s.options,
}
}
在addEntryPoints主要时将entryPoint解析成ast,同时获取该文件有哪些依赖
func (s *scanner) addEntryPoints(entryPoints []EntryPoint) []graph.EntryPoint {
s.timer.Begin("Add entry points")
defer s.timer.End("Add entry points")
// Reserve a slot for each entry point
entryMetas := make([]graph.EntryPoint, 0, len(entryPoints)+1)
entryPointResolveResults := make([]*resolver.ResolveResult, len(entryPoints))
entryPointWaitGroup := sync.WaitGroup{}
entryPointWaitGroup.Add(len(entryPoints))
// 在解析文件前用RunOnResolvePlugins对每个entryPoint执行预解析
// 因为每个entryPoint解析过程都是独立的,这里单独开goroutine提高效率
for i, entryPoint := range entryPoints {
go func(i int, entryPoint EntryPoint) {
var importer logger.Path
if entryPoint.InputPathInFileNamespace {
importer.Namespace = "file"
}
// 预解析,如果文件路径不存在会解析失败
resolveResult, didLogError, debug := RunOnResolvePlugins(
s.options.Plugins,
s.res,
s.log,
s.fs,
&s.caches.FSCache,
nil,
logger.Range{},
importer,
entryPoint.InputPath,
ast.ImportEntryPoint,
entryPointAbsResolveDir,
nil,
)
// 标记任务执行结束
entryPointWaitGroup.Done()
}(i, entryPoint)
}
// 等待所有goroutine执行结束(每次调用Done方法代表结束)
entryPointWaitGroup.Wait()
// 根据RunOnResolvePlugins的结果解析文件
for i, resolveResult := range entryPointResolveResults {
if resolveResult != nil {
prettyPath := resolver.PrettyPath(s.fs, resolveResult.PathPair.Primary)
// 解析文件的核心函数,内部会解析AST
sourceIndex := s.maybeParseFile(*resolveResult, prettyPath, nil, logger.Range{}, resolveResult.PluginData, inputKindEntryPoint, nil)
outputPath := entryPoints[i].OutputPath
outputPathWasAutoGenerated := false
entryMetas = append(entryMetas, graph.EntryPoint{
OutputPath: outputPath,
SourceIndex: sourceIndex,
OutputPathWasAutoGenerated: outputPathWasAutoGenerated,
})
}
}
for i := range entryMetas {
entryPoint := &entryMetas[i]
if entryPoint.OutputPathWasAutoGenerated && !s.fs.IsAbs(entryPoint.OutputPath) {
entryPoint.OutputPath = s.fs.Join(entryPointAbsResolveDir, entryPoint.OutputPath)
}
}
return entryMetas
}
addEntryPoints先通过RunOnResolvePlugins对每个entryPoint进行解析,确保解析没有Error后才会通过mybeParseFile对文件进行解析。
扫描依赖
通过addEntryPoints已经获取了入口文件的AST及其所有的依赖,接下来esbuild会调用scanAllDependencies(看名字就知道是扫描依赖用的)扫描所有依赖并生成对应的AST
func (s *scanner) scanAllDependencies() {
s.timer.Begin("Scan all dependencies")
defer s.timer.End("Scan all dependencies")
// remaining不为0说明还有文件没有被parse
for s.remaining > 0 {
// 解析结果会被存在scanner.resultChannel内
// 没获取一个解析结果就需要 remaining - 1
result := <-s.resultChannel
s.remaining--
if !result.ok {
continue
}
if recordsPtr := result.file.inputFile.Repr.ImportRecords(); s.options.Mode == config.ModeBundle && recordsPtr != nil {
records := *recordsPtr
for importRecordIndex := range records {
record := &records[importRecordIndex]
resolveResult := result.resolveResults[importRecordIndex]
if resolveResult == nil {
continue
}
// 之前在解析addEntryPoints后可ui获取到依赖的所有ImportRecords
// 这里的resolveResult时之前解析entryPoint时通过RunOnResolvePlugin生成
// 常见的isExternal时Node内置库 import fs from 'fs'
path := resolveResult.PathPair.Primary
if !resolveResult.IsExternal {
// Handle a path within the bundle
sourceIndex := s.maybeParseFile(*resolveResult, resolver.PrettyPath(s.fs, path),
&result.file.inputFile.Source, record.Range, resolveResult.PluginData, inputKindNormal, nil)
record.SourceIndex = ast.MakeIndex32(sourceIndex)
} else {
if resolveResult.PrimarySideEffectsData != nil {
record.Flags |= ast.IsExternalWithoutSideEffects
}
if path.Namespace == "file" {
if relPath, ok := s.fs.Rel(s.options.AbsOutputDir, path.Text); ok {
relPath = strings.ReplaceAll(relPath, "\\", "/")
if resolver.IsPackagePath(relPath) {
relPath = "./" + relPath
}
record.Path.Text = relPath
} else {
record.Path = path
}
} else {
record.Path = path
}
}
}
}
s.results[result.file.inputFile.Source.Index] = result
}
}
在扫描依赖时会不断判断scanner.remaining是否还有剩余值,如果存在剩余值则继续扫描
每次解析一个文件(解析文件的同时可以获取所有的importRecords)都会向scanner.resultChannel内存入解析结果
scannAllDependencies从scanner.resultChannel获取解析结果并调用maybeParseFile,maybeParseFile在执行结束后又会将result(AST)写入scanner.resultChannel
如何生成Token
前面看完了esbuild构建AST的流程,接下来阅读一下执行细节。在生成AST之前需要获取打扫这份代码的Token,毕竟需要知道写的是啥。
常见的扫描Token的方式大概有两种
- 先生成全量Token再生成Statements
- 一边扫描Token一边构建Statements
esbuild使用的是第二种,即获取一个token然后构建Statements,再获取下一个Token。
// internal/js_lexer/js_lexer.go
// NewLexer用于创建一个词法分析器
func NewLexer(log logger.Log, source logger.Source, ts config.TSOptions) Lexer {
lexer := Lexer{
log: log,
source: source,
tracker: logger.MakeLineColumnTracker(&source),
prevErrorLoc: logger.Loc{Start: -1},
FnOrArrowStartLoc: logger.Loc{Start: -1},
ts: ts,
json: NotJSON,
}
lexer.step()
lexer.Next()
return lexer
}
// 返回词法分析器当前扫描的位置
func (lexer *Lexer) Loc() logger.Loc {
return logger.Loc{Start: int32(lexer.start)}
}
// 判断下一次Token应该是目标类型的Token,如果不是会报错
// 比如 const t; 第一个Token是const,那下一个Token应该是是一个变量名,肯定不能是const const这样的东西
func (lexer *Lexer) Expected(token T) {
if text, ok := tokenToString[token]; ok {
lexer.ExpectedString(text)
} else {
lexer.Unexpected()
}
}
//用于获取下一个Token
func (lexer *Lexer) Next() {
lexer.HasNewlineBefore = lexer.end == 0
lexer.HasPureCommentBefore = false
lexer.PrevTokenWasAwaitKeyword = false
lexer.LegalCommentsBeforeToken = lexer.LegalCommentsBeforeToken[:0]
lexer.CommentsBeforeToken = lexer.CommentsBeforeToken[:0]
for {
lexer.start = lexer.end
lexer.Token = 0
switch lexer.codePoint {
case -1: // This indicates the end of the file
lexer.Token = TEndOfFile
case '\r', '\n', '\u2028', '\u2029':
lexer.step()
lexer.HasNewlineBefore = true
continue
case '\t', ' ':
lexer.step()
continue
case '(':
lexer.step()
lexer.Token = TOpenParen
// some code
default:
// Check for unusual whitespace characters
if js_ast.IsWhitespace(lexer.codePoint) {
lexer.step()
continue
}
if js_ast.IsIdentifierStart(lexer.codePoint) {
lexer.step()
for js_ast.IsIdentifierContinue(lexer.codePoint) {
lexer.step()
}
if lexer.codePoint == '\\' {
lexer.Identifier, lexer.Token = lexer.scanIdentifierWithEscapes(normalIdentifier)
} else {
lexer.Token = TIdentifier
lexer.Identifier = lexer.rawIdentifier()
}
break
}
lexer.end = lexer.current
lexer.Token = TSyntaxError
}
return
}
}
上面列了一下Token及其相关的比较重要的方法,再构建Statement时经常会看到这些函数的调用
比如通过Next方法可以不断获取Token构建当前的Statement,当需要判断下一个Token的类型时可以使用Expected防止扫描出现语法错误(比如关键字while后面肯定不能是const之类的关键字)
构建Statament
在构建AST之前需要将Token合成更大的单位也就是Statment,看一下下面的代码
while(true){
}
这是一个典型的while statement,构成该Statement的Token有”while”, “(“,”)”, “true”, “{“, “}”
// internal/js_parser/js_parser.go
case js_lexer.TImport:
previousImportStatementKeyword := p.esmImportStatementKeyword
p.esmImportStatementKeyword = p.lexer.Range()
p.lexer.Next()
stmt := js_ast.SImport{}
wasOriginallyBareImport := false
if (opts.isExport || (opts.isNamespaceScope && !opts.isTypeScriptDeclare)) && p.lexer.Token != js_lexer.TIdentifier {
p.lexer.Expected(js_lexer.TIdentifier)
}
// 在识别到Import Token后需要判断后面的Token类型, 因为import语句的写法比较多
// 如果后面是 * 说明和可能时import * as ns from 'path'这样的语法
switch p.lexer.Token {
case js_lexer.TAsterisk:
if !opts.isModuleScope && (!opts.isNamespaceScope || !opts.isTypeScriptDeclare) {
p.lexer.Unexpected()
return js_ast.Stmt{}
}
p.lexer.Next()
// 下一个Token应该是as,如果不是说明有语法错误
p.lexer.ExpectContextualKeyword("as")
stmt.NamespaceRef = p.storeNameInRef(p.lexer.Identifier)
starLoc := p.lexer.Loc()
stmt.StarNameLoc = &starLoc
// 紧接着一个变量名
p.lexer.Expect(js_lexer.TIdentifier)
// 变量名后面必须是From
p.lexer.ExpectContextualKeyword("from")
default:
p.lexer.Unexpected()
return js_ast.Stmt{}
}
p.currentScope.IsAfterConstLocalPrefix = true
return js_ast.Stmt{Loc: loc, Data: &stmt}
上面是parseStmt在处理”import * as fs from ‘fs'”时的一段代码,解析其他语句的过程基本差不多。
首先识别到Import Token后说明这是一个import语句,再获取到下一个Token时”*” 可以判断后面是”as”,逐步判断成功后说明这是Import Statement
对于判断其他语句利于Function Statement基本大同小异,感兴趣可以自行阅读
构建AST
前面构建好的stmts时AST的核心部分,除此之外,AST还有很多其他数据,比如ImportRecords,符号表,作用域等等
func (p *parser) toAST(before, parts, after []js_ast.Part, hashbang string, directive string) js_ast.AST {
// Insert an import statement for any runtime imports we generated
if len(p.runtimeImports) > 0 && !p.options.omitRuntimeForTests {
keys := sortedKeysOfMapStringLocRef(p.runtimeImports)
sourceIndex := runtime.SourceIndex
before = p.generateImportStmt("<runtime>", keys, &sourceIndex, before, p.runtimeImports)
}
// 插入前面解析过的js runtime代码
if len(p.jsxRuntimeImports) > 0 && !p.options.omitJSXRuntimeForTests {
keys := sortedKeysOfMapStringLocRef(p.jsxRuntimeImports)
path := p.options.jsx.ImportSource
if p.options.jsx.Development {
path = path + "/jsx-dev-runtime"
} else {
path = path + "/jsx-runtime"
}
before = p.generateImportStmt(path, keys, nil, before, p.jsxRuntimeImports)
}
parts = append(parts, after...)
keptImportEquals := false
removedImportEquals := false
partsEnd := 0
//处理statements
for partIndex, part := range parts {
p.importRecordsForCurrentPart = nil
p.declaredSymbols = nil
result := p.scanForImportsAndExports(part.Stmts)
part.Stmts = result.stmts
keptImportEquals = keptImportEquals || result.keptImportEquals
removedImportEquals = removedImportEquals || result.removedImportEquals
part.ImportRecordIndices = append(part.ImportRecordIndices, p.importRecordsForCurrentPart...)
part.DeclaredSymbols = append(part.DeclaredSymbols, p.declaredSymbols...)
}
parts = parts[:partsEnd]
for _, part := range parts {
for _, stmt := range part.Stmts {
if s, ok := stmt.Data.(*js_ast.SExportClause); ok {
for _, item := range s.Items {
// Mark re-exported imports as such
if namedImport, ok := p.namedImports[item.Name.Ref]; ok {
namedImport.IsExported = true
p.namedImports[item.Name.Ref] = namedImport
}
}
}
}
}
// 生成AST对象
return js_ast.AST{
Parts: parts,
ModuleTypeData: p.options.moduleTypeData,
ModuleScope: p.moduleScope,
CharFreq: p.computeCharacterFrequency(),
Symbols: p.symbols,
ExportsRef: p.exportsRef,
ModuleRef: p.moduleRef,
WrapperRef: wrapperRef,
Hashbang: hashbang,
Directive: directive,
NamedImports: p.namedImports,
NamedExports: p.namedExports,
TSEnums: p.tsEnums,
ConstValues: p.constValues,
ExprComments: p.exprComments,
NestedScopeSlotCounts: nestedScopeSlotCounts,
TopLevelSymbolToPartsFromParser: p.topLevelSymbolToParts,
ExportStarImportRecords: p.exportStarImportRecords,
ImportRecords: p.importRecords,
ApproximateLineCount: int32(p.lexer.ApproximateNewlineCount) + 1,
MangledProps: p.mangledProps,
ReservedProps: p.reservedProps,
ManifestForYarnPnP: p.manifestForYarnPnP,
// CommonJS features
UsesExportsRef: usesExportsRef,
UsesModuleRef: usesModuleRef,
ExportsKind: exportsKind,
// ES6 features
ExportKeyword: p.esmExportKeyword,
TopLevelAwaitKeyword: p.topLevelAwaitKeyword,
LiveTopLevelAwaitKeyword: p.liveTopLevelAwaitKeyword,
}
}
总结
总结一下esbuild在构建AST阶段的执行流程,首先解析命令行入参,根据不同的参数选择不同的功能,其次从参数内获取entryPoint
获取entryPoint后通过addEntryPoints解析入口文件,获取该文件的AST以及其他依赖,将解析结果写入scanner.resultChannel
接下来通过scanAllDependencies从resultChannel获取addEntryPoints的解析结果,并将全部依赖解析成Stmts
最后通过toAST生成AST