Esbuild 入口文件及啓動過程|源碼解讀

前言

又回到了經典的一句話:“先知其然,而後使其然”。相信很多同學都知道了 esbuild,其以飛快的構建速度聞名於衆。並且,esbuild 作者 Evan Wallace 也在官網的 FAQ 專門介紹了爲什麼 esbuild 會這麼快?(有興趣的同學可以自行了解 https://esbuild.github.io/faq/)

那麼,回到今天本文,將會從 esbuild 源碼的目錄結構入手,圍繞以下 2 點和大家一起走進 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>

這裏,我們來分別認識一下各個參數的含義:

那麼,下面我們來看一下 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 點來分步驟講解:

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 分別對應的作用:

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~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/lpWf4jTu2xJRtaevjpmqvQ