文章内容来源:
字节前端是如何基于 ESBuild 的做现代化打包设计? 字节前端是如何基于 ESBuild 的做现代化打包设计?
新世代建置工具解析(esbuild、Snowpack、Vite、wmr) 新世代建置工具解析(esbuild、Snowpack、Vite、wmr) - DEVLOG of andyyou
bundler的实现和大部分的编译器的实现非常类似,也是采用三段式设计,我们可以对比一下
llvm: 将各个语言通过编译器前端编译到LLVM IR,然后基于LLVM IR做各种优化,然后基于优化后的LLVM IR根据不同处理器架构生成不同的cpu指令集代码。
bundler: 将各个模块先编译为module graph,然后基于module graph做tree shaking && code spliting &&minify等优化,最后将优化后的module graph根据指定的format生成不同格式的js代码。
GJWJP 这也使得传统的LLVM的很多编译优化策略实际上也可在bundler中进行,esbuild就是将这一做法推广到极致的例子。
一个基本的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的支持。我们简单看几个例子来展示插件的作用。
大家使用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处理即可。
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 }) // 返回sourcemap,esbuild会自动将整个链路的sourcemap进行merge let contents = js.code + `//# sourceMappingURL=` + js.map.toUrl() // 将warning和errors上报给esbuild,经esbuild再上报给业务方 return { contents, warnings: warnings.map(convertMessage) } } 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/[1] 之类的repl的时候,通常需要将一些代码示例加载到memfs里,然后在浏览器上基于memfs进行构建,但是如果例子涉及的文件很多的话,一个个导入这些文件是很麻烦的,我们可以支持glob形式的导入。
//examples index.html index.tsx index.css import examples from 'glob:./examples/**/*'; import {vol} from 'memfs'; //将本地的examples目录挂载到memfs vol.fromJson(examples, '/');
类似的功能可以通过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= {}; 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模块,完全是根据环境变量计算出来的
不需要下载node_modules就可以进行npm run dev
esbuild的virtual module设计的非常灵活和强大,当我们使用virtual module时候,实际上我们的整个模块系统结构变成如下的样子 无法复制加载中的内容 针对不同的场景我们可以选择不同的namespace进行组合
本地开发: 完全走本地file加载,即都走file namespace
本地开发免安装node_modules: 即类似deno和snowpack的streaming import[3]的场景,可以通过业务文件走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非常灵活,能够灵活应对各种业务场景,而且各个场景之间的开销互不影响。
转载本站文章《新一代构建工具(2):从bundler角度看esbuild》,
请注明出处:新一代构建工具(2):从bundler角度看esbuild - vite学习与使用心得 - 周陆军的个人网站