基於esbuild的universal bundler設計

語言: CN / TW / HK

——位元組跳動前端 ByteFE :楊健

背景

由於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與環境嗅tan,矛與盾之爭

polyfill和環境嗅tan是個爭鋒相對的功能,一方面polyfill儘可能抹平node和browser差異,另一方面環境嗅tan想盡可能從差異裡區分瀏覽器和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萬條事件量。

👉 點選這裡,立即申請