esbuild源码分析(一)如何构建AST

什么是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

test.png

如何生成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,
	}
}

总结

progress.png

总结一下esbuild在构建AST阶段的执行流程,首先解析命令行入参,根据不同的参数选择不同的功能,其次从参数内获取entryPoint

获取entryPoint后通过addEntryPoints解析入口文件,获取该文件的AST以及其他依赖,将解析结果写入scanner.resultChannel

接下来通过scanAllDependencies从resultChannel获取addEntryPoints的解析结果,并将全部依赖解析成Stmts

最后通过toAST生成AST

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYvZN4NR' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片