Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Esbuild 入口文件及启动过程|源码解读 #29

Open
WJCHumble opened this issue May 5, 2021 · 0 comments
Open

Esbuild 入口文件及启动过程|源码解读 #29

WJCHumble opened this issue May 5, 2021 · 0 comments

Comments

@WJCHumble
Copy link
Owner

前言

又回到了经典的一句话:“先知其然,而后使其然”。相信很多同学都知道了 esbuild,其以飞快的构建速度闻名于众。并且,esbuild 作者 Evan Wallace 也在官网的 FAQ专门介绍了为什么 esbuild 会这么快?(有兴趣的同学可以自行了解 https://esbuild.github.io/faq/)

那么,回到今天本文,将会从 esbuild 源码的目录结构入手,围绕以下 2 点和大家一起走进 esbuild 底层的世界:

  • 初识 Esbuild 构建的入口
  • Esbuild 构建的入口做了什么

1 初识 Esbuild 构建的入口

在 Go 中,是以 package (包)来划分模块,每个 Go 的应用程序都需要包含一个入口 package main,即 main.go 文件。那么,显然 esbuild 本身也是一个 Go 应用,即它的入口文件同样也是 main.go 文件。

而对于 esbuild,它的目录结构:

|—— cmd
|—— docs
|—— images
|—— internal
|—— lib
|—— npm
|—— pkg
|—— require
|—— scripts
.gitignore
go.mod
go.sum
Makefile
README.md
version.txt

似乎一眼望去,并没有我们想要的 main.go 文件,那么我们要怎么找到整个应用的入口?

学过 C 的同学,应该知道 Make 这个构建工具,它可以用于执行我们定义好的一系列命令,来实现某个构建目标。并且,不难发现的是上面的目录结构中有一个 Makefile 文件,它则是用来注册 Make 命令的。

而在 Makefile 文件中注册规则的基础语法会是这样:

<target> : <prerequisites> 
[tab]  <commands>

这里,我们来分别认识一下各个参数的含义:

  • target 构建的目标,即使用 Make 命令的目标,例如 make 某个目标名
  • prerequisites 前置条件,通常是一些文件对应的路径,一旦这些文件发生变动,在执行 Make 命令时,就会进行重新构建,反之不会
  • tab 固定的语法格式要求,命令 commands 的开始必须为一个 tab
  • commands 命令,即执行 Make 命令构建某个目标时,对应会执行的命令

那么,下面我们来看一下 esbuild 中 Makefile 文件中的内容:

ESBUILD_VERSION = $(shell cat version.txt)

# Strip debug info
GO_FLAGS += "-ldflags=-s -w"

# Avoid embedding the build path in the executable for more reproducible builds
GO_FLAGS += -trimpath

esbuild: cmd/esbuild/version.go cmd/esbuild/*.go pkg/*/*.go internal/*/*.go go.mod
	CGO_ENABLED=0 go build $(GO_FLAGS) ./cmd/esbuild

test:
	make -j6 test-common

# These tests are for development
test-common: test-go vet-go no-filepath verify-source-map end-to-end-tests js-api-tests plugin-tests register-test node-unref-tests

# These tests are for release (the extra tests are not included in "test" because they are pretty slow)
test-all:
	make -j6 test-common test-deno ts-type-tests test-wasm-node test-wasm-browser lib-typecheck
....

注意:这里只是列出了 Makefile 文件中的部分规则,有兴趣的同学可以自行查看其他规则~

可以看到,在 Makefile 文件中注册了很多规则。而我们经常使用的 esbuild 命令,则对应着这里的 esbuild 目标。

根据上面对 Makefile 的介绍以及结合这里的内容,我们可以知道的是 esbuild 命令的核心是由 cmd/esbuild/version.go cmd/esbuild/*.gopkg/*/*.gointernal/*/*.go go.mod 这三部分相关的文件实现的。

那么,通常执行 make esbuild 命令,其本质上是执行命令:

CGO_ENABLED=0 go build $(GO_FLAGS) ./cmd/esbuild

下面,我们来分别看一下这个命令做了什么(含义):

CGO_ENABLED=0

CGO_ENABLED 是 Go 的环境(env)信息之一,我们可以用 go env 命令查看 Go 支持的所有环境信息。

而这里将 CGO_ENABLED 设为 0 是为了禁用 cgo,因为默认情况下,CGO_ENABLED1,也就是开启 cgo 的,但是 cgo 是会导入一些包含 C 代码的文件,那么也就是说最后编译的结果会包含一些外部动态链接,而不是纯静态链接

cgo 可以让你在 .go 文件中使用 C 的语法,这里不做详细的展开介绍,有兴趣的同学可以自行了解

那么,这个时候大家可能会思考外部动态链接静态链接之间的区别是什么?为什么需要纯静态链接的编译结果?

这是因为外部动态链接会打破你最后编译出的程序对平台的适应性。因为,外部动态链接存在一定的不确定因素,简单的说也许你现在构建出来的应用是可以用的,但是在某天外部动态链接的内容发生了变化,那么很可能会对你的程序运行造成影响。

go build $(GO_FLAGS) ./cmd/esbuild

go build $(GO_FLAGS) ./cmd/esbuild 的核心是 go build 命令,它是用于编译源码文件、代码包、依赖包等操作,例如我们这里是对 ./cmd/esbuild/main.go 文件执行编译操作。

到这里,我们就已经知道了 esbuild 构建的入口是 cmd/esbuild/main.go 文件了。那么,接下来就让我们看一下构建的入口都做了哪些事情?

2 Esbuild 构建的入口做了什么?

虽然,Esbuild 构建的入口 cmd/esbuild/main.go 文件的代码总共才 268 行左右。但是,为了方便大家理解,这里我将拆分为以下 3 点来分步骤讲解:

  • 基础依赖的 package 导入
  • --help 的文字提示函数的定义
  • main 函数具体都做了哪些

2.1 基础依赖的 package 导入

首先,是基础依赖的 package 导入,总共导入了 8 个 package

import (
	"fmt"
	"os"
	"runtime/debug"
	"strings"
	"time"

	"github.com/evanw/esbuild/internal/api_helpers"
	"github.com/evanw/esbuild/internal/logger"
	"github.com/evanw/esbuild/pkg/cli"
)

这 8 个 package 分别对应的作用:

  • fmt 用于格式化输出 I/O 的函数
  • os 提供系统相关的接口
  • runtime/debug 提供程序在运行时进行调试的功能
  • strings 用于操作 UTF-8 编码的字符串的简单函数
  • time 用于测量和展示时间
  • github.com/evanw/esbuild/internal/api_helpers 用于检测计时器是否正在使用
  • github.com/evanw/esbuild/internal/logger 用于格式化日志输出
  • github.com/evanw/esbuild/pkg/cli 提供 esbuild 的命令行接口

2.2 --help 的文字提示函数的定义

任何一个工具都会有一个 --help 的选项(option),用于告知用户能使用的具体命令。所以,esbuild 的 --help 文字提示函数的定义也具备同样的作用,对应的代码(伪代码):

var helpText = func(colors logger.Colors) string {
	return `
` + colors.Bold + `Usage:` + colors.Reset + `
  esbuild [options] [entry points]

` + colors.Bold + `Documentation:` + colors.Reset + `
  ` + colors.Underline + `https://esbuild.github.io/` + colors.Reset + `

` + colors.Bold
  ...
}

这里会用到我们上面提到的 logger 这个 packageColors 结构体,它主要用于美化在终端输出的内容,例如加粗(Bold)、颜色(RedGreen):

type Colors struct {
	Reset     string
	Bold      string
	Dim       string
	Underline string

	Red   string
	Green string
	Blue  string

	Cyan    string
	Magenta string
	Yellow  string
}

而使用 Colors 结构体创建的变量会是这样:

var TerminalColors = Colors{
	Reset:     "\033[0m",
	Bold:      "\033[1m",
	Dim:       "\033[37m",
	Underline: "\033[4m",

	Red:   "\033[31m",
	Green: "\033[32m",
	Blue:  "\033[34m",

	Cyan:    "\033[36m",
	Magenta: "\033[35m",
	Yellow:  "\033[33m",
}

2.3 main 函数主要都做了哪些

在前面,我们也提及了每个 Go 的应用程序都必须要有一个 main package,即 main.go 文件来作为应用的入口。而在 main.go 文件内也必须声明 main 函数,来作为 package 的入口函数。

那么,作为 esbuild 的入口文件的 main 函数,主要是做这 2 件事:

1. 获取输入的选项(option),并进行处理

使用我们上面提到的 os 这个 package 获取终端输入的选项,即 os.Args[1:]。其中 [1:] 表示获取数组从索引为 1 到最后的所有元素构成的数组。

然后,会循环 osArgs 数组,每次会 switch 判断具体的 case,对不同的选项,进行相应的处理。例如 --version 选项,会输出当前 esbuild 的版本号以及退出:

fmt.Printf("%s\n", esbuildVersion)
os.Exit(0)

这整个过程对应的代码会是这样:

osArgs := os.Args[1:]
argsEnd := 0
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)

  // Special-case the version flag here
  case arg == "--version":
    fmt.Printf("%s\n", esbuildVersion)
    os.Exit(0)
    ...
  default:
    osArgs[argsEnd] = arg
    argsEnd++
  }
}

并且,值得一提的是这里会重新构造 osArgs 数组,由于选项是可以一次性输入多个的,
但是 osArgs 会在后续的启动构建的时候作为参数传入,所以这里处理过的选项会在数组中去掉。

2. 调用 cli.Run(),启动构建

对于使用者来说,我们切实关注的是使用 esbuild 来打包某个应用,例如使用 esbuild xxx.js --bundle 命令。而这个过程由 main 函数最后的自执行函数完成。

该函数的核心是调用 cli.Run() 来启动构建过程,并且传入上面已经处理过的选项。

func() {
  ...
  exitCode = cli.Run(osArgs)
}()

并且,在正式开启构建之前,会根据继续处理前面的选项相关的逻辑,具体会涉及到 CPU 跟踪、堆栈的跟踪等,这里不作展开介绍,有兴趣的同学自行了解。

结语

好了,到这里我们就大致过了一遍 esbuild 构建的入口文件相关源码。站在没接触过 Go 的同学角度看可能稍微有点晦涩,并且有些分支逻辑,文中并没有展开分析,这会在后续的文章中继续展开。但是,总体上来看,打开一个新的窗户看到了不一样的风景,这不就是我们作为工程师所希望经历的嘛 😎。最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant