基於 esbuild 的 universal bundler 設計

位元組跳動終端技術發表於2022-01-26

——位元組跳動前端 Byte FE :楊健

基於esbuild的universal bundler設計

背景

由於 Lynx(公司自研跨端框架)編譯工具和傳統 Web 編譯工具鏈有較大的差別(如不支援動態 style 和動態 script 基本告別了 bundleless 和 code splitting,模組系統基於 json 而非 js,沒有瀏覽器環境),且有在 Web 端實時編譯(搭建系統)、web 端動態編譯(WebIDE),服務端實時編譯(服務端編譯下發)、和多版本切換等需求,因此我們需要開發一個即支援在本地也支援在瀏覽器工作且可以根據業務靈活定製開發的 bundler,即 universal bundler,在開發 universal bundler 的過程中也碰到了一些問題,最後我們基於 esbuild 開發了全新的 universal bundler,解決了我們碰到的大部分問題。

什麼是 bundler

bundler 的工作就是將一系列通過模組方式組織的程式碼將其打包成一個或多個檔案,我們常見的 bundler 包括 webpack、rollup、esbuild 等。 這裡的模組組織形式大部分指的是基於 js 的模組系統,但也不排除其他方式組織的模組系統(如 wasm、小程式的 json 的 usingComponents,css 和 html 的 import 等),其生成檔案也可能不僅僅是一個檔案如(code spliting 生成的多個 js 檔案,或者生成不同的 js、css、html 檔案等)。 大部分的 bundler 的核心工作原理都比較類似,但是其會偏重某些功能,如

  • webpack :強調對 web 開發的支援,尤其是內建了 HMR 的支援,外掛系統比較強大,對各種模組系統相容性最佳(amd,cjs,umd,esm 等,相容性好的有點過分了,這實際上有利有弊,導致面向 webpack 程式設計),有豐富的生態,缺點是產物不夠乾淨,產物不支援生成 esm 格式, 外掛開發上手較難,不太適合庫的開發。

  • rollup: 強調對庫開發的支援,基於 ESM 模組系統,對 tree shaking 有著良好的支援,產物非常乾淨,支援多種輸出格式,適合做庫的開發,外掛 api 比較友好,缺點是對 cjs 支援需要依賴外掛,且支援效果不佳需要較多的 hack,不支援 HMR,做應用開發時需要依賴各種外掛。

  • esbuild: 強調效能,內建了對 css、圖片、react、typescript 等內建支援,編譯速度特別快(是 webpack 和 rollup 速度的 100 倍+),缺點是目前外掛系統較為簡單,生態不如 webpack 和 rollup 成熟。

bundler 如何工作

bundler 的實現和大部分的編譯器的實現非常類似,也是採用三段式設計,我們可以對比一下

  • llvm: 將各個語言通過編譯器前端編譯到 LLVM IR,然後基於 LLVM IR 做各種優化,然後基於優化後的 LLVM IR 根據不同處理器架構生成不同的 cpu 指令集程式碼。

  • bundler: 將各個模組先編譯為 module graph,然後基於 module graph 做 tree shaking && code spliting &&minify 等優化,最後將優化後的 module graph 根據指定的 format 生成不同格式的 js 程式碼。

LLVM 和 bundler 的對比

基於 esbuild 的 universal bundler 設計

GJWJP 這也使得傳統的 LLVM 的很多編譯優化策略實際上也可在 bundler 中進行,esbuild 就是將這一做法推廣到極致的例子。 因為 rollup 的功能和架構較為精簡,我們以 rollup 為例看看一個 bundler 的是如何工作的。 rollup 的 bundle 過程分為兩步 rollup 和 generate,分別對應了 bundler 前端和 bundler 後端兩個過程。

  • src/main.js

 

import lib from './lib';
console.log('lib:', lib);
 
 
 
複製程式碼
 
  • src/lib.js

 
const answer = 42;export default answer;
 
 
 
複製程式碼
 

首先通過生成 module graph

 
const rollup = require('rollup');const util = require('util');async function main() {  const bundle = await rollup.rollup({    input: ['./src/index.js'],  });  console.log(util.inspect(bundle.cache.modules, { colors: true, depth: null }));}main();
 
 
 
複製程式碼
 

