當前位置:網站首頁>基於 esbuild 的 universal bundler 設計

基於 esbuild 的 universal bundler 設計

2022-01-28 07:06:00 字節跳動終端技術

——字節跳動前端 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 的對比

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 不够簡潔。。。)。 

 

 其通過徹底放弃對 cjs tree shaking 的支持來更好的兼容 cjs,並且同時可以在不引入插件的情况下,直接使得 web bundler 支持 cjs。

virutual module 的支持

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

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 等眾多庫的情况下)

這帶來了另一個好處就是你的 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 萬條事件量。

 

點擊這裏,立即申請

版權聲明
本文為[字節跳動終端技術]所創,轉載請帶上原文鏈接,感謝
https://cht.chowdera.com/2022/01/202201280705595239.html

隨機推薦