iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0
Modern Web

Rust 的戰國時代:探索網頁前端工具的前世今生系列 第 17

Day 17:esbuild 是什麼?code splitting 是什麼?來做個實驗玩玩看

  • 分享至 

  • xImage
  •  

day 17

前言

昨天在 Vite 原始碼分析的最後看到了 esbuild 的設定們,今天這篇就利用這個機會來了解一下 esbuild 並實際動手玩玩看這個工具。

esbuild 是什麼?

esbuild

簡介

前面有稍微簡介過,這裡再稍微複習下 esbuild 是什麼:

  • 一款用 Go 開發的 web module bundler
  • 號稱比傳統 JS-based 的 bundler 像是 Webpack、Rollup 等快 10~100 倍
  • 內建可轉譯 TypeScript、JSX、CSS modules 等語法
  • 可幫忙做 ESM 與 CJS 模組轉換
  • 有 tree-shaking、dev server、source map、plugin 等 bundler 基本功能
  • 冷知識:作者為 Figma 的共同創辦人兼曾經的 CTO Evan Wallace

為什麼可以這麼快?

那為什麼 esbuild 可以這麼快呢,官方文件中有特別做說明:

  • 基底靜態語言的 Go,相較動態語言的 JavaScript 更快,且在記憶體與 CPU 的使用上有許多天生的優勢
  • 不依賴第三方套件,從頭自己手刻模組管控效能
  • esbuild 中的演算法盡可能用上多核 CPU
  • esbuild 在解析 AST 演算法的設計讓它在記憶體的使用上更有效率

esbuild 的限制

快歸快,esbuild 有什麼事做不到呢,這文件上的 roadmap 也寫得很清楚:

  • 計畫未來可能會去解決的
    • code splitting 功能只能在輸出 ESM 的狀況下使用
    • 不支援以 HTML 做為打包進入點 (ref)
  • 不打算支援的
    • 其他前端框架語言的轉譯 (Vue、Angular、Svelte 等)
    • TypeScript 型別檢查,建議自己設定 tsc
    • 對 AST 操作客製化的設定接口
    • HMR (dev server 中可以不重整頁面就更新特定模組)
    • Module federation (被用在微前端架構)

上面不支援 HTML 做為進入點的問題,好奇看了下其實 Webpack 也有同樣問題,只是後來有人寫了 html-bundler-webpack-plugin 來解決,只能說做為一個比較新生代的 bundler,esbuild 的 plugin 生態系真的不如 WebpackRollup,這其實也是為何 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"
},

Hello, esbuild!

前置作業都完成後,就可以實際來執行打包試試看,基本上是一個 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 中。

嘗試一些進階設定與 API

有了初始的基本專案後,有需要的話就可以再參考文件調整設定來測試一下每個 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?

split

(圖片來源)

這裡也補充一下 code splitting (程式碼分割) 的筆記。在以往的 module bundler 中,code splitting 是一種打包時讓網頁載入更有效率的優化方式。

如果白話點形容的話,可以想像我們今天要去野餐,如果把所有食物都放在一個大野餐籃裡,每去到一個地方就得扛著過去,因為很重所以走得很慢,但很可能每次都只會吃到野餐籃裡的一些東西。

但如果今天我們將大野餐籃分裝成前菜籃、主食籃、甜點籃、飲料籃、餐具籃等,每去到一個地方我們就可以只帶需要的部份,因為只帶了需要的部份所以可以更快到達目的地。甚至像是餐具籃因為每次都需要用上,所以可以固定放在容易拿到的地方重複使用,不用每次都需要帶上新的。

對應到網頁載入的話,做 code splitting 有兩個好處:

  • 把複雜的模組切分成小塊的 chunk,在每個頁面去載入時能動態地依照需求載入,因此載入速度會變快。就像帶小野餐籃可以更快到目的地一樣。
  • 每個頁面模組中可能載入了許多共用的套件等等,這些共用套件就像是餐具籃,可以將他們切出來獨立一個 chunk,在一次載入後就快取起來,也能加速後續的載入速度

esbuild 在 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 尚待解決:

  • 無法完美解析動態載入 (#16)
  • 打包後的載入 split chunk 順序可能出錯 (#399)

這裡我有實際用上面的實驗專案去測試第二個 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

split issue

從上圖會看到因為這兩個進入點因為都有去載入 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 特性、實驗、分析原始碼的主題做個總整理,一起來回頭看看是否已經能回答以下問題了:

  • 為什麼 Vite 的冷啟動可以這麼快?
  • 什麼是 pre-bundling?
  • 如果專案很大的話,esbuild 在做 pre-bundle 真的不會有效能瓶頸嗎?
  • Vite 跟 esbuild 的關係是什麼?
  • 為什麼 Vite 要用 Rollup 做為 production build 的 bundler,而不選用 esbuild?
  • HMR (Hot Module Replacement)的原理是什麼?
  • 什麼是 Rolldown?跟 Rollup 差在哪?
  • 什麼是 OXC?跟 SWC 有何關係?為什麼 Vite 底層會需要這個工具?

參考資料

文章同步發表於個人部落格


上一篇
Day 16:來試著追一下 Vite 原始碼 (4) - pre-bundling 最終章
下一篇
Day 18:Vite 系列總整理
系列文
Rust 的戰國時代:探索網頁前端工具的前世今生30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言