輸出內容如下

 
[{  code: 'const answer = 42;\nexport default answer;\n',  ast: xxx,  depenencies: [],  id: 'Users/admin/github/neo/examples/rollup-demo/src/lib.js'  ...},{  ast: xxx,  code: 'import lib from './lib';\n\nconsole.log('lib:', lib);\n',  dependencies: [ '/Users/admin/github/neo/examples/rollup-demo/src/lib.js' ]  id: '/Users/admin/github/neo/examples/rollup-demo/src/index.js',  ...}]
 
 
 
複製程式碼
 

我們的生成產物裡已經包含的各個模組解析後的 ast 結構,以及模組之間的依賴關係。 待構建完 module graph,rollup 就可以繼續基於 module graph 根據使用者的配置構建產物了。

 
 const result = await bundle.generate({    format: 'cjs',  });  console.log('result:', result);
 
 
 
複製程式碼
 

生成內容如下

 
exports: [],      facadeModuleId: '/Users/admin/github/neo/examples/rollup-demo/src/index.js',      isDynamicEntry: false,      isEntry: true,      type: 'chunk',      code: "'use strict';\n\nconst answer = 42;\n\nconsole.log('lib:', answer);\n",      dynamicImports: [],      fileName: 'index.js',
 
 
 
複製程式碼
 

所以一個基本的 JavaScript 的 bundler 流程並不複雜,但是其如果要真正的應用於生產環境,支援複雜多樣的業務需求,就離不開其強大的外掛系統。

外掛系統

大部分的 bundler 都提供了外掛系統,以支援使用者可以自己定製 bundler 的邏輯。如 rollup 的外掛分為 input 外掛和 output 外掛,input 外掛對應的是根據輸入生成 Module Graph 的過程,而 output 外掛則對應的是根據 Module Graph 生成產物的過程。 我們這裡主要討論 input 外掛,其是 bundler 外掛系統的核心,我們這裡以 esbuild 的外掛系統為例,來看看我們可以利用外掛系統來做什麼。 input 的核心流程就是生成依賴圖,依賴圖一個核心的作用就是確定每個模組的原始碼內容。input 外掛正提供瞭如何自定義模組載入原始碼的方式。 大部分的 input 外掛系統都提供了兩個核心鉤子

  • onResolve(rollup 裡叫 resolveId, webpack 裡叫 factory.hooks.resolver): 根據一個 moduleid 決定實際的的模組地址

  • onLoad(rollup 裡叫 loadId,webpack 裡是 loader):根據模組地址載入模組內容)

load 這裡 esbuild 和 rollup 與 webpack 處理有所差異,esbuild 只提供了 load 這個 hooks,你可以在 load 的 hooks 裡做 transform 的工作,rollup 額外提供了 transform 的 hooks,和 load 的職能做了顯示的區分(但並不阻礙你在 load 裡做 transform),而 webpack 則將 transform 的工作下放給了 loader 去完成。 這兩個鉤子的功能看似雖小,組合起來卻能實現很豐富的功能。(外掛文件這塊,相比之下 webpack 的文件簡直垃圾) esbuild 外掛系統相比於 rollup 和 webpack 的外掛系統,最出色的就是對於 virtual module 的支援。我們簡單看幾個例子來展示外掛的作用。

loader

大家使用 webpack 最常見的一個需求就是使用各種 loader 來處理非 js 的資源,如匯入圖片 css 等,我們看一下如何用 esbuild 的外掛來實現一個簡單的 less-loader。

 
export const less = (): Plugin => {  return {    name: 'less',    setup(build) {      build.onLoad({ filter: /.less$/ }, async (args) => {        const content = await fs.promises.readFile(args.path);        const result = await render(content.toString());        return {          contents: result.css,          loader: 'css',        };      });    },  };};
 
 
 
複製程式碼
 

我們只需要在 onLoad 裡通過 filter 過濾我們想要處理的檔案型別,然後讀取檔案內容並進行自定義的 transform,然後將結果返回給 esbuild 內建的 css loader 處理即可。是不是十分簡單 大部分的 loader 的功能都可以通過 onLoad 外掛實現。

sourcemap && cache && error handle

上面的例子比較簡化,作為一個更加成熟的外掛還需要考慮 transform 後 sourcemap 的對映和自定義快取來減小 load 的重複開銷以及錯誤處理,我們來通過 svelte 的例子來看如何處理 sourcemap 和 cache 和錯誤處理。

 
let sveltePlugin = {  name: 'svelte',  setup(build) {    let svelte = require('svelte/compiler')    let path = require('path')    let fs = require('fs')    let cache = new LRUCache(); // 使用一個LRUcache來避免watch過程中記憶體一直上漲    build.onLoad({ filter: /.svelte$/ }, async (args) => {      let value = cache.get(args.path); // 使用path作為key      let input = await fs.promises.readFile(args.path, 'utf8');      if(value && value.input === input){         return value // 快取命中,跳過後續transform邏輯,節省效能      }      // This converts a message in Svelte's format to esbuild's format      let convertMessage = ({ message, start, end }) => {        let location        if (start && end) {          let lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]          let lineEnd = start.line === end.line ? end.column : lineText.length          location = {            file: filename,            line: start.line,            column: start.column,            length: lineEnd - start.column,            lineText,          }        }        return { text: message, location }      }
// Load the file from the file system let source = await fs.promises.readFile(args.path, 'utf8') let filename = path.relative(process.cwd(), args.path)
// Convert Svelte syntax to JavaScript try { let { js, warnings } = svelte.compile(source, { filename }) let contents = js.code + `//# sourceMappingURL=` + js.map.toUrl() // 返回sourcemap,esbuild會自動將整個鏈路的sourcemap進行merge return { contents, warnings: warnings.map(convertMessage) } // 將warning和errors上報給esbuild,經esbuild再上報給業務方 } catch (e) { return { errors: [convertMessage(e)] } } }) }}
require('esbuild').build({ entryPoints: ['app.js'], bundle: true, outfile: 'out.js', plugins: [sveltePlugin],}).catch(() => process.exit(1))
 
 
 
複製程式碼
 

至此我們實現了一個比較完整的 svelte-loader 的功能。

virtual module

esbuild 外掛相比 rollup 外掛一個比較大的改進就是對 virtual module 的支援,一般 bundler 需要處理兩種形式的模組,一種是路徑對應真是的磁碟裡的檔案路徑,另一種路徑並不對應真實的檔案路徑而是需要根據路徑形式生成對應的內容即 virtual module。 virtual module 有著非常豐富的應用場景。

glob import

舉一個常見的場景,我們開發一個類似https://rollupjs.org/repl/ 之類的 repl 的時候,通常需要將一些程式碼示例載入到 memfs 裡,然後在瀏覽器上基於 memfs 進行構建,但是如果例子涉及的檔案很多的話,一個個匯入這些檔案是很麻煩的,我們可以支援 glob 形式的匯入。 examples/

 
examples    index.html    index.tsx    index.css
 
 
 
複製程式碼
 

 

 
import examples from 'glob:./examples/**/*';import {vol}  from 'memfs';vol.fromJson(examples,'/'); //將本地的examples目錄掛載到memfs
 
 
 
複製程式碼
 

類似的功能可以通過vite或者 babel-plugin-macro 來實現,我們看看 esbuild 怎麼實現。 實現上面的功能其實非常簡單,我們只需要

  • 在 onResolve 裡將自定義的 path 進行解析,然後將後設資料通過 pluginData 和 path 傳遞給 onLoad,並且自定義一個 namespace(namespace 的作用是防止正常的 file load 邏輯去載入返回的路徑和給後續的 load 做 filter 的過濾)

  • 在 onLoad 裡通過 namespace 過濾拿到感興趣的 onResolve 返回的後設資料,根據後設資料自定義載入生成資料的邏輯,然後將生成的內容交給 esbuild 的內建 loader 處理

 
const globReg = /^glob:/;export const pluginGlob = (): Plugin => {  return {    name: 'glob',    setup(build) {      build.onResolve({ filter: globReg }, (args) => {        return {          path: path.resolve(args.resolveDir, args.path.replace(globReg, '')),          namespace: 'glob',          pluginData: {            resolveDir: args.resolveDir,          },        };      });      build.onLoad({ filter: /.*/, namespace: 'glob' }, async (args) => {        const matchPath: string[] = await new Promise((resolve, reject) => {          glob(            args.path,            {              cwd: args.pluginData.resolveDir,            },            (err, data) => {              if (err) {                reject(err);              } else {                resolve(data);              }            }          );        });        const result: Record<string, string> = {};        await Promise.all(          matchPath.map(async (x) => {            const contents = await fs.promises.readFile(x);            result[path.basename(x)] = contents.toString();          })        );        return {          contents: JSON.stringify(result),          loader: 'json',        };      });    },  };};
 
 
 
複製程式碼
 

esbuild 基於 filter 和 namespace 的過濾是出於效能考慮的,這裡的 filter 的正則是 golang 的正則,namespace 是字串,因此 esbuild 可以完全基於 filter 和 namespace 進行過濾而避免不必要的陷入到 js 的呼叫,最大程度減小 golang call js 的 overhead,但是仍然可以 filter 設定為/.*/來完全陷入到 js,在 js 裡進行過濾,實際的陷入開銷實際上還是能夠接受的。

virtual module 不僅可以從磁碟裡獲取內容,也可以直接記憶體裡計算內容,甚至可以把模組匯入當函式呼叫。

memory virtual module

這裡的 env 模組,完全是根據環境變數計算出來的

 
let envPlugin = {  name: 'env',  setup(build) {    // Intercept import paths called "env" so esbuild doesn't attempt    // to map them to a file system location. Tag them with the "env-ns"    // namespace to reserve them for this plugin.    build.onResolve({ filter: /^env$/ }, args => ({      path: args.path,      namespace: 'env-ns',    }))
// Load paths tagged with the "env-ns" namespace and behave as if // they point to a JSON file containing the environment variables. build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({ contents: JSON.stringify(process.env), loader: 'json', })) },}
// import { NODE_ENV } from 'env' // env為虛擬模組,
 
 
 
複製程式碼
 

function virtual module

把模組名當函式使用,完成編譯時計算,甚至支援遞迴函式呼叫。

 
 build.onResolve({ filter: /^fib((\d+))/ }, args => {            return { path: args.path, namespace: 'fib' }   })  build.onLoad({ filter: /^fib((\d+))/, namespace: 'fib' }, args => {        let match = /^fib((\d+))/.exec(args.path), n = +match[1]        let contents = n < 2 ? `export default ${n}` : `              import n1 from 'fib(${n - 1}) ${args.path}'              import n2 from 'fib(${n - 2}) ${args.path}'              export default n1 + n2`         return { contents }  })  // 使用方式  import fib5 from 'fib(5)' // 直接編譯器獲取fib5的結果,是不是有c++模板的味道
 
 
 
複製程式碼
 

stream import

不需要下載 node_modules 就可以進行 npm run dev

 
import { Plugin } from 'esbuild';import { fetchPkg } from './http';export const UnpkgNamepsace = 'unpkg';export const UnpkgHost = 'https://unpkg.com/';export const pluginUnpkg = (): Plugin => {  const cache: Record<string, { url: string; content: string }> = {};  return {    name: 'unpkg',    setup(build) {      build.onLoad({ namespace: UnpkgNamepsace, filter: /.*/ }, async (args) => {        const pathUrl = new URL(args.path, args.pluginData.parentUrl).toString();        let value = cache[pathUrl];        if (!value) {          value = await fetchPkg(pathUrl);        }        cache[pathUrl] = value;        return {          contents: value.content,          pluginData: {            parentUrl: value.url,          },        };      });      build.onResolve({ namespace: UnpkgNamepsace, filter: /.*/ }, async (args) => {        return {          namespace: UnpkgNamepsace,          path: args.path,          pluginData: args.pluginData,        };      });    },  };};
// 使用方式import react from 'react'; //會自動在編譯器轉換為 import react from 'https://unpkg.com/react'
 
 
 
複製程式碼
 

上面幾個例子可以看出,esbuild 的 virtual module 設計的非常靈活和強大,當我們使用 virtual module 時候,實際上我們的整個模組系統結構變成如下的樣子 無法複製載入中的內容 針對不同的場景我們可以選擇不同的 namespace 進行組合

  • 本地開發: 完全走本地 file 載入,即都走 file namespace

  • 本地開發免安裝 node_modules: 即類似 deno 和 snowpack 的streaming import的場景,可以通過業務檔案走 file namespace,node_modules 檔案走 unpkg namespace,比較適合超大型 monorepo 專案開發一個專案需要安裝所有的 node_modules 過慢的場景。

  • web 端實時編譯場景(效能和網路問題):即第三方庫是固定的,業務程式碼可能變化,則本地 file 和 node_modules 都走 memfs。

  • web 端動態編譯:即內網 webide 場景,此時第三方庫和業務程式碼都不固定,則本地 file 走 memfs,node_modules 走 unpkg 動態拉取

我們發現基於 virtual module 涉及的 universal bundler 非常靈活,能夠靈活應對各種業務場景,而且各個場景之間的開銷互不影響。

universal bundler

大部分的 bundler 都是預設執行在瀏覽器上,所以構造一個 universal bundler 最大的難點還是在於讓 bundler 執行在瀏覽器上。 區別於我們本地的 bundler,瀏覽器上的 bundler 存在著諸多限制,我們下面看看如果將一個 bundler 移植到瀏覽器上需要處理哪些問題。

rollup

首先我們需要選取一個合適的 bundler 來幫我們完成 bundle 的工作,rollup 就是一個非常優秀的 bundler,rollup 有著很多非常優良的性質

  • treeshaking 支援非常好,也支援 cjs 的 tree shaking

  • 豐富的外掛 hooks,具有非常靈活定製的能力

  • 支援執行在瀏覽器上

  • 支援多種輸出格式(esm,cjs,umd,systemjs)

正式因為上述優良的特性,所以很多最新的 bundler|bundleness 工具都是基於 rollup 或者相容 rollup 的外掛體系,典型的就是 vite 和wmr, 不得不說給 rollup 寫外掛比起給 webpack 寫外掛要舒服很多。 我們早期的 universal bundler 實際上就是基於 rollup 開發的,但是使用 rollup 過程中碰到了不少問題,總結如下

對 CommonJS 的相容問題

但凡在實際的業務中使用 rollup 進行 bundle 的同學,繞不開的一個外掛就是 rollup-plugin-commonjs,因為 rollup 原生只支援 ESM 模組的 bundle,因此如果實際業務中需要對 commonjs 進行 bundle,第一步就是需要將 CJS 轉換成 ESM,不幸的是,Commonjs 和 ES Module 的 interop 問題是個非常棘手的問題(搜一搜 babel、rollup、typescript 等工具下關於 interop 的 issue https://sokra.github.io/interop-test/ ,其兩者語義上存在著天然的鴻溝,將 ESM 轉換成 Commonjs 一般問題不太大(小心避開 default 匯出問題),但是將 CJS 轉換為 ESM 則存在著更多的問題。 rollup-plugin-commonjs 雖然在 cjs2esm 上下了很多功夫,但是實際仍然有非常多的 edge case,實際上 rollup 也正在重寫該核心模組 https://github.com/rollup/plugins/pull/658。 

一些典型的問題如下

迴圈引用問題

由於 commonjs 的匯出模組並非是 live binding 的,所以導致一旦出現了 commonjs 的迴圈引用,則將其轉換成 esm 就會出問題

動態 require 的 hoist 問題

同步的動態 require 幾乎無法轉換為 esm,如果將其轉換為 top-level 的 import,根據 import 的語義,bundler 需要將同步 require 的內容進行 hoist,但是這與同步 require 相違背,因此動態 require 也很難處理

Hybrid CJS 和 ESM

因為在一個模組裡混用 ESM 和 CJS 的語義並沒有一套標準的規範規定,雖然 webpack 支援在一個模組裡混用 CJS 和 ESM(downlevel to webpack runtime),但是 rollup 放棄了對該行為的支援(最新版可以條件開啟,我沒試過效果咋樣)

效能問題

正是因為 cjs2esm 的複雜性,導致該轉換演算法十分複雜,導致一旦業務裡包含了很多 cjs 的模組,rollup 其編譯效能就會急劇下降,這在編譯一些庫的時候可能不是大問題,但是用於大型業務的開發,其編譯速度難以接受。

瀏覽器上 cjs 轉 esm

另一方面雖然 rollup 可以較為輕鬆的移植到到 memfs 上,但是 rollup-plugin-commonjs 是很難移植到 web 上的,所以我們早期基於 rollup 做 web bundler 只能藉助於類似 skypack 之類的線上 cjs2esm 的服務來完成上述轉換,但是大部分這類服務其後端都是通過 rollup-plugin-commonjs 來實現的,因此 rollup 原有的那些問題並沒有擺脫,並且還有額外的網路開銷,且難以處理非 node_modules 裡 cjs 模組的處理。 幸運的是 esbuild 採取的是和 rollup 不同的方案,其對 cjs 的相容採取了類似 node 的 module wrapper,引入了一個非常小的執行時,來支援 cjs(webpack 實際上也是採用了執行時的方案來相容 cjs,但是他的 runtime 不夠簡潔。。。)。 

基於 esbuild 的 universal bundler 設計

 

 其通過徹底放棄對 cjs tree shaking 的支援來更好的相容 cjs,並且同時可以在不引入外掛的情況下,直接使得 web bundler 支援 cjs。

virutual module 的支援

rollup 的 virtual module 的支援比較 hack,依賴路徑前面拼上一個'\0',對路徑有入侵性,且對一些 ffi 的場景不太友好(c++ string 把'\0'視為終結符),當處理較為複雜的 virtual module 場景下,'\0'這種路徑非常容易處理出問題。 

基於 esbuild 的 universal bundler 設計

filesystem

本地的 bundler 都是訪問的本地檔案系統,但是在 browser 是不存在本地檔案系統的,因此如何訪問檔案呢,一般可以通過將 bundler 實現為與具體的 fs 無關來實現,所有的檔案訪問通過可配置的 fs 來進行訪問。https://rollupjs.org/repl/ 即是採用此方式。因此我們只需要將模組的載入邏輯從 fs 裡替換為瀏覽器上的 memfs 即可,onLoad 這個 hooks 正可以用於替換檔案的讀取邏輯。

node module resolution

當我們將檔案訪問切換到 memfs 時,一個接踵而至的問題就是如何獲取一個 require 和 import 的 id 對應的實際路徑格式,node 裡將一個 id 對映為一個真實檔案地址的演算法就是 module resolution, 該演算法實現較為複雜需要考慮如下情況,詳細演算法見 https://tech.bytedance.net/articles/6935059588156751880

  • file|index|目錄三種情形

  • js、json、addon 多檔案字尾

  • esm 和 cjs loader 區別

  • main field 處理

  • conditional exports 處理

  • exports subpath

  • NODE_PATH 處理

  • 遞迴向上查詢

  • symlink 的處理

除了 node module resolution 本身的複雜,我們可能還需要考慮 main module filed fallback、alias 支援、ts 等其他字尾支援等 webpack 額外支援但在社群比較流行的功能,yarn|pnpm|npm 等包管理工具相容等問題。自己從頭實現這一套演算法成本較大,且 node 的 module resolution 演算法一直在更新,webpack 的enhanced-resolve 模組基本上實現了上述功能,並且支援自定義 fs,可以很方便的將其移植到 memfs 上。

我覺得這裡 node 的演算法著實有點 over engineering 而且效率低下(一堆 fallback 邏輯有不小的 io 開銷),而且這也導致了萬惡之源 hoist 盛行的主要原因,也許 bare import 配合 import map,或者 deno|golang 這種顯示路徑更好一些。

main field

main field 也是個較為複雜的問題,主要在於沒有一套統一的規範,以及社群的庫並不完全遵守規範,其主要涉及包的分發問題,除了 main 欄位是 nodejs 官方支援的,module、browser、browser 等欄位各個 bundler 以及第三方社群庫並未達成一致意見如

  • cjs 和 esm,esnext 和 es5,node 和 browser,dev 和 prod 的入口該怎麼配置

  • module| main 裡的程式碼應該是 es5 還是 esnext 的(決定了 node_module 裡的程式碼是否需要走 transformer)

  • module 裡的程式碼是應該指向 browser 的實現還是指向 node 的實現(決定了 node bundler

和 browser bundler 情況下 main 和 module 的優先順序問題)

  • node 和 browser 差異的程式碼如何分發處理等等

unpkg

接下來我們就需要處理 node_modules 的模組了,此時有兩種方式,一種是將 node_modules 全量掛載到 memfs 裡,然後使用 enhanced-resolve 去 memfs 里載入對應的模組,另一種方式則是藉助於 unpkg,將 node_modules 的 id 轉換為 unpkg 的請求。這兩種方式都有其適用場景 第一種適合第三方模組數目比較固定(如果不固定,memfs 必然無法承載無窮的 node_modules 模組),而且 memfs 的訪問速度比網路請求訪問要快的多,因此非常適合搭建系統的實現。 第二種則適用第三方模組數目不固定,對編譯速度沒有明顯的實時要求,這種就比較適合類似 codesandbox 這種 webide 場景,業務可以自主的選擇其想要的 npm 模組。

shim 與 polyfill

web bundler 碰到的另一個問題就是大部分的社群模組都是圍繞 node 開發的,其會大量依賴 node 的原生 api,但是瀏覽器上並不會支援這些 api,因此直接將這些模組跑在瀏覽器上就會出問題。此時分為兩種情況

  • 一種是這些模組依賴的實際就是些 node 的 utily api 例如 utils、path 等,這些模組實際上並不依賴 node runtime,此時我們實際上是可以在瀏覽器上模擬這些 api 的,browserify 實際上就是為了解決這種場景的,其提供了大量的 node api 在瀏覽器上的 polyfill 如 path-browserify,stream-browserify 等等,

  • 另一種是瀏覽器和 node 的邏輯分開處理,雖然 node 的程式碼不需要在瀏覽器上執行,但是不期望 node 的實現一方面增大瀏覽器 bundle 包的體積和導致報錯,此時我們需要 node 相關的模組進行 external 處理即可。

一個小技巧,大部分的 bundler 配置 external 可能會比較麻煩或者沒辦法修改 bundler 的配置,我們只需要將 require 包裹在 eval 裡,大部分的 bundler 都會跳過 require 模組的打包。如 eval('require')('os')

polyfill 與環境嗅探,矛與盾之爭

polyfill 和環境嗅探是個爭鋒相對的功能,一方面 polyfill 儘可能抹平 node 和 browser 差異,另一方面環境嗅探想盡可能從差異裡區分瀏覽器和 node 環境,如果同時用了這倆功能,就需要各種 hack 處理了

webassembly

我們業務中依賴了 c++的模組,在本地環境下可以將 c++編譯為靜態庫通過 ffi 進行呼叫,但是在瀏覽器上則需要將其編譯為 webassembly 才能執行,但是大部分的 wasm 的大小都不小,esbuild 的 wasm 有 8M 左右,我們自己的靜態庫編譯出來的 wasm 也有 3M 左右,這對整體的包大小影響較大,因此可以借鑑 code split 的方案,將 wasm 進行拆分,將首次訪問可能用到的程式碼拆為 hot code,不太可能用到的拆為 cold code, 這樣就可以降低首次載入的包的體積。

我們可以在哪裡使用 esbuild

esbuild 有三個垂直的功能,既可以組合使用也可以完全獨立使用

  • minifier

  • transformer

  • bundler

更高效的 register 和 minify 工具

利用 esbuild 的 transform 功能,使用 esbuild-register 替換單元測試框架 ts-node 的 register,大幅提升速度:見 https://github.com/aelbore/esbuild-jest ,不過 ts-node 現在已經支援自定義 register 了,可以直接將 register 替換為 esbuild-register 即可,esbuild 的 minify 效能也是遠遠超過 terser(100 倍以上)

更高效的 prebundle 工具

在一些 bundleness 的場景,雖然不對業務程式碼進行 bundle,但是為了一方面防止第三方庫的 waterfall 和 cjs 的相容問題,通常需要對第三方庫進行 prebundle,esbuild 相比 rollup 是個更好的 prebundle 工具,實際上 vite 的最新版已經將 prebundle 功能從 rollup 替換為了 esbuild。

更好的線上 cjs2esm 服務

使用 esbuild 搭建 esm cdn 服務:esm.sh 就是如此

node bundler

相比於前端社群,node 社群似乎很少使用 bundle 的方案,一方面是因為 node 服務裡可能使用 fs 以及 addon 等對 bundle 不友好的操作,另一方面是大部分的 bundler 工具都是為了前端設計的,導致應用於 node 領域需要額外的配置。但是對 node 的應用或者服務進行 bundle 有著非常大的好處

  • 減小了使用方的 node_modules 體積和加快安裝速度,相比將 node 應用的一堆依賴一起安裝到業務的 node_modules 裡,只安裝 bundle 的程式碼大大減小了業務的安裝體積和加快了安裝速度,pnpm 和 yarn 就是使用 esbuild 將所有依賴 bundle 實現零依賴的正面典型https://twitter.com/pnpmjs/status/1353848140902903810?s=21

  • 提高了冷啟動的速度,因為 bundle 後的程式碼一方面通過 tree shaking 減小了引起實際需要 parse 的 js 程式碼大小(js 的 parse 開銷在大型應用的冷啟動速度上佔據了不小的比重,尤其是對冷啟動速度敏感的應用),另一方面避免了檔案 io,這兩方面都同時大大減小了應用冷啟動的速度,非常適合一些對冷啟動敏感的場景,如 serverless

  • 避免上游的 semver 語義破壞,雖然 semver 是一套社群規範,但是這實際上對程式碼要求非常嚴格,當引入了較多的第三方庫時,很難保證上游依賴不會破壞 semver 語義,因此 bundle 程式碼可以完全避免上游依賴出現 bug 導致應用出現 bug,這對安全性要求極高的應用(如編譯器)至關重要。

因此筆者十分鼓勵大家對 node 應用進行 bundle,而 esbuild 對 node 的 bundle 提供了開箱即用的支援。

tsc transformer 替代品

tsc 即使支援了增量編譯,其效能也極其堪憂,我們可以通過 esbuild 來代替 tsc 來編譯 ts 的程式碼。(esbuid 不支援 ts 的 type check 也不準備支援),但是如果業務的 dev 階段不強依賴 type checker,完全可以 dev 階段用 esbuild 替代 tsc,如果對 typechecker 有強要求,可以關注 swc,swc 正在用 rust 重寫 tsc 的 type checker 部分,https://github.com/swc-project/swc/issues/571

monorepo 與 monotools

esbuild 是少有的對庫開發和應用開發支援都比較良好的工具(webpack 庫支援不佳,rollup 應用開發支援不佳),這意味著你完全可以通過 esbuild 統一你專案的構建工具。 esbuild 原生支援 react 的開發,bundle 速度極其快,在沒有做任何 bundleness 之類的優化的情況下,一次的完整的 bundle 只需要 80ms(包含了 react,monaco-editor,emotion,mobx 等眾多庫的情況下)

基於 esbuild 的 universal bundler 設計

這帶來了另一個好處就是你的 monorepo 裡很方便的解決公共包的編譯問題。你只需要將 esbuild 的 main field 配置為['source','module','main'],然後在你公共庫裡將 source 指向你的原始碼入口,esbuild 會首先嚐試去編譯你公共庫的原始碼,esbuild 的編譯速度是如此之快,根本不會因為公共庫的編譯影響你的整體 bundle 速度。我只能說 TSC 不太適合用來跑編譯,too slow && too complex。

esbuild 存在的一些問題

除錯麻煩

esbuild 的核心程式碼是用 golang 編寫,使用者使用的直接是編譯出來的 binary 程式碼和一堆 js 的膠水程式碼,binary 程式碼幾乎沒法斷點除錯(lldb|gdb 除錯),每次除錯 esbuild 的程式碼,需要拉下程式碼重新編譯除錯,除錯要求較高,難度較大

只支援 target 到 es6

esbuild 的 transformer 目前只支援 target 到 es6,對於 dev 階段影響較小,但目前國內大部分都仍然需要考慮 es5 場景,因此並不能將 esbuild 的產物作為最終產物,通常需要配合 babel | tsc | swc 做 es6 到 es5 的轉換

golang wasm 的效能相比 native 有較大的損耗,且 wasm 包體積較大,

目前 golang 編譯出的 wasm 效能並不是很好(相比於 native 有 3-5 倍的效能衰減),並且 go 編譯出來 wasm 包體積較大(8M+),不太適合一些對包體積敏感的場景

外掛 api 較為精簡

相比於 webpack 和 rollup 龐大的外掛 api 支援,esbuild 僅支援了 onLoad 和 onResolve 兩個外掛鉤子,雖然基於此能完成很多工作,但是仍然較為匱乏,如 code spliting 後的 chunk 的後處理都不支援


? 火山引擎 APMPlus 應用效能監控是火山引擎應用開發套件 MARS 下的效能監控產品。我們通過先進的資料採集與監控技術,為企業提供全鏈路的應用效能監控服務,助力企業提升異常問題排查與解決的效率。

目前我們面向中小企業特別推出_「APMPlus 應用效能監控企業助力行動」_,為中小企業提供應用效能監控免費資源包。現在申請,有機會獲得 60 天免費效能監控服務,最高可享 6000 萬條事件量。

 

? 點選這裡,立即申請

相關文章