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