昨天在 Vite 原始碼分析的最後看到了 esbuild 的設定們,今天這篇就利用這個機會來了解一下 esbuild 並實際動手玩玩看這個工具。
前面有稍微簡介過,這裡再稍微複習下 esbuild 是什麼:
那為什麼 esbuild 可以這麼快呢,官方文件中有特別做說明:
快歸快,esbuild 有什麼事做不到呢,這文件上的 roadmap 也寫得很清楚:
tsc
上面不支援 HTML 做為進入點的問題,好奇看了下其實 Webpack 也有同樣問題,只是後來有人寫了 html-bundler-webpack-plugin 來解決,只能說做為一個比較新生代的 bundler,esbuild 的 plugin 生態系真的不如 Webpack 跟 Rollup,這其實也是為何 Vite 選擇在正式打包時採用 Rollup 的原因之一 (ref)。
另外還有一個小問題是官方文件也算是相當陽春,竟然沒有全站搜尋的功能,只能自己用 cmd + f
搜尋想找的設定。
講了這麼多用個簡單的範例來實際體驗下 esbuild 吧。
起手專案可以用前面的這個 ironman-lab,先來安裝 esbuild 本人:
$ pnpm add -D -E esbuild
💡 這個 -E 是因為參考了安裝 esbuild 文件中,其中的 npm 指令上有帶上
--save-exact
,比較少見查了下文件是指要直接使用特定版本像是,而不使用一些被稱為 Semver Range Operator 的版本範圍處理。像這裡有帶上的話會是0.24.0
,沒帶的話會是^0.24.0
。
安裝 React 相關套件:
$ pnpm add react react-dom
在 src
底下新增一個測試檔案 app.jsx
:
import * as React from 'react'
import * as Server from 'react-dom/server'
let Greet = () => <h1>Hello, world!</h1>
console.log(Server.renderToString(<Greet />))
在 package.json
加一段 script 方便執行:
"scripts": {
"build": "esbuild src/app.jsx --bundle --outfile=dist/out.js"
},
前置作業都完成後,就可以實際來執行打包試試看,基本上是一個 esbuild 版本的 hello world:
$ pnpm run build
執行後應該會在 dist/out.js
中看到被打包出來的內容,裡面包含了前面 app.jsx
的內容並帶上執行 React 程式需要的 runtime 在裡面:
// dist/out.js
(() => {
// ... 略 ...
// src/app.jsx
var React = __toESM(require_react());
var Server = __toESM(require_server_browser());
var Greet = () => /* @__PURE__ */ React.createElement("h1", null, "Hello, world!");
console.log(Server.renderToString(/* @__PURE__ */ React.createElement(Greet, null)));
})();
在前面我們將打包指令直接放在 package.json
中,但隨著要做的事變多放到設定檔中會比較好管理,這裡改在根目錄建立一個 esbuild.config.mjs
,這裡也順手將輸出位置改成特定資料夾的 outdir
:
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['src/app.jsx'],
bundle: true,
outdir: 'dist',
})
將剛剛 package.json
中的 script 改成這樣:
"scripts": {
"build": "node esbuild.config.mjs"
},
此時再試一次 pnpm run build
也可以看到同樣的輸出檔案被放在 dist
中。
有了初始的基本專案後,有需要的話就可以再參考文件調整設定來測試一下每個 option 或嘗試其他 API 的用途。
像是來試試前幾天在 Day 14 中 Vite 原始碼中看到的 write,這個設定預設是 true
也就是會打包出檔案到 outdir
底下,但如果手動將它改成 false
來試試:
import * as esbuild from 'esbuild'
const result = await esbuild.build({
entryPoints: ['src/app.jsx'],
bundle: true,
write: false,
outdir: 'dist',
})
console.log(result)
如果先將原本的 dist
刪掉,再次執行 pnpm run build
後會發現沒有新的 dist
被建出來,而是只有輸出到 CLI 的 console 上,這也說明了文件上的會將內容寫到記憶體緩衝區的意思吧:
{
errors: [],
warnings: [],
outputFiles: [
{
path: '/ironman2024/ironman-lab/dist/out.js',
contents: [Uint8Array],
hash: '0Rf7MrjSZDc',
text: [Getter]
}
],
metafile: undefined,
mangleCache: undefined
}
而除了基本的 esbuild.build
之外,裡面也提到有三種模式可以彈性調整跟其他 build tool 做整合,像是之前在 Vite 裡很常看到的 rebuild
,讓你可以實作自己的 watcher 跟 dev server:
let ctx = await esbuild.context({
entryPoints: ['app.ts'],
bundle: true,
outdir: 'dist',
})
for (let i = 0; i < 5; i++) {
let result = await ctx.rebuild()
}
另外還有一個做轉譯的 API 是 esbuild.transform
,像是執行以下的程式碼後,就可以看到程式碼被轉成一般的 JS:
import * as esbuild from 'esbuild'
let ts = 'let x: number = 1'
let result = await esbuild.transform(ts, {
loader: 'ts',
})
console.log(result)
其他設定有興趣的讀者也可以再利用這個 repo 與官方文件自己玩玩看。接下來我們也回頭來了解一下前面提到的 code splitting 問題。
(圖片來源)
這裡也補充一下 code splitting (程式碼分割) 的筆記。在以往的 module bundler 中,code splitting 是一種打包時讓網頁載入更有效率的優化方式。
如果白話點形容的話,可以想像我們今天要去野餐,如果把所有食物都放在一個大野餐籃裡,每去到一個地方就得扛著過去,因為很重所以走得很慢,但很可能每次都只會吃到野餐籃裡的一些東西。
但如果今天我們將大野餐籃分裝成前菜籃、主食籃、甜點籃、飲料籃、餐具籃等,每去到一個地方我們就可以只帶需要的部份,因為只帶了需要的部份所以可以更快到達目的地。甚至像是餐具籃因為每次都需要用上,所以可以固定放在容易拿到的地方重複使用,不用每次都需要帶上新的。
對應到網頁載入的話,做 code splitting 有兩個好處:
在 esbuild 的文件中寫到:
⚠️ Code splitting is still a work in progress. It currently only works with the esm output format. There is also a known ordering issue with import statements across code splitting chunks.
這段意思是目前 esbuild 在 code splitting 暫時只支援以 ESM 為輸出的方式,而且有兩個 issue 尚待解決:
這裡我有實際用上面的實驗專案去測試第二個 issue,有興趣可以直接下載這份程式碼測試。簡單說如果今天分別有兩個 entry point 分別將定義好的模組與執行拆開來:
// init-dep-1.js
global.foo = {
log: () => console.log("foo.log() (from entry 1) called"),
};
// init-dep-2.js
global.foo = {
log: () => console.log("foo.log() (from entry 2) called"),
};
// run-dep.js
global.foo.log();
// entry1.js
import "./init-dep-1.js";
import "./run-dep.js";
// entry2.js
import "./init-dep-2.js";
import "./run-dep.js";
使用這樣的設定做打包:
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['esbuild-split/entry1.js', 'esbuild-split/entry2.js'],
bundle: true,
splitting: true,
outdir: 'dist',
format: 'esm',
platform: 'node',
outExtension: { '.js': '.mjs' },
})
打包後會輸出這樣的結構與內容:
├── dist
| ├── chunk-3S5DYAIT.mjs
| ├── entry1.mjs
| └── entry2.mjs
從上圖會看到因為這兩個進入點因為都有去載入 run-dep.js
這個檔案,所以在開啟 code splitting 的狀況下,在打包時被切出一個 chunk,但此時如果去執行 node entry1.mjs
的話就會因為載入順序問題而報錯:
global.foo.log();
^
TypeError: Cannot read properties of undefined (reading 'log')
但其實追到近期關於此 issue 的討論會看到,這個算是在多個 entry point、有共用模組、且開啟 code splitting 設定這些組合的情況下會發生的 edge case,不確定有朝一日會不會解決。但重點只要記住 esbuild 在 code splitting 的特性上沒有這麼完善,在使用上會需要特別注意。
另外值得一提的是這也正是像 Rspack 文件上面提到他們要自己重用 Rust 刻一套 bundler 的原因之一:
esbuild achieves very good performance by implementing nearly all operations in Golang except for some JavaScript plugins. However, esbuild's feature set is not as complete as webpack, for example missing HMR and optimization.splitChunks features.
今天這篇中從基礎的 esbuild 是什麼,並實際動手建立一個簡單的打包專案,並進階嘗試調整各種設定與 API,最後也回頭看看昨天留下來的關於 code splitting 在 esbuild 中的限制與問題。
可以說雖然 esbuild 有著極快的打包效能,但其中也犧牲掉了許多會影響效能的功能與特性,且跟老牌的 bundler 如 Webpack、Rollup 比起來,生態圈中所支援的 plugin 也不夠多。這些原因也正是為何 Vite 在 dev server 中選擇 esbuild 來快速做 pre-bundling,但實際在做正式版打包時,用的仍是 Rollup。
明天我會將這一系列從理解 Vite 特性、實驗、分析原始碼的主題做個總整理,一起來回頭看看是否已經能回答以下問題了: