基於esbuild的universal bundler設計

背景

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

什麼是bundler

bundler的工做就是將一系列經過模塊方式組織的代碼將其打包成一個或多個文件,咱們常見的bundler包括webpack、rollup、esbuild等。 這裏的模塊組織形式大部分指的是基於js的模塊系統,但也不排除其餘方式組織的模塊系統(如wasm、小程序的json的usingComponents,css和html的import等),其生成文件也可能不只僅是一個文件如(code spliting生成的多個js文件,或者生成不一樣的js、css、html文件等)。 大部分的bundler的核心工做原理都比較相似,可是其會偏重某些功能,如html

  • 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的對比

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

  • src/main.js
import lib from './lib';

console.log('lib:', lib);
複製代碼
  • src/lib.js
const answer = 42;
export default answer;
複製代碼

首先經過生成module graphreact

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();
複製代碼

輸出內容以下webpack

[
{
  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根據用戶的配置構建產物了。c++

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

生成內容以下git

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流程並不複雜,可是其若是要真正的應用於生產環境,支持複雜多樣的業務需求,就離不開其強大的插件系統。es6

插件系統

大部分的bundler都提供了插件系統,以支持用戶能夠本身定製bundler的邏輯。如rollup的插件分爲input插件和output插件,input插件對應的是根據輸入生成Module Graph的過程,而output插件則對應的是根據Module Graph生成產物的過程。 咱們這裏主要討論input插件,其是bundler插件系統的核心,咱們這裏以esbuild的插件系統爲例,來看看咱們能夠利用插件系統來作什麼。 input的核心流程就是生成依賴圖,依賴圖一個核心的做用就是肯定每一個模塊的源碼內容。input插件正提供瞭如何自定義模塊加載源碼的方式。 大部分的input 插件系統都提供了兩個核心鉤子github

  • 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

舉一個常見的場景,咱們開發一個相似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的插件體系,典型的就是 vitewmr, 不得不說給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 sokra.github.io/interop-tes… ,其二者語義上存在着自然的鴻溝,將ESM轉換成Commonjs通常問題不太大(當心避開default導出問題),可是將CJS轉換爲ESM則存在着更多的問題。 rollup-plugin-commonjs雖然在cjs2esm上下了不少功夫,可是實際仍然有很是多的edge case,實際上rollup也正在重寫該核心模塊 github.com/rollup/plug… 一些典型的問題以下

循環引用問題

因爲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來進行訪問。rollupjs.org/repl/ 便是採用此方式。所以咱們只須要將模塊的加載邏輯從fs裏替換爲瀏覽器上的memfs便可,onLoad這個hooks正能夠用於替換文件的讀取邏輯。

node module resolution

當咱們將文件訪問切換到memfs時,一個接踵而至的問題就是如何獲取一個require和import的id對應的實際路徑格式,node裏將一個id映射爲一個真實文件地址的算法就是 module resolution, 該算法實現較爲複雜須要考慮以下狀況,詳細算法見 tech.bytedance.net/articles/69…

  • 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,大幅提高速度:見 github.com/aelbore/esb… ,不過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實現零依賴的正面典型twitter.com/pnpmjs/stat…
  • 提升了冷啓動的速度,由於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部分,github.com/swc-project…

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的後處理都不支持

相關文章
相關標籤/搜索