前端構建新世代,Esbuild 原來還能這么玩!
Hello,我是三元同學。之前停更了一段時間,因為得了流感,一直在家養(yǎng)病,沒來得及更新文章,跟讀者朋友們先說聲抱歉~今天給大家?guī)淼氖俏易罱鼘懙脑瓌?chuàng)文章,由于近段時間一直在研究前端構建相關的領域,像 Esbuild、Vite 這些都接觸得比較多了,而且這些工具現(xiàn)在在前端圈也比較熱門,備受業(yè)界關注,因此我想我有必要把我研究過的一些東西分享給大家,希望能對你有所幫助。
什么是 Esbuild?
Esbuild 是由 Figma 的 CTO 「Evan Wallace」基于 Golang 開發(fā)的一款打包工具,相比傳統(tǒng)的打包工具,主打性能優(yōu)勢,在構建速度上可以快 10~100 倍。
架構優(yōu)勢
1. Golang 開發(fā)
采用 Go 語言開發(fā),相比于 單線程 + JIT 性質的解釋型語言 ,使用 Go 的優(yōu)勢在于 :
- 一方面可以充分利用多線程打包,并且線程之間共享內(nèi)容,而 JS 如果使用多線程還需要有線程通信(postMessage)的開銷;
- 另一方面直接編譯成機器碼,而不用像 Node 一樣先將 JS 代碼解析為字節(jié)碼,然后轉換為機器碼,大大節(jié)省了程序運行時間。
2. 多核并行
內(nèi)部打包算法充分利用多核 CPU 優(yōu)勢。Esbuild 內(nèi)部算法設計是經(jīng)過精心設計的,盡可能充分利用所有的 CPU 內(nèi)核。所有的步驟盡可能并行,這也是得益于 Go 當中多線程共享內(nèi)存的優(yōu)勢,而在 JS 中所有的步驟只能是串行的。
3. 從零造輪子
從零開始造輪子,沒有任何第三方庫的黑盒邏輯,保證極致的代碼性能。
4. 高效利用內(nèi)存
一般而言,在 JS 開發(fā)的傳統(tǒng)打包工具當中一般會頻繁地解析和傳遞 AST 數(shù)據(jù),比如 string -> TS -> JS -> string,這其中會涉及復雜的編譯工具鏈,比如 webpack -> babel -> terser,每次接觸到新的工具鏈,都得重新解析 AST,導致大量的內(nèi)存占用。而 Esbuild 中從頭到尾盡可能地復用一份 AST 節(jié)點數(shù)據(jù),從而大大提高了內(nèi)存的利用效率,提升編譯性能。
與 SWC 對比
速度
下面拿純 Esbuild 和 SWC 來編譯代碼,作為 Transformer 來轉換 800+ 個 tsx 文件,不寫任何的 JS 膠水代碼(如 esbuild-register、esbuild-loader、swc-loader 本身為了適配相應的宿主工具,會寫一堆 JS 膠水代碼,影響判斷)。
從這個例子可以看出,Esbuild 與 SWC 在性能上是在一個量級的,這里通過倉庫的例子 Esbuild 略快,但不排除其他例子里面 SWC 比 Esbuild 略快的場景。
兼容性
Esbuild 本身的限制,包括如下:
- 沒有 TS 類型檢查
- 不能操作 AST
- 不支持裝飾器語法
- 產(chǎn)物 target 無法降級到 ES5 及以下
意味著需要 ES5 產(chǎn)物的場景只用 Esbuild 無法勝任。
相比之下,SWC 的兼容性更好:
- 產(chǎn)物支持 ES5 格式
- 支持裝飾器語法
- 可以通過寫 JS 插件操作 AST
應用場景
對于 Esbuild 和 SWC,很多時候我們都在對比兩者的性能而忽略了應用場景。對于前端的構建工具來說主要有這樣幾個垂直的功能:
- Bundler
- Transformer
- Minimizer
從上面的速度和兼容性對比可以看出,Esbuild 和 SWC 作為 transformer 性能是差不多的,但 Esbuild 兼容性遠遠不及 SWC。因此,SWC 作為 Transformer 更勝一籌。
但作為 Bundler 以及 Minimizer,SWC 就顯得捉襟見肘了,首先官方的 swcpack 目前基本處于不可用狀態(tài),Minimizer 方面也非常不成熟,很容易碰到兼容性問題。
而 Esbuild 作為 Bundler 已經(jīng)被 Vite 作為開發(fā)階段的依賴預打包工具,同時也被大量用作線上 esm CDN 服務,比如esm.sh等等;作為 Minimizer ,Esbuild 也已足夠成熟,目前已經(jīng)被 Vite 作為 JS 和 CSS 代碼的壓縮工具用上了生產(chǎn)環(huán)境。
綜合來看,SWC 與 Esbuild 的關系類似于當下的 Babel 和 Webpack,前者更適合做兼容性和自定義要求高的 Transformer(比如移動端業(yè)務場景),而后者適合做 Bundler 和 Minimizer,以及兼容性和自定義要求均不高的 Transformer。
插件機制
esbuild 插件就是一個對象,里面有name和setup兩個屬性,name是插件的名稱,setup是一個函數(shù),其中入?yún)⑹且粋€ build 對象,這個對象上掛載了一些鉤子可供我們自定義一些構建邏輯。以下是一個簡單的esbuild插件示例:
- let envPlugin = {
- name: 'env',
- setup(build) {
- // 文件解析時觸發(fā)
- // 將插件作用域限定于env文件,并為其標識命名空間"env-ns"
- build.onResolve({ filter: /^env$/ }, args => ({
- path: args.path,
- namespace: 'env-ns',
- }))
- // 加載文件時觸發(fā)
- // 只有命名空間為"env-ns"的文件才會被處理
- // 將process.env對象反序列化為字符串并交由json-loader處理
- build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
- contents: JSON.stringify(process.env),
- loader: 'json',
- }))
- },
- }
- require('esbuild').build({
- entryPoints: ['app.js'],
- bundle: true,
- outfile: 'out.js',
- // 應用插件
- plugins: [envPlugin],
- }).catch(() => process.exit(1))
使用如下:
- *// 應用了env插件后,構建時將會被替換成process.env對象*
- import { PATH } from 'env'
- console.log(`PATH is ${PATH}`)
不過在編寫插件的時候有一些需要注意的地方:
- Esbuild 插件機制只可作用于 build API,而不適用于 transformAPI,這意味著 webpack 當中的 esbuild-loader 這種只使用 Esbuild transform 功能的地方無法利用 Esbuild 的插件機制。
- 插件中的 filter 正則是使用 go 原生的正則實現(xiàn)的,用來過濾文件,為了不使性能過于劣化,規(guī)則應該盡可能嚴格。同時它本身和 JS 的正則也有所區(qū)別,比如前瞻(?<=)、后顧(?=)和反向引用(\1)就不支持。
- 實際的插件應該考慮到自定義緩存(減少 load 的重復開銷)、sourcemap 合并(源代碼正確映射)和錯誤處理??梢詤⒖?Svelte plugin。
虛擬模塊支持
與 Rollup 對比
作為打包器,一般需要兩種形式的模塊,一種存在于真實的磁盤文件系統(tǒng)中,另一種并不在磁盤而在內(nèi)存當中,也就是虛擬模塊。Rollup 本身就天然支持虛擬模塊,Vite 基于它的插件機制,也重度使用了虛擬模塊的功能,以 wasm 文件的處理為例:
- const wasmHelperId = '/__vite-wasm-helper'
- // helper 函數(shù)實現(xiàn)
- const wasmHelper = async (opts = {}, url: string) => {
- // 省略具體實現(xiàn)
- }
- export const wasmPlugin = (config: ResolvedConfig): Plugin => {
- return {
- name: 'vite:wasm',
- resolveId(id) {
- if (id === wasmHelperId) {
- return id
- }
- },
- async load(id) {
- if (id === wasmHelperId) {
- return `export default ${wasmHelperCode}`
- }
- if (!id.endsWith('.wasm')) {
- return
- }
- const url = await fileToUrl(id, config, this)
- // 虛擬模塊
- return `
- import initWasm from "${wasmHelperId}"
- export default opts => initWasm(opts, ${JSON.stringify(url)})
- `
- }
- }
- }
但 Rollup 的虛擬模塊也有一些限制,為了與真實模塊區(qū)分開,默認約定要在路徑前面拼上一個'\0'。這樣會對路徑產(chǎn)生一定的入侵性,直接放到瀏覽器進行 import 會出問題(Vite 內(nèi)部也將 \0 替換成 __xx 這種形式,以免直接將 帶\0 路徑放到瀏覽器中 import):
Esbuild 中對于虛擬模塊的支持更加友好一些,直接通過 namespace 來區(qū)分真實模塊和虛擬模塊,這樣也不會有 \0 這樣 hack 操作。
編譯能力
使用 Esbuild 的虛擬模塊,可以完成很豐富的功能,除了上述插件實例中在內(nèi)存中計算出 env 的值作為模塊內(nèi)容,還可以模塊名當做一個函數(shù)來進行編譯,甚至可以在編譯階段實現(xiàn)函數(shù)遞歸的過程。比如這個 Esbuild 插件:
- {
- name: 'fibo',
- setup(build) {
- build.onResolve({ filter: /^fib\(\d+\)/ }, args => {
- return { path: args.path, namespace: 'fib' }
- })
- build.onLoad({ filter: /^fib\(\d+\)/, namespace: 'fib' }, args => {
- const match = /^fib\((\d+)\)/.exec(args.path);
- n = Number(match[1]);
- console.log(n);
- let contents = n < 2 ? `export default ${n+1}` : `
- import n1 from 'fib(${n - 1})'
- import n2 from 'fib(${n - 2})'
- export default n1 + n2`
- return { contents }
- })
- }
- }
引入這個插件,可以解析如下的 import 語句:
- import fib5 from 'fib(5)'
- console.log(fib5)
- // 13
所有的模塊都是虛擬模塊,在真實文件系統(tǒng)中并不存在
另外,還能借助虛擬模塊來進行 URL Import,支持如下的 import 代碼:
- import React from 'https://esm.sh/react@17'
這也可以在插件當中實現(xiàn),可參考示例。
落地場景
1. 代碼壓縮工具
Esbuild 的代碼壓縮功能非常優(yōu)秀,可以甩開傳統(tǒng)的壓縮工具一個量級以上的性能差距。Vite 在 2.6 版本也官宣在生產(chǎn)環(huán)境中直接使用 Esbuild 來壓縮 JS 和 CSS 代碼。
2. 代替 ts-node
社區(qū)已經(jīng)有了相應的方案 esno: https://github.com/antfu/esno
- ts-node index.ts
- // 替換為
- esno hello.ts
3. 代替 ts-jest
使用 esbuild-jest 代替ts-jest,我曾經(jīng)嘗試在某些大型包中使用 esbuild-jest 來作為 transformer,相比 ts-jest,整體大概提升 3 倍測試效率。
Github 地址:https://github.com/aelbore/esbuild-jest
4. 第三方庫 Bundler
Vite 中在開發(fā)階段使用 Esbuild 來進行依賴的預打包,將所有用到的第三方依賴轉成 ESM 格式 Bundle 產(chǎn)物,并且未來有用到生產(chǎn)環(huán)境的打算。
同時業(yè)界也有一些平臺基于純 Esbuild 來做線上 cjs -> esm 的 CDN 服務,比如 esm.sh 和 skypack:
5. 打包 Node 庫
為什么要打包 Node 庫:
- 減少 node_modules 代碼,避免業(yè)務安裝一大堆 node_modules 的代碼,減少安裝體積
- 提高啟動速度,所有代碼打到一個文件,減少了大量的文件 io 操作
- 更安全。所有代碼打包也是鎖定依賴版本的一種方式,可以避免之前出現(xiàn)的 coa 包導致的大面積 CI 掛掉的問題,可參考云謙的這篇文章。
這方面 Esbuild 的作用跟現(xiàn)在 vercel 團隊出品的 ncc 差不多,但會對代碼的寫法有一些限制,無法分析動態(tài) require 或者 import 語句含有變量的情況:
6. 小程序編譯
對于小程序的場景,也可以使用 Esbuild 來代替 Webpack,大大提升編譯速度,對于 AST 的轉換則通過 Esbuild 插件嵌入 SWC 來實現(xiàn),實現(xiàn)快速編譯。詳見 132 的分享 esbuild 上生產(chǎn)。
7. Web 構建
Web 場景就顯得比較復雜了,對于兼容性和周邊工具生態(tài)的要求比較高,比如低瀏覽器語法降級、CSS 預編譯器、HMR 等等,如果要用純 Esbuild 來做,還需要補充很多能力。
之前三元同學基于 Esbuild 實現(xiàn)了一套 Web 開發(fā)腳手架 ewas,已經(jīng)在 Github 開源,并且已成功落地到我之前的小冊項目當中,相比 create-react-app 啟動速度提升了 100 倍以上(30s -> 0.3s)。倉庫地址: https://github.com/sanyuan0704/ewas。
如今 Remix 1.0 正式發(fā)布,底層使用 Esbuild 構建,帶來了極致的性能體驗,成為 Next.js 強有力的競爭對手。
但總體來說,目前 Esbuild 對于真實的 Web 場景還有很多能力不支持,還有一些硬傷,包括語法不支持降級到ES5,拆包不靈活、不支持 HMR,對于真正能作為 Webpack 一樣的構建工具來講還有很長的路要走。