因爲Lynx(公司自研跨端框架)編譯工具和傳統Web編譯工具鏈有較大的差異(如不支持動態style和動態script基本告別了bundleless和code splitting,模塊系統基於json而非js,沒有瀏覽器環境),且有在Web端實時編譯(搭建系統)、web端動態編譯(WebIDE),服務端實時編譯(服務端編譯下發)、和多版本切換等需求,所以咱們須要開發一個即支持在本地也支持在瀏覽器工做且能夠根據業務靈活定製開發的bundler,即universal bundler,在開發universal bundler的過程當中也碰到了一些問題,最後咱們基於esbuild開發了全新的universal bundler,解決了咱們碰到的大部分問題。css
bundler的工做就是將一系列經過模塊方式組織的代碼將其打包成一個或多個文件,咱們常見的bundler包括webpack、rollup、esbuild等。 這裏的模塊組織形式大部分指的是基於js的模塊系統,但也不排除其餘方式組織的模塊系統(如wasm、小程序的json的usingComponents,css和html的import等),其生成文件也可能不只僅是一個文件如(code spliting生成的多個js文件,或者生成不一樣的js、css、html文件等)。 大部分的bundler的核心工做原理都比較相似,可是其會偏重某些功能,如html
bundler的實現和大部分的編譯器的實現很是相似,也是採用三段式設計,咱們能夠對比一下前端
GJWJP 這也使得傳統的LLVM的不少編譯優化策略實際上也可在bundler中進行,esbuild就是將這一作法推廣到極致的例子。 由於rollup的功能和架構較爲精簡,咱們以rollup爲例看看一個bundler的是如何工做的。 rollup的bundle過程分爲兩步rollup和generate,分別對應了bundler前端和bundler後端兩個過程。node
import lib from './lib';
console.log('lib:', lib);
複製代碼
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
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的支持。咱們簡單看幾個例子來展現插件的做用。
你們使用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插件實現。
上面的例子比較簡化,做爲一個更加成熟的插件還須要考慮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的功能。
esbuild插件相比rollup插件一個比較大的改進就是對virtual module的支持,通常bundler須要處理兩種形式的模塊,一種是路徑對應真是的磁盤裏的文件路徑,另外一種路徑並不對應真實的文件路徑而是須要根據路徑形式生成對應的內容即virtual module。 virtual module有着很是豐富的應用場景。
舉一個常見的場景,咱們開發一個相似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怎麼實現。 實現上面的功能其實很是簡單,咱們只須要
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不只能夠從磁盤裏獲取內容,也能夠直接內存裏計算內容,甚至能夠把模塊導入當函數調用。
這裏的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爲虛擬模塊,
複製代碼
把模塊名當函數使用,完成編譯時計算,甚至支持遞歸函數調用。
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++模板的味道
複製代碼
不須要下載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進行組合
咱們發現基於virtual module涉及的universal bundler很是靈活,可以靈活應對各類業務場景,並且各個場景之間的開銷互不影響。
大部分的bundler都是默認運行在瀏覽器上,因此構造一個universal bundler最大的難點仍是在於讓bundler運行在瀏覽器上。 區別於咱們本地的bundler,瀏覽器上的bundler存在着諸多限制,咱們下面看看若是將一個bundler移植到瀏覽器上須要處理哪些問題。
首先咱們須要選取一個合適的bundler來幫咱們完成bundle的工做,rollup就是一個很是優秀的bundler,rollup有着不少很是優良的性質
正式由於上述優良的特性,因此不少最新的bundler|bundleness工具都是基於rollup或者兼容rollup的插件體系,典型的就是 vite 和wmr, 不得不說給rollup寫插件比起給webpack寫插件要舒服不少。 咱們早期的universal bundler實際上就是基於rollup開發的,可是使用rollup過程當中碰到了很多問題,總結以下
但凡在實際的業務中使用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幾乎沒法轉換爲esm,若是將其轉換爲top-level的import,根據import的語義,bundler須要將同步require的內容進行hoist,可是這與同步require相違背,所以動態require也很難處理
由於在一個模塊裏混用ESM和CJS的語義並無一套標準的規範規定,雖然webpack支持在一個模塊裏混用CJS和ESM(downlevel to webpack runtime),可是rollup放棄了對該行爲的支持(最新版能夠條件開啓,我沒試過效果咋樣)
正是由於cjs2esm的複雜性,致使該轉換算法十分複雜,致使一旦業務裏包含了不少cjs的模塊,rollup其編譯性能就會急劇降低,這在編譯一些庫的時候可能不是大問題,可是用於大型業務的開發,其編譯速度難以接受。
另外一方面雖然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。
rollup的virtual module的支持比較hack,依賴路徑前面拼上一個'\0',對路徑有入侵性,且對一些ffi的場景不太友好(c++ string把'\0'視爲終結符),當處理較爲複雜的virtual module場景下,'\0'這種路徑很是容易處理出問題。
本地的bundler都是訪問的本地文件系統,可是在browser是不存在本地文件系統的,所以如何訪問文件呢,通常能夠經過將bundler實現爲與具體的fs無關來實現,全部的文件訪問經過可配置的fs來進行訪問。rollupjs.org/repl/ 便是採用此方式。所以咱們只須要將模塊的加載邏輯從fs裏替換爲瀏覽器上的memfs便可,onLoad這個hooks正能夠用於替換文件的讀取邏輯。
當咱們將文件訪問切換到memfs時,一個接踵而至的問題就是如何獲取一個require和import的id對應的實際路徑格式,node裏將一個id映射爲一個真實文件地址的算法就是 module resolution, 該算法實現較爲複雜須要考慮以下狀況,詳細算法見 tech.bytedance.net/articles/69…
除了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字段是nodejs官方支持的,module、browser、browser等字段各個bundler以及第三方社區庫並未達成一致意見如
和browser bundler狀況下main和module的優先級問題)
接下來咱們就須要處理node_modules的模塊了,此時有兩種方式,一種是將node_modules全量掛載到memfs裏,而後使用enhanced-resolve去memfs里加載對應的模塊,另外一種方式則是藉助於unpkg,將node_modules的id轉換爲unpkg的請求。這兩種方式都有其適用場景 第一種適合第三方模塊數目比較固定(若是不固定,memfs必然沒法承載無窮的node_modules模塊),並且memfs的訪問速度比網絡請求訪問要快的多,所以很是適合搭建系統的實現。 第二種則適用第三方模塊數目不固定,對編譯速度沒有明顯的實時要求,這種就比較適合相似codesandbox這種webide場景,業務能夠自主的選擇其想要的npm模塊。
web bundler碰到的另外一個問題就是大部分的社區模塊都是圍繞node開發的,其會大量依賴node的原生api,可是瀏覽器上並不會支持這些api,所以直接將這些模塊跑在瀏覽器上就會出問題。此時分爲兩種狀況
一個小技巧,大部分的bundler配置external可能會比較麻煩或者沒辦法修改bundler的配置,咱們只須要將require包裹在eval裏,大部分的bundler都會跳過require模塊的打包。如eval('require')('os')
polyfill和環境嗅探是個爭鋒相對的功能,一方面polyfill儘量抹平node和browser差別,另外一方面環境嗅探想盡量從差別裏區分瀏覽器和node環境,若是同時用了這倆功能,就須要各類hack處理了
咱們業務中依賴了c++的模塊,在本地環境下能夠將c++編譯爲靜態庫經過ffi進行調用,可是在瀏覽器上則須要將其編譯爲webassembly才能運行,可是大部分的wasm的大小都不小,esbuild的wasm有8M左右,咱們本身的靜態庫編譯出來的wasm也有3M左右,這對總體的包大小影響較大,所以能夠借鑑code split的方案,將wasm進行拆分,將首次訪問可能用到的代碼拆爲hot code,不太可能用到的拆爲cold code, 這樣就能夠下降首次加載的包的體積。
esbuild有三個垂直的功能,既能夠組合使用也能夠徹底獨立使用
利用esbuild的transform功能,使用esbuild-register替換單元測試框架ts-node的register,大幅提高速度:見 github.com/aelbore/esb… ,不過ts-node如今已經支持自定義register了,能夠直接將register替換爲esbuild-register便可,esbuild的minify性能也是遠遠超過terser(100倍以上)
在一些bundleness的場景,雖然不對業務代碼進行bundle,可是爲了一方面防止第三方庫的waterfall和cjs的兼容問題,一般須要對第三方庫進行prebundle,esbuild相比rollup是個更好的prebundle工具,實際上vite的最新版已經將prebundle功能從rollup替換爲了esbuild。
使用esbuild搭建esm cdn服務:esm.sh就是如此
相比於前端社區,node社區彷佛不多使用bundle的方案,一方面是由於node服務裏可能使用fs以及addon等對bundle不友好的操做,另外一方面是大部分的bundler工具都是爲了前端設計的,致使應用於node領域須要額外的配置。可是對node的應用或者服務進行bundle有着很是大的好處
所以筆者十分鼓勵你們對node應用進行bundle,而esbuild對node的bundle提供了開箱即用的支持。
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…
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的核心代碼是用golang編寫,用戶使用的直接是編譯出來的binary代碼和一堆js的膠水代碼,binary代碼幾乎無法斷點調試(lldb|gdb調試),每次調試esbuild的代碼,須要拉下代碼從新編譯調試,調試要求較高,難度較大
esbuild的transformer目前只支持target到es6,對於dev階段影響較小,但目前國內大部分都仍然須要考慮es5場景,所以並不能將esbuild的產物做爲最終產物,一般須要配合babel | tsc | swc作es6到es5的轉換
目前golang編譯出的wasm性能並非很好(相比於native有3-5倍的性能衰減),而且go編譯出來wasm包體積較大(8M+),不太適合一些對包體積敏感的場景
相比於webpack和rollup龐大的插件api支持,esbuild僅支持了onLoad和onResolve兩個插件鉤子,雖然基於此能完成不少工做,可是仍然較爲匱乏,如code spliting後的chunk的後處理都不支持