——字節(jié)跳動前端 ByteFE :楊健
背景
由于Lynx(公司自研跨端框架)編譯工具和傳統(tǒng)Web編譯工具鏈有較大的差別(如不支持動態(tài)style和動態(tài)script基本告別了bundleless和code splitting,模塊系統(tǒng)基于json而非js,沒有瀏覽器環(huán)境),且有在Web端實時編譯(搭建系統(tǒng))、web端動態(tài)編譯(WebIDE),服務(wù)端實時編譯(服務(wù)端編譯下發(fā))、和多版本切換等需求,因此我們需要開發(fā)一個即支持在本地也支持在瀏覽器工作且可以根據(jù)業(yè)務(wù)靈活定制開發(fā)的bundler,即universal bundler,在開發(fā)universal bundler的過程中也碰到了一些問題,最后我們基于esbuild開發(fā)了全新的universal bundler,解決了我們碰到的大部分問題。
什么是bundler
bundler的工作就是將一系列通過模塊方式組織的代碼將其打包成一個或多個文件,我們常見的bundler包括webpack、rollup、esbuild等。 這里的模塊組織形式大部分指的是基于js的模塊系統(tǒng),但也不排除其他方式組織的模塊系統(tǒng)(如wasm、小程序的json的usingComponents,css和html的import等),其生成文件也可能不僅僅是一個文件如(code spliting生成的多個js文件,或者生成不同的js、css、html文件等)。 大部分的bundler的核心工作原理都比較類似,但是其會偏重某些功能,如
- webpack :強調(diào)對web開發(fā)的支持,尤其是內(nèi)置了HMR的支持,插件系統(tǒng)比較強大,對各種模塊系統(tǒng)兼容性最佳(amd,cjs,umd,esm等,兼容性好的有點過分了,這實際上有利有弊,導(dǎo)致面向webpack編程),有豐富的生態(tài),缺點是產(chǎn)物不夠干凈,產(chǎn)物不支持生成esm格式, 插件開發(fā)上手較難,不太適合庫的開發(fā)。
- rollup: 強調(diào)對庫開發(fā)的支持,基于ESM模塊系統(tǒng),對tree shaking有著良好的支持,產(chǎn)物非常干凈,支持多種輸出格式,適合做庫的開發(fā),插件api比較友好,缺點是對cjs支持需要依賴插件,且支持效果不佳需要較多的hack,不支持HMR,做應(yīng)用開發(fā)時需要依賴各種插件。
- esbuild: 強調(diào)性能,內(nèi)置了對css、圖片、react、typescript等內(nèi)置支持,編譯速度特別快(是webpack和rollup速度的100倍+),缺點是目前插件系統(tǒng)較為簡單,生態(tài)不如webpack和rollup成熟。
bundler如何工作
bundler的實現(xiàn)和大部分的編譯器的實現(xiàn)非常類似,也是采用三段式設(shè)計,我們可以對比一下
- llvm: 將各個語言通過編譯器前端編譯到LLVM IR,然后基于LLVM IR做各種優(yōu)化,然后基于優(yōu)化后的LLVM IR根據(jù)不同處理器架構(gòu)生成不同的cpu指令集代碼。
- bundler: 將各個模塊先編譯為module graph,然后基于module graph做tree shaking && code spliting &&minify等優(yōu)化,最后將優(yōu)化后的module graph根據(jù)指定的format生成不同格式的js代碼。
LLVM和bundler的對比
GJWJP 這也使得傳統(tǒng)的LLVM的很多編譯優(yōu)化策略實際上也可在bundler中進行,esbuild就是將這一做法推廣到極致的例子。 因為rollup的功能和架構(gòu)較為精簡,我們以rollup為例看看一個bundler的是如何工作的。 rollup的bundle過程分為兩步rollup和generate,分別對應(yīng)了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();
輸出內(nèi)容如下
[
{
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',
...
}]
我們的生成產(chǎn)物里已經(jīng)包含的各個模塊解析后的ast結(jié)構(gòu),以及模塊之間的依賴關(guān)系。 待構(gòu)建完module graph,rollup就可以繼續(xù)基于module graph根據(jù)用戶的配置構(gòu)建產(chǎn)物了。
const result = await bundle.generate({
format: 'cjs',
});
console.log('result:', result);
生成內(nèi)容如下
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流程并不復(fù)雜,但是其如果要真正的應(yīng)用于生產(chǎn)環(huán)境,支持復(fù)雜多樣的業(yè)務(wù)需求,就離不開其強大的插件系統(tǒng)。
插件系統(tǒng)
大部分的bundler都提供了插件系統(tǒng),以支持用戶可以自己定制bundler的邏輯。如rollup的插件分為input插件和output插件,input插件對應(yīng)的是根據(jù)輸入生成Module Graph的過程,而output插件則對應(yīng)的是根據(jù)Module Graph生成產(chǎn)物的過程。 我們這里主要討論input插件,其是bundler插件系統(tǒng)的核心,我們這里以esbuild的插件系統(tǒng)為例,來看看我們可以利用插件系統(tǒng)來做什么。 input的核心流程就是生成依賴圖,依賴圖一個核心的作用就是確定每個模塊的源碼內(nèi)容。input插件正提供了如何自定義模塊加載源碼的方式。 大部分的input 插件系統(tǒng)都提供了兩個核心鉤子
- onResolve(rollup 里叫resolveId, webpack里叫factory.hooks.resolver): 根據(jù)一個moduleid決定實際的的模塊地址
- onLoad(rollup里叫l(wèi)oadId,webpack里是loader):根據(jù)模塊地址加載模塊內(nèi)容)
load這里esbuild和rollup與webpack處理有所差異,esbuild只提供了load這個hooks,你可以在load的hooks里做transform的工作,rollup額外提供了transform的hooks,和load的職能做了顯示的區(qū)分(但并不阻礙你在load里做transform),而webpack則將transform的工作下放給了loader去完成。 這兩個鉤子的功能看似雖小,組合起來卻能實現(xiàn)很豐富的功能。(插件文檔這塊,相比之下webpack的文檔簡直垃圾) esbuild插件系統(tǒng)相比于rollup和webpack的插件系統(tǒng),最出色的就是對于virtual module的支持。我們簡單看幾個例子來展示插件的作用。
loader
大家使用webpack最常見的一個需求就是使用各種loader來處理非js的資源,如導(dǎo)入圖片css等,我們看一下如何用esbuild的插件來實現(xiàn)一個簡單的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過濾我們想要處理的文件類型,然后讀取文件內(nèi)容并進行自定義的transform,然后將結(jié)果返回給esbuild內(nèi)置的css loader處理即可。是不是十分簡單 大部分的loader的功能都可以通過onLoad插件實現(xiàn)。
sourcemap && cache && error handle
上面的例子比較簡化,作為一個更加成熟的插件還需要考慮transform后sourcemap的映射和自定義緩存來減小load的重復(fù)開銷以及錯誤處理,我們來通過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過程中內(nèi)存一直上漲
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 // 緩存命中,跳過后續(xù)transform邏輯,節(jié)省性能
}
// 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,經(jīng)esbuild再上報給業(yè)務(wù)方
} catch (e) {
return { errors: [convertMessage(e)] }
}
})
}
}
require('esbuild').build({
entryPoints: ['app.js'],
bundle: true,
outfile: 'out.js',
plugins: [sveltePlugin],
}).catch(() => process.exit(1))
至此我們實現(xiàn)了一個比較完整的svelte-loader的功能。
virtual module
esbuild插件相比rollup插件一個比較大的改進就是對virtual module的支持,一般bundler需要處理兩種形式的模塊,一種是路徑對應(yīng)真是的磁盤里的文件路徑,另一種路徑并不對應(yīng)真實的文件路徑而是需要根據(jù)路徑形式生成對應(yīng)的內(nèi)容即virtual module。 virtual module有著非常豐富的應(yīng)用場景。
glob import
舉一個常見的場景,我們開發(fā)一個類似https://rollupjs.org/repl/ 之類的repl的時候,通常需要將一些代碼示例加載到memfs里,然后在瀏覽器上基于memfs進行構(gòu)建,但是如果例子涉及的文件很多的話,一個個導(dǎo)入這些文件是很麻煩的,我們可以支持glob形式的導(dǎo)入。 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來實現(xiàn),我們看看esbuild怎么實現(xiàn)。 實現(xiàn)上面的功能其實非常簡單,我們只需要
- 在onResolve里將自定義的path進行解析,然后將元數(shù)據(jù)通過pluginData和path傳遞給onLoad,并且自定義一個namespace(namespace的作用是防止正常的file load邏輯去加載返回的路徑和給后續(xù)的load做filter的過濾)
- 在onLoad里通過namespace過濾拿到感興趣的onResolve返回的元數(shù)據(jù),根據(jù)元數(shù)據(jù)自定義加載生成數(shù)據(jù)的邏輯,然后將生成的內(nèi)容交給esbuild的內(nèi)置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進行過濾而避免不必要的陷入到j(luò)s的調(diào)用,最大程度減小golang call js的overhead,但是仍然可以filter設(shè)置為/.*/來完全陷入到j(luò)s,在js里進行過濾,實際的陷入開銷實際上還是能夠接受的。
virtual module不僅可以從磁盤里獲取內(nèi)容,也可以直接內(nèi)存里計算內(nèi)容,甚至可以把模塊導(dǎo)入當函數(shù)調(diào)用。
memory virtual module
這里的env模塊,完全是根據(jù)環(huán)境變量計算出來的
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
把模塊名當函數(shù)使用,完成編譯時計算,甚至支持遞歸函數(shù)調(diào)用。
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的結(jié)果,是不是有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'; //會自動在編譯器轉(zhuǎn)換為 import react from 'https://unpkg.com/react'
上面幾個例子可以看出,esbuild的virtual module設(shè)計的非常靈活和強大,當我們使用virtual module時候,實際上我們的整個模塊系統(tǒng)結(jié)構(gòu)變成如下的樣子 無法復(fù)制加載中的內(nèi)容 針對不同的場景我們可以選擇不同的namespace進行組合
- 本地開發(fā): 完全走本地file加載,即都走file namespace
- 本地開發(fā)免安裝node_modules: 即類似deno和snowpack的streaming import的場景,可以通過業(yè)務(wù)文件走file namespace,node_modules文件走unpkg namespace,比較適合超大型monorepo項目開發(fā)一個項目需要安裝所有的node_modules過慢的場景。
- web端實時編譯場景(性能和網(wǎng)絡(luò)問題):即第三方庫是固定的,業(yè)務(wù)代碼可能變化,則本地file和node_modules都走memfs。
- web端動態(tài)編譯:即內(nèi)網(wǎng)webide場景,此時第三方庫和業(yè)務(wù)代碼都不固定,則本地file走memfs,node_modules走unpkg動態(tài)拉取
我們發(fā)現(xiàn)基于virtual module涉及的universal bundler非常靈活,能夠靈活應(yīng)對各種業(yè)務(wù)場景,而且各個場景之間的開銷互不影響。
universal bundler
大部分的bundler都是默認運行在瀏覽器上,所以構(gòu)造一個universal bundler最大的難點還是在于讓bundler運行在瀏覽器上。 區(qū)別于我們本地的bundler,瀏覽器上的bundler存在著諸多限制,我們下面看看如果將一個bundler移植到瀏覽器上需要處理哪些問題。
rollup
首先我們需要選取一個合適的bundler來幫我們完成bundle的工作,rollup就是一個非常優(yōu)秀的bundler,rollup有著很多非常優(yōu)良的性質(zhì)
- treeshaking支持非常好,也支持cjs的tree shaking
- 豐富的插件hooks,具有非常靈活定制的能力
- 支持運行在瀏覽器上
- 支持多種輸出格式(esm,cjs,umd,systemjs)
正式因為上述優(yōu)良的特性,所以很多最新的bundler|bundleness工具都是基于rollup或者兼容rollup的插件體系,典型的就是 vite 和wmr, 不得不說給rollup寫插件比起給webpack寫插件要舒服很多。 我們早期的universal bundler實際上就是基于rollup開發(fā)的,但是使用rollup過程中碰到了不少問題,總結(jié)如下
對CommonJS的兼容問題
但凡在實際的業(yè)務(wù)中使用rollup進行bundle的同學(xué),繞不開的一個插件就是rollup-plugin-commonjs,因為rollup原生只支持ESM模塊的bundle,因此如果實際業(yè)務(wù)中需要對commonjs進行bundle,第一步就是需要將CJS轉(zhuǎn)換成ESM,不幸的是,Commonjs和ES Module的interop問題是個非常棘手的問題(搜一搜babel、rollup、typescript等工具下關(guān)于interop的issue https://sokra.github.io/interop-test/ ,其兩者語義上存在著天然的鴻溝,將ESM轉(zhuǎn)換成Commonjs一般問題不太大(小心避開default導(dǎo)出問題),但是將CJS轉(zhuǎn)換為ESM則存在著更多的問題。 rollup-plugin-commonjs雖然在cjs2esm上下了很多功夫,但是實際仍然有非常多的edge case,實際上rollup也正在重寫該核心模塊 https://github.com/rollup/plugins/pull/658。 一些典型的問題如下
循環(huán)引用問題
由于commonjs的導(dǎo)出模塊并非是live binding的,所以導(dǎo)致一旦出現(xiàn)了commonjs的循環(huán)引用,則將其轉(zhuǎn)換成esm就會出問題
動態(tài)require的hoist問題
同步的動態(tài)require幾乎無法轉(zhuǎn)換為esm,如果將其轉(zhuǎn)換為top-level的import,根據(jù)import的語義,bundler需要將同步require的內(nèi)容進行hoist,但是這與同步require相違背,因此動態(tài)require也很難處理
Hybrid CJS和ESM
因為在一個模塊里混用ESM和CJS的語義并沒有一套標準的規(guī)范規(guī)定,雖然webpack支持在一個模塊里混用CJS和ESM(downlevel to webpack runtime),但是rollup放棄了對該行為的支持(最新版可以條件開啟,我沒試過效果咋樣)
性能問題
正是因為cjs2esm的復(fù)雜性,導(dǎo)致該轉(zhuǎn)換算法十分復(fù)雜,導(dǎo)致一旦業(yè)務(wù)里包含了很多cjs的模塊,rollup其編譯性能就會急劇下降,這在編譯一些庫的時候可能不是大問題,但是用于大型業(yè)務(wù)的開發(fā),其編譯速度難以接受。
瀏覽器上cjs轉(zhuǎn)esm
另一方面雖然rollup可以較為輕松的移植到到memfs上,但是rollup-plugin-commonjs是很難移植到web上的,所以我們早期基于rollup做web bundler只能借助于類似skypack之類的在線cjs2esm的服務(wù)來完成上述轉(zhuǎn)換,但是大部分這類服務(wù)其后端都是通過rollup-plugin-commonjs來實現(xiàn)的,因此rollup原有的那些問題并沒有擺脫,并且還有額外的網(wǎng)絡(luò)開銷,且難以處理非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'視為終結(jié)符),當處理較為復(fù)雜的virtual module場景下,'\0'這種路徑非常容易處理出問題。
filesystem
本地的bundler都是訪問的本地文件系統(tǒng),但是在browser是不存在本地文件系統(tǒng)的,因此如何訪問文件呢,一般可以通過將bundler實現(xiàn)為與具體的fs無關(guān)來實現(xiàn),所有的文件訪問通過可配置的fs來進行訪問。https://rollupjs.org/repl/ 即是采用此方式。因此我們只需要將模塊的加載邏輯從fs里替換為瀏覽器上的memfs即可,onLoad這個hooks正可以用于替換文件的讀取邏輯。
node module resolution
當我們將文件訪問切換到memfs時,一個接踵而至的問題就是如何獲取一個require和import的id對應(yīng)的實際路徑格式,node里將一個id映射為一個真實文件地址的算法就是 module resolution, 該算法實現(xiàn)較為復(fù)雜需要考慮如下情況,詳細算法見 https://tech.bytedance.net/articles/6935059588156751880
- file|index|目錄三種情形
- js、json、addon多文件后綴
- esm和cjs loader區(qū)別
- main field處理
- conditional exports處理
- exports subpath
- NODE_PATH處理
- 遞歸向上查找
- symlink的處理
除了node module resolution本身的復(fù)雜,我們可能還需要考慮main module filed fallback、alias支持、ts等其他后綴支持等webpack額外支持但在社區(qū)比較流行的功能,yarn|pnpm|npm等包管理工具兼容等問題。自己從頭實現(xiàn)這一套算法成本較大,且node 的module resolution算法一直在更新,webpack的enhanced-resolve 模塊基本上實現(xiàn)了上述功能,并且支持自定義fs,可以很方便的將其移植到memfs上。
我覺得這里node的算法著實有點over engineering而且效率低下(一堆fallback邏輯有不小的io開銷),而且這也導(dǎo)致了萬惡之源hoist盛行的主要原因,也許bare import配合import map,或者deno|golang這種顯示路徑更好一些。
main field
main field也是個較為復(fù)雜的問題,主要在于沒有一套統(tǒng)一的規(guī)范,以及社區(qū)的庫并不完全遵守規(guī)范,其主要涉及包的分發(fā)問題,除了main字段是nodejs官方支持的,module、browser、browser等字段各個bundler以及第三方社區(qū)庫并未達成一致意見如
- cjs和esm,esnext和es5,node和browser,dev和prod的入口該怎么配置
- module| main 里的代碼應(yīng)該是es5還是esnext的(決定了node_module里的代碼是否需要走transformer)
- module里的代碼是應(yīng)該指向browser的實現(xiàn)還是指向node的實現(xiàn)(決定了node bundler
和browser bundler情況下main和module的優(yōu)先級問題)
- node和browser差異的代碼如何分發(fā)處理等等
unpkg
接下來我們就需要處理node_modules的模塊了,此時有兩種方式,一種是將node_modules全量掛載到memfs里,然后使用enhanced-resolve去memfs里加載對應(yīng)的模塊,另一種方式則是借助于unpkg,將node_modules的id轉(zhuǎn)換為unpkg的請求。這兩種方式都有其適用場景 第一種適合第三方模塊數(shù)目比較固定(如果不固定,memfs必然無法承載無窮的node_modules模塊),而且memfs的訪問速度比網(wǎng)絡(luò)請求訪問要快的多,因此非常適合搭建系統(tǒng)的實現(xiàn)。 第二種則適用第三方模塊數(shù)目不固定,對編譯速度沒有明顯的實時要求,這種就比較適合類似codesandbox這種webide場景,業(yè)務(wù)可以自主的選擇其想要的npm模塊。
shim 與 polyfill
web bundler碰到的另一個問題就是大部分的社區(qū)模塊都是圍繞node開發(fā)的,其會大量依賴node的原生api,但是瀏覽器上并不會支持這些api,因此直接將這些模塊跑在瀏覽器上就會出問題。此時分為兩種情況
- 一種是這些模塊依賴的實際就是些node的utily api例如utils、path等,這些模塊實際上并不依賴node runtime,此時我們實際上是可以在瀏覽器上模擬這些api的,browserify實際上就是為了解決這種場景的,其提供了大量的node api在瀏覽器上的polyfill如path-browserify,stream-browserify等等,
- 另一種是瀏覽器和node的邏輯分開處理,雖然node的代碼不需要在瀏覽器上執(zhí)行,但是不期望node的實現(xiàn)一方面增大瀏覽器bundle包的體積和導(dǎo)致報錯,此時我們需要node相關(guān)的模塊進行external處理即可。
一個小技巧,大部分的bundler配置external可能會比較麻煩或者沒辦法修改bundler的配置,我們只需要將require包裹在eval里,大部分的bundler都會跳過require模塊的打包。如eval('require')('os')
polyfill與環(huán)境嗅t(yī)an,矛與盾之爭
polyfill和環(huán)境嗅t(yī)an是個爭鋒相對的功能,一方面polyfill盡可能抹平node和browser差異,另一方面環(huán)境嗅t(yī)an想盡可能從差異里區(qū)分瀏覽器和node環(huán)境,如果同時用了這倆功能,就需要各種hack處理了
webassembly
我們業(yè)務(wù)中依賴了c++的模塊,在本地環(huán)境下可以將c++編譯為靜態(tài)庫通過ffi進行調(diào)用,但是在瀏覽器上則需要將其編譯為webassembly才能運行,但是大部分的wasm的大小都不小,esbuild的wasm有8M左右,我們自己的靜態(tài)庫編譯出來的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現(xiàn)在已經(jīng)支持自定義register了,可以直接將register替換為esbuild-register即可,esbuild的minify性能也是遠遠超過terser(100倍以上)
更高效的prebundle工具
在一些bundleness的場景,雖然不對業(yè)務(wù)代碼進行bundle,但是為了一方面防止第三方庫的waterfall和cjs的兼容問題,通常需要對第三方庫進行prebundle,esbuild相比rollup是個更好的prebundle工具,實際上vite的最新版已經(jīng)將prebundle功能從rollup替換為了esbuild。
更好的線上cjs2esm服務(wù)
使用esbuild搭建esm cdn服務(wù):esm.sh就是如此
node bundler
相比于前端社區(qū),node社區(qū)似乎很少使用bundle的方案,一方面是因為node服務(wù)里可能使用fs以及addon等對bundle不友好的操作,另一方面是大部分的bundler工具都是為了前端設(shè)計的,導(dǎo)致應(yīng)用于node領(lǐng)域需要額外的配置。但是對node的應(yīng)用或者服務(wù)進行bundle有著非常大的好處
- 減小了使用方的node_modules體積和加快安裝速度,相比將node應(yīng)用的一堆依賴一起安裝到業(yè)務(wù)的node_modules里,只安裝bundle的代碼大幅減小了業(yè)務(wù)的安裝體積和加快了安裝速度,pnpm和yarn就是使用esbuild將所有依賴bundle實現(xiàn)零依賴的正面典型https://twitter.com/pnpmjs/status/1353848140902903810?s=21
- 提高了冷啟動的速度,因為bundle后的代碼一方面通過tree shaking減小了引起實際需要parse的js代碼大小(js的parse開銷在大型應(yīng)用的冷啟動速度上占據(jù)了不小的比重,尤其是對冷啟動速度敏感的應(yīng)用),另一方面避免了文件io,這兩方面都同時大幅減小了應(yīng)用冷啟動的速度,非常適合一些對冷啟動敏感的場景,如serverless
- 避免上游的semver語義破壞,雖然semver是一套社區(qū)規(guī)范,但是這實際上對代碼要求非常嚴格,當引入了較多的第三方庫時,很難保證上游依賴不會破壞semver語義,因此bundle代碼可以完全避免上游依賴出現(xiàn)bug導(dǎo)致應(yīng)用出現(xiàn)bug,這對安全性要求極高的應(yīng)用(如編譯器)至關(guān)重要。
因此筆者十分鼓勵大家對node應(yīng)用進行bundle,而esbuild對node的bundle提供了開箱即用的支持。
tsc transformer替代品
tsc即使支持了增量編譯,其性能也極其堪憂,我們可以通過esbuild來代替tsc來編譯ts的代碼。(esbuid不支持ts的type check也不準備支持),但是如果業(yè)務(wù)的dev階段不強依賴type checker,完全可以dev階段用esbuild替代tsc,如果對typechecker有強要求,可以關(guān)注swc,swc正在用rust重寫tsc的type checker部分,https://github.com/swc-project/swc/issues/571
monorepo與monotools
esbuild是少有的對庫開發(fā)和應(yīng)用開發(fā)支持都比較良好的工具(webpack庫支持不佳,rollup應(yīng)用開發(fā)支持不佳),這意味著你完全可以通過esbuild統(tǒng)一你項目的構(gòu)建工具。 esbuild原生支持react的開發(fā),bundle速度極其快,在沒有做任何bundleness之類的優(yōu)化的情況下,一次的完整的bundle只需要80ms(包含了react,monaco-editor,emotion,mobx等眾多庫的情況下)這帶來了另一個好處就是你的monorepo里很方便的解決公共包的編譯問題。你只需要將esbuild的main field配置為['source','module','main'],然后在你公共庫里將source指向你的源碼入口,esbuild會首先嘗試去編譯你公共庫的源碼,esbuild的編譯速度是如此之快,根本不會因為公共庫的編譯影響你的整體bundle速度。我只能說TSC不太適合用來跑編譯,too slow && too complex。
esbuild存在的一些問題
調(diào)試麻煩
esbuild的核心代碼是用golang編寫,用戶使用的直接是編譯出來的binary代碼和一堆js的膠水代碼,binary代碼幾乎沒法斷點調(diào)試(lldb|gdb調(diào)試),每次調(diào)試esbuild的代碼,需要拉下代碼重新編譯調(diào)試,調(diào)試要求較高,難度較大
只支持target到es6
esbuild的transformer目前只支持target到es6,對于dev階段影響較小,但目前國內(nèi)大部分都仍然需要考慮es5場景,因此并不能將esbuild的產(chǎn)物作為最終產(chǎn)物,通常需要配合babel | tsc | swc做es6到es5的轉(zhuǎn)換
golang wasm的性能相比native有較大的損耗,且wasm包體積較大,
目前golang編譯出的wasm性能并不是很好(相比于native有3-5倍的性能衰減),并且go編譯出來wasm包體積較大(8M+),不太適合一些對包體積敏感的場景
插件api較為精簡
相比于webpack和rollup龐大的插件api支持,esbuild僅支持了onLoad和onResolve兩個插件鉤子,雖然基于此能完成很多工作,但是仍然較為匱乏,如code spliting后的chunk的后處理都不支持
?? 火山引擎 APMPlus 應(yīng)用性能監(jiān)控是火山引擎應(yīng)用開發(fā)套件 MARS 下的性能監(jiān)控產(chǎn)品。我們通過先進的數(shù)據(jù)采集與監(jiān)控技術(shù),為企業(yè)提供全鏈路的應(yīng)用性能監(jiān)控服務(wù),助力企業(yè)提升異常問題排查與解決的效率。
目前我們面向中小企業(yè)特別推出「APMPlus 應(yīng)用性能監(jiān)控企業(yè)助力行動」,為中小企業(yè)提供應(yīng)用性能監(jiān)控免費資源包。現(xiàn)在申請,有機會獲得60天免費性能監(jiān)控服務(wù),最高可享6000萬條事件量。
?? 點擊這里,立即申請