⚡ 优化 webpack 开发体验以及依赖预编译方案
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
⚡想分享的是 webpack 中使用依赖预编译的手段优化编译环节,优化开发环境下的体验。从用缓存降低二次编译时间,到第三方依赖的预编译方案的需求和实践讨论,到具体的实际落地。
ps: 无论是 webpack4 的内存缓存、hard-source-webpack-plugin还是 webpack5 内置完善的 cache 方案,都能够在开发环境下将二次编译压缩到我们能够接受的时间,这篇文章的目的更多是在持久化缓存的基础上,探索更少的编译时间。
static_sites 项目是作为很多小项目的集合,目的是因为以前每个项目都开个仓库,Node、依赖之类的升级,导致很难维护,所以后来就创建了这个项目。
虽然整个项目没有多复杂,不像是很多一线公司数百个页面那种,但是仍然涉及到几十个小项目,有非常多的依赖,截止到当前已经将近上万的编译模块数量了,开发和打包时间在单机上已经很影响体验了。
后来优化了一次,引入了
hard-source-webpack-plugin 的强制缓存策略,在没有特别大缓存变动的情况下,开发环境下包括第三方依赖的8000个模块首次编译耗时50s,二次编译耗时控制在了15s以内,热更新影响不大,所以没特别留意。从头的打包时间仍然很难看。很好但是还是不够好。每次开发环境启动都需要那么久时间,每次启动项目敲完命令还要等个几十秒?No Way....。
除了性能优化,也做了一个插件来显示不同 entry 的编译进度,分析显示上万个模块的编译进度、关键耗时模块、耗时第三方依赖等,分析了下确实第三方依赖占用了很多的编译时间,是时候做点什么了。
先看结果(均为开发环境):
// ========== 仅 webpack5 + inner cache system首次编译耗时:setup 85msbuilding 97921mssealing 3279msemitting 393ms二次编译耗时:setup 27msbuilding 11231mssealing 1351msemitting 70ms// ========== 配置 prebundle 机制无缓存编译 49s二次编译 10s三次编译 10s// ========== 将 prebundle 同步到另一台机器带缓存编译 12s二次编译 10s二次编译 10s
之前 CRA 自带的是 webpack4,webpack5 已经发布了很长一段时间了,整个社区观望也足够了,想着能不能迁移过去,一是为了优化编译的速度和体验,另一方面也是为了适配最新,以便以后长期的使用。
webpack5 相比 4 较大的提升:
- 内置的增加持久化缓存
- 用更稳定的 hash 实现有效的长期缓存
- 更好的 Tree Shaking 和代码生成
- 内置社区非常多的最佳实践
- 优化技术架构以便以后更好的扩展
- 新特性:模块联邦、内置资源 loader、内置 worker loader、内置 json loader、自动处理 WebAssembly 等异步模块、import 支持 URI 协议、进度显示优化
所以 webpack5 确实内置解决了之前我们遇到的一些迫切问题,因为 eject 出来的配置,所以将项目工具依赖升级花费了点时间。最后结果说实话并没有提升多么明显,缓存只是替换了外部的方案,很多最佳实践也并没有把编译方案变革多夸张,最关心的编译逻辑仍然是 babel 编译一切。
编译时间因为 webpack4 hack 了一些持久逻辑,所以这里提升没那么明显。因为这些持久缓存逻辑缓存的都是过程代码,所以没有共享多台机器的办法。
esbuild 已经发布很久了,对编译的优化也是毋庸置疑的,除了没办法和 webpack 这种大而全且历经大规模测试的工具相比,毕竟术业有专攻,被 webpack 的生态养刁了的手,还是想要什么都有现成的。
单独使用 esbuild 作为 bundler 打包器来说,很多文件类型不支持、不支持 code splite、ast 缺失等等,直接用来编译项目目前还是不太友好的,而且项目中用了很多 webpack 插件,esbuild 生态直接切换还是有难度的。
esbuild 通过彻底放弃对cjs tree shaking的支持来更好的兼容cjs,并且同时可以在不引入插件的情况下,直接使得web bundler下支持cjs。
esbuild 有三个具体的功能方向:
- 打包器: bundler
- 编译器: transformer
- 压缩器: minifier
编译器、压缩器这部分工作可以使用 esbuild-loader 来加速 webpack 里面的旧逻辑。
node 环境下的 prebundle 是一个不同的问题,这里只是讲 web 开发环境下的方案。
太长不看:将第三方依赖整体提前打包,小心处理依赖共享、循环和互相依赖问题,然后让打包工具在开发阶段直接使用提前打包的产物,跳过冗长的第三方依赖打包过程,只编译打包业务代码,节省开发模式下的整体时间。
在编译耗时优化方面,有很多社区探索,基本上都是对打包体积、本地硬盘缓存、内存缓存、编译安全、缓存有效期等之间的平衡,webpack 本身也是一个异常开放的架构,允许这些实践的百花齐放。
之前对项目编译优化的时候,将模块的编译时间单独分析了一下,编译时间超过 1s 的依赖都详尽的记录了下来,发现有很多第三方依赖在开发环境启动的时候编译挺耗时的,一直想着这块怎么能优化一下。
第三方依赖为什么要编译呢?因为我们使用了他的 export 导出,不像是很久之前一个 min 文件引入即可,现代的打包手段能够分析出用到了哪些、没用哪些导出,按需打包,最后生成代码的时候甚至能够 tree-shaking 掉没用的代码,这些都是对打包体积的优化。比如整个 Antd 或者 lodash 之类的库或者框架,如果不引入依赖分析,直接全部打包的话,那包体积将会爆炸的。除此之外,我们还可以从源码出发编译出我们的目标平台适配代码,生成的资源更可控。
不过开发环境下第三方依赖编译耗时的问题对于大型项目确确实实是个问题,毕竟开发环境下,速度比体积更优先,第三方依赖在开发期间并不需要 tree-shaking,代码几乎不会变动,为什么不用最快最简单的提前打包呢?
无论是 webpack4 的内存缓存、
hard-source-webpack-plugin 还是 webpack5 内置完善的 cache 方案,都能够极大的将二次编译压缩到极致,在缓存安全和性能上都能够满足我们的使用了。照着现在国内社区的架势肯定有人说:你这啥都不懂,生产环境编译体验不重要,编译超过 30 分钟上 CI/CD 啊、完善制品管理流程,升级打包服务器 CPU 啊。项目膨胀?那是你项目不对。开发体验有缓存就够了。预编译没用,按需编译才对。现在没条件,那等社区完善啊。微应用模块拆分单独打包也没意义。你机器不行、公司基建不行、公司框架不行,你去研究xxx吧、你理解有问题、你多写点代码。
非常多的地方的讨论气氛都特别让人感觉进了早年 QQ 群,反正好像是只要没遇到的问题,别人就不应该折腾。
但人总是不知足,什么都想要:
- 缓存仍然需要一个耗时的首次编译。
- 黑盒的过程缓存没有代码级意义,自然也很难共享。
- 生产环境无法收益。
- 有比 webpack 更快的编译方法
- 更小的内存占用
DllPlugin 算是动态链接的实现,能够通过配置,将第三方的依赖单独提前打包,生成索引关系,然后交给 webpack 来跳过第三方依赖的打包,能够节省很多第三方依赖的频繁处理,实际证明也确实能够极大的优化编译时间。
很多的第三方库包括 React 和 Vue 都放弃了 DLL,所以好多人都说 DLL 是过去时,不能再用了。我倒觉得只是方案不同,有些方案是将编译结果缓存从而跳过频繁编译,DLL 是将编译结果生成到了 bundle,都是能够优化编译时间的,一个缓存了过程黑盒,一个缓存了最终结果,两个不同的技术缓存方向。
但是 DllPlugin 还是存在较多问题的,通用性不好、配置复杂,依赖不好处理、流程侵入强等,无法按需加载,多应用无法共用,缺乏动态性的特点。实际用 DLL 来做主要优化手段的团队越来越少了,没必要花太多精力扣一个复杂的实现。
不同叫法吧,有些叫 ModuleGraph、ModuleMap 等等,这种之前大厂比较喜欢的方案,也基本上内部工具都有各自的实现,具体核心的方法也是细化依赖关系,运行时按需加载。
处理方法基本上就是将不同的模块提前打包,然后生成一份 json 或者什么格式的依赖关系图,在加载主应用之前,先根据需要的依赖,去加载提前打包好的模块,加载完毕之后再运行主应用的逻辑,或者干脆都将外部依赖做成异步导入,连依赖关系都几乎不用分析了。
也有的是只生成依赖关系图,然后根据不同的编译目标,再做 tree-shaking 和 bundle,能有更好的适配灵活性,甚至可以上生产环境。
模块的引入也基本上是 hack/hook 进 webpack_require,替换或者拼接各个依赖的 chunk。
其实这种实现方案和 webpack 5 的 Module Federation 感觉原理是一个意思,只是 Module Federation 的方案更加更符合官方的 runtime。
目前有很多的打包工具提出了依赖预编译的方案,包括互联网大厂等也在依赖预编译上开始琢磨了,都是算是逐渐膨胀的业务编译时间的极致优化吧。
- vite https://cn.vitejs.dev/guide/dep-pre-bundling.html
- mfsu https://umijs.org/zh-CN/docs/mfsu
- taro https://github.com/NervJS/taro/discussions/11533
- Lynx 基于 esbuild 的 universal bundler 设计 - 知乎 (zhihu.com) esbuild 的 universal bundler 设计
- ICE 依赖打包 面对 ESM 的开发模式,webpack 还有还手之力吗?-阿里云开发者社区 (aliyun.com)
- 探索 webpack5 新特性 Module federation 在腾讯文档的应用 | AlloyTeam
所以腾讯、阿里、字节、京东等互联网大厂都在这个方向上进行了探索并有产出。
你可以在这个仓库中看到各个大厂的相关应用: 模块-联合/模块-联合-示例:模块联合的实现示例,由模块联合的创建者提供 (github.com)
实现方案的源码:
- vite https://github.com/vitejs/vite/blob/main/packages/vite/src/node/optimizer/registerMissing.ts
- umi.js umi-next/mfsu.ts at master · umijs/umi-next (github.com)
- taro https://github.com/NervJS/taro/blob/feat%2Fwebpack5/packages/taro-webpack5-runner/src/prebundle/index.ts
虽然各自的实现不同,但是 webpack 这边都是使用 Module Federation 的加载方案做的,而且根据他们的结论,这种预打包的方案对于编译的提升是非常夸张的,甚至一分多钟能优化到 1s以内。
不过看他们的源码,虽然方案独立,但是仍然有非常多的框架风格和非核心代码,封装的太“高级”,直接拿来用我也是一脸懵,不如自己捋一下消化消化,后期用到业务中。本文实际翻看并参考了他们非常多的实现思路和技巧,不过因为最后我的实现比较传统,再加上篇幅有限也就不一一对比贴出来了。
主要思想都是分析依赖关系,根据依赖引入来提前打包,最后通过加载或者模块联邦来无感代替用户的引入。
- 收集对 node_modules 的引用
- 分析依赖关系,以便处理互相依赖问题
- 打包依赖: 将依赖打包成 bundle(ESM/Commonjs)
- 通过各自的模块引入方案被 import
所以关键点在于怎么打包,以及打包好的 bundle 怎么被引入,这两点的实现其实方法挺开放的。
webpack、esbuild、Rollup、SnowPack等,只要能打包出 esm 或者 commonjs 标准的包就行,我们如果仅针对开发环境的浏览器,那么只要直接能够被引用即可。
甚至很多的第三方库都在 dist 目录下提供了打包好的 min.js 或者 umd/esm 标准的文件,免打包直接拿来用即可,还有些线上的 esm CDN 服务 esm.sh / skypack 等。
至于本地环境下,目前社区更多使用 esbuild 来打包,毕竟第三方依赖的复杂度可控,esbuild 应该是目前最好的选择,留意一下互相引用问题即可。
前端的 bundle 可以说真的是太难了,cjs、iife、esm,甚至还有 amd、umd、system.js 之类。总要确定一个吧,至少让引入 bundle 的处理不用各种兼容了。
cjs 是 node 环境下的包,虽然实现容易,兼容也挺好,但是需要 runtime 且 tree-shaking 或相关的分析依赖不好做。
iife 就是传说中的闭包,这个封装大概率会出现重复执行的问题。
esm 是 ES 规范下的标准模块,但是可能存在异步逻辑,不太符合 webpack 风格,对开发也有影响
不过 webpack 打包也都是不用太关心,而且开发环境,不太担心缺点,能跑就行,优先 esm,其次 cjs。
打包和包格式都不是什么大问题,已经有很多实践了,怎么用呢?毕竟业务代码不再打包到一起了,所以 runtime 的加载器必须考虑异步加载、共享模块等一堆前置处理。
引入方法和打包的产物相关性较高,甚至分为 node 端 / browser端,区分cjs、esm、iife甚至 module graph 的产物。
打包工具或者浏览器本身对 esm 或 commonjs 的支持都挺好的了,尤其是开发环境下更关心速度而不是兼容的情况下,比如:
- vite 之类的 esm 开发工具直接使用,无需什么特别的引入。
- 打包出 esm/commonjs 然后 inject 到 html 中,打包时设为 externals。简单粗暴但有效,只是要处理不同的 entry,同时也有依赖和顺序的问题。
- 打包之后 inject 到 entry 中,需要考虑顺序的问题。
- 作为 alias 代替之前的引用。
以上的方法什么的都可以用,手搓一个自然也就是费些脑细胞,前提也都是需要处理好复杂的依赖关系,异步加载逻辑等必要的需求。
我们需要业务和第三方依赖模块的 exportMap 和 importMap,递归处理所有需要引入的文件,然后根据不同的包格式引入进来,然后按顺序执行业务逻辑就行。关键就是这个 exportMap 和 importMap 的收集,在工程化中,在上面的打包中我们也能够用
es_module_lexer 之类的工具收集这些,最后我们做一个加载器也就行。不过 webpack5 已经帮我们实现了一个跨模块的导出导入方案:webpack Module Federation,可以作为我们的依赖解决方案。当然,也并不是唯一的。
Module Federation 的概念和实现都并不是很新的东西,只不过是 webpack 官方方案,所以比较适合 webpack 的 runtime 和编译逻辑,这篇文章也无意做源码级别的分析,这里只探索怎么用的问题。
一句话就是:编译产物 bundle 可以互相加载、依赖共享、远端免构建、webpack 风格
推荐看下腾讯文档的早期探索 探索 webpack5 新特性 Module federation 在腾讯文档的应用 | AlloyTeam,很详尽且有参考意义。
关键点在 动态加载机制:
- webpack 通过 Module Federation 插件机制,将 remote 暴露出来的模块生成了一份依赖图
moduleMap到 remoteEntry 中。 - 主应用入口执行的时候,根据依赖链,动态加载各个依赖模块。
- remoteEntry 被引入后可以让动态加载在 remote 的依赖中去找响应的依赖使用异步加载。
- 也就是只要主应用能够通过 runtime 将 remoteEntry 加载进来,相关的前置依赖和各个 chunk 就能够自然被引入,不会多也不会少,后面一切也自然都能跑的起来了。
说白了,开发者在 webpack 配置中将需要导入导出的配置写,webpack 根据配置将各个导出模块作为 entry 单独导出,并生成一个 remoteEntry 包含这个 moduleMap,这样准备好子应用/远端应用,不必和主应用一起打包了。主应用在配置了导入的时候,根据配置引入远端的 remoteEntry,根据 moduleMap 来提前将依赖的远端 chunk 加载好,后续的主应用自然能够无感知引用了。
官方也在文档中提到了对于组件库的用例,只不过是强调可以组件库的修改单独编译,不需要重新构建主应用。
第三方依赖提前构建的目的和 Module Federation 都是避免非必要程序的构建,只不过将相对独立的模块、业务、组件库、单页等官方场景换成了具体的某一个npm依赖。
有很多种实现途径和方法,我这里参考了 umijs/mfsu 和 taro-prebundle 的实现,但是落地代码不同。
- 找到 entry
- 扫描出所有的 node_modules 依赖: scanImports
- 针对不同的包形式,准备对应共享入口文件,准备好 remoteApp
- 新设一个依赖包的 webpack 配置,考虑依赖、引用和配置相关逻辑
- 将 remoteApp 打包成 Module Federation 格式,得到预编译格式的 chunk 文件
- 主应用修改第三方依赖的引用方法
- 主应用webpack 配置调整添加 Module Federation 相关,设置 remotes 引入方式
- 主应用入口文件覆盖改动
对 node_modules 依赖的打包不一定要用 esbuild,任意的打包工具都可以,umi用的是 babel,taro 参考 vite 用的都是 esbuild。esbuild 速度快,但 babel 的 runtime 比较开放,更方便介入编译过程。
umi、taro 还有 vite 的实现,虽然都说可以单独使用,但是源码里面与框架的耦合还是比较大,同时因为考虑太多异常和边界,没有那么一目了然。所以下面简单说下我怎么做的。方案相比他们更简单粗暴,不过还是能用的。
以下代码大多只能提供思路,而且每个人的配置项不同,需求不同,具体实现请自己调试:
这一步很简单,毕竟 webpack 的打包也是需要的,项目中是多页面的应用,所以有单独的入口收集方法。
// 决定了编译和收集器从哪开始const entries = paths.appIndexJs.map((p) => p.path);const appPath = paths.appPath;
esbuild 实现这个会非常快,onResolve 的各种处理很方便,快速跑一遍就行。
- 收集第三方依赖的名称
- 根据名称获得一个规范名,方便后续文件生成
- resolve 出目标入口文件,方便后续分析
- 获取包格式
主要的文件分三类:跳过纯资源类,收集第三方依赖,递归非第三方的项目文件。
await Promise.all(entries.map(entry => esbuild.build({absWorkingDir: appPath,entryPoints: [entry],bundle: true,write: false, // 只是分析引用关系,不需要真的写入磁盘format: 'esm',plugins: [ scanImportsPlugin ]})));const scanImportsPlugin = {name: 'scanImports',setup(build) {// 确认不需要处理的引用,直接标记外部模块跳过build.onResolve(({ filter: new RegExp(`\\.(${[/*[非jsx?tsx?之类]等等*/].join('|')})$`) }), utils.externalModule);// 纯粹的第三方引用,以字母或者@开头,不包含 https://类,不带文件前缀,不带[http,ftp,E:]://类的资源资源build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path: id, importer }) => {// 已经收集的依赖直接跳过if (deps.has(id)) return { path: id, external: true };const resolvedPath = await resolve(path.dirname(importer), id);if (resolvedPath.includes('node_modules')) {// 这是我们最主要关心的地方: 第三方依赖if (/\.[jt]sx?$/.test(resolvedPath)) {// 用 es-module-lexer(快速词法分析库) 检查 ES 的 exports 语法// 但是 "export * from" 语法还是需要单独检查,后续有用let fileContent = await fs.promises.readFile(resolvedPath, 'utf8')// 获取 es 导出信息,后续用来判定是不是 ems,以及打包的提取方法const exportsData = es_module_lexer.parse(fileContent);let hasReExports = false;for (const { ss, se } of exportsData[0]) {const exp = fileContent.slice(ss, se);if (/export\s+\*\s+from/.test(exp)) {hasReExports = true;}}const hash = utils.getPkgHash(id, resolvedPath);// flatten id: 将id中的斜杠替换成下划线// 同时也加入缓存 hashconst flat = utils.flattenId(id);const flatId = flat + '_' + hash;const moduleMeta = {imports: exportsData.imports || [],exports: exportsData.exports || [],facade: exportsData.facade || false,hasReExports: hasReExports,}let js = '';const importsList = moduleMeta.imports;const exportsList = moduleMeta.exports;// 根据 exportsData 判断是 ES 还是 CommonJS 的模块if (!importsList.length && !exportsList.length) {/** CommonJS */// console.log('rawId cjs -: ', rawId)moduleMeta.needInterop = true;js = `module.exports = require("${id}")`;} else {/** ESM */if (exportsList.includes('default')) {// export defaultjs += `import d from "${id}";export default d;`;}if (hasReExports ||exportsList.length > 1 ||exportsList[0] !== 'default') {// console.log('rawId esm *: ', rawId)// export * from 'xx'// export const xxjs += `export * from "${id}";`;} else {// console.log('rawId esm $: ', rawId)}}deps.set(id, {id,flatId,exposeCode: js,});}// 收集过后直接跳过,不考虑依赖的依赖return { path: id, external: true };} else if (constant.assetsRE.test(resolvedPath)) {return { path: id, external: true };} else return { path: resolvedPath };});// catch all 兜底的引入build.onResolve({ filter: /.*/ }, async ({ path: id, importer }) => {const resolvedPath = await resolve(path.dirname(importer), id);return { path: resolvedPath };});}}
最终产物得到一个依赖列表,包含着第三方依赖的名称、文件hash、重新导出的引用文件,以便后续逻辑
deps = [{id: 'dayjs',flatId: 'dayjs_a1fc7db8',exposeCode: 'module.exports = require("dayjs")',},// ...]
webpack Module Federation 的 webpack 配置中需要一个 expose 的字段,里面需要包含你想把哪些组件暴露出去,这个文件我们需要生成。
flattenDeps.forEach(dep => {const exposePath = path.join(bundlesDir, dep.flatId + '.expose.js')if(!fs.existsSync(exposePath)) {fs.writeFileSync(exposePath, dep.exposeCode)}})
明白 Module Federation 方案架构的应该意识到了,这样我们相当于准备了一个新的 webpack 项目,里面包含了一堆模块,每个模块实际上都是直接引用并根据 esm 或 commonJS 规范导出的第三方模块。为我们下一步的工作做好了准备。
之前花了非常多心思设计了一个 esbuild 先 bundle 化之后再 expose 出去的方案,会出现共享依赖重复打包,还有多实例的问题,所以我们这里简单处理更有效,让 webpack 后边帮我们处理共享依赖的问题。
最终产物:
./react_60f57521.expose.js./react-dom_ee488318.expose.js./dayjs_a1fc7db8.expose.js
上一步我们准备好了这个空壳的 remoteApp,这里我们开始打包出符合 Module Federation 的逻辑产物,也就是 remote-chunk 文件:
- 创建一个新的 webpack 配置文件,只包含输入输出,输入不重要,空文件即可
- 将所有上面生成的共享依赖的入口作为配置 exposes 参数即可
- 通过一个独立的 loader 将打包过程中的共享依赖给处理下,一定程度上避免重复打包问题(这一步可能没有必要,不过这里还是加上)
- 可以用 esbuild-loader,或者 swc 加速编译
- 然后 webpack 编译
- 在 output 文件夹就能得到我们的 remote 的东西
const webpackConfig = {entry: path.join(__dirname, './webpack/index.js'),output: {path: remoteEntryPath, // 设为 public 目录filename: "index.bundle.js",},// mode: "development",// devtool: 'source-map',cache: {type: 'filesystem',cacheDirectory: webpackEntryPath,buildDependencies: {config: Object.values(exposes)}},module: {rules: [{test: /\.(js|mjs|jsx|ts|tsx)$/,use: [{loader: require.resolve('esbuild-loader'),options: {loader: 'tsx',target: 'esnext',},},{loader: require.resolve('./import-to-remote-loader.js'),options: {type: 'remote',}}],},]},plugins: [new ModuleFederationPlugin({name: "prebundle_remote",filename: "remoteEntry.js",exposes: exposes, // './dayjs': 'E:\\u-codes\\hola\\static-sites\\node_modules\\.pnpm\\dayjs@1.11.8\\node_modules\\dayjs\\dayjs.min.js'// 开发环境,目前不考虑共享依赖,只用了 cjs 和 esm,应该不会出现重复执行的模块// shared: ['react', 'react-dom']}),]}await new Promise((resolve, reject) => {webpack(remoteWebpackConf, (err, stats) => {if (err || stats.hasErrors()) {reject(err || stats.toJson().errors)} resolve()})})
这里耐心等待下,编译工作根据你项目三方依赖多少有不同的时间,好在这些编译工作以后可能就省下了,只不过你需要根据自己项目依赖变动,缓存或者跳过编译。
// output 文件夹将会出现我们所需的文件,根据不同的配置可能目录不同./remoteEntry.js./157.index.bundle.js// mode: "development" 的情况下,不会被压缩处理,文件名也有可读性./_prebundle_bundles_dayjs_a1fc7db8_js.index.bundle.js
然后将这些文件,放到我们项目的 public 文件夹中,或者修改一下 webpack-dev-server 让我们的目标页面直接请求到这堆文件就行。
这一步产出的东西目的在:在这个目录里面是我们已经用 webpack 编译好的项目,这个项目配置了 remote 共享依赖,暴露出来的共享模块是我们所以需要的第三方依赖。
我们的远端模块已经准备好了,主应用也需要额外处理。
修改依赖引用路径
主应用使用子应用里面的模块,不再能直接引用了,需要增加 remote 前缀:
// 直接引用第三方模块import dayjs from 'dayjs'// 用联邦模块的方法引入我们之前打包好的,prebundle 是我们的 remote 名称import dayjs from 'prebundle/dayjs'
所以我们还需要额外处理我们代码里面的语法,不然 webpack 的 compiler 和 runtime 是没办法知道我们预打包好的模块的。
存在两种方法,一种是静态分析模式,纯文本形式找出项目源码中的 import,然后替换。另一种是在编译阶段,对模块的参数进行修改。webpack 或者 babel 都有丰富的插件或机制可以做。umi 通过 babel plugin 提供了两种模式,taro 应该是在 webpack 插件中魔改做了编译阶段的部分。
我这边用的是 esbuild-loader,esbuild-loader 用的 transform 方法并不支持插件,不过我们可以配置多层 loader,在把文件传给 webpack 之前把文件内容给替换了:
// webpack.config.js{test: /\.(js|mjs|jsx|ts|tsx)$/,include: paths.appSrc,use: [{loader: require.resolve('esbuild-loader'),},{loader: require.resolve('./help/prebundle/import-to-remote-loader.js'),}]},
// 通过最简单的正则替换来做,满足使用了目前是async function PrebundleModuleRenameLoader(source) {var flattenDeps = readFlattenDeps()if (!flattenDeps) return sourceconst regexes = [new RegExp(`(require\\(['|"])(.*)(.*?['|"]\\))`, 'g'),new RegExp(`(from\\s+['|"])(.*)(.*?['|"])`, 'g')];regexes.forEach(reg => {let match;while (match = reg.exec(source)) {const before = match[1];const hit = match[2];const after = match[3];if (flattenDeps.indexOf(hit) > -1) {const wrappedTo = `${before}prebundle/${hit}${after}`;source = source.replace(match[0], wrappedTo);}}});return source}exports['default'] = PrebundleModuleRenameLoader;
配置插件
这里没啥问题,把官方文档和示例看一下也基本上能配出来
webpackConf.plugins.push(new ModuleFederationPlugin({name: 'ubug_app',//远程访问地址入口remotes: {[prebundleNameID]: prebundleNameID + "@" + url + "/prebundle/remoteEntry.js",},shared: [{ react: { singleton: true }, 'react-dom': { singleton: true } }],}))
通过上面几个步骤项目能够编译,而且你也能够看到编译速度有多快,但是实际把项目跑起来才会发现报错了。有点坑的点在于这个 remote bundle 是一个异步 chunk,这里仔细想个三秒钟...
Module Federation 目的是应用之间的互相引用,远端的模块不能作为主应用的直接依赖,大白话就是,需要用到远端模块不能是被打包到立即执行的入口里面,只能异步加载远程组件后使用。所以这里我们的使用场景必须要处理这个异步的点。官方文档也提供了一些 方法,我们采用最简单直接的额外一个 bootstrap.js 的方法来处理这个问题,taro 的方案也是这个,用 VirtualModule 做个切换,把整个主应用都放到异步里面。
webpackConf.plugins.push(VirtualModule)
VirtualModule.apply(compiler);appIndexJs.forEach((entry) => {const { dir, name } = path.parse(entry.path)// remove suffixconst filePath = path.join(dir, name)const originCode = fs.readFileSync(entry.path, { encoding: 'utf-8' })const bootPath = `${filePath}.boot.tsx`VirtualModule.writeModule(bootPath, originCode)const code = 'import("./' + name + '.boot")'VirtualModule.writeModule(entry.path, code)})
完美实现,不需要手动修改自己源码里面的东西,不过 Webpack5 和 VirtualModule 有点不太对付,会出现 首次编译重复编译的问题,虽然不影响体验,但是也很奇怪,手动 hack 下也可以解决问题。
不出意外,应用成功启动了,实际项目文件里面也能够正确加载执行我们的远端模块了。

webpack 的配置和工程化非常复杂,如果你对 chunk 或者 cache 或者 optimization 或者 plugin 做了太多介入的话,有可能会出现问题。
除了说的这些好处,还有一些细节需要处理:react和react-dom必须配置 share、runtimeChunk 配置需要关闭、第三方依赖中不规范引用重复打包、非js类文件需要额外处理等等。除此之外,还要检测依赖变更,让缓存失效重新编译等等配套功能,上面代码里面没有涉及,也不是本文重点。。
上面的技术方案比较简陋,而且很多地方没考虑到或者有更简单方法,毕竟整个依赖打包也是非常复杂的,主要展示的也是个思路。不过最终结果还是好的,节省了开发环境的编译时间和内存。至于存在的重复打包、不规范依赖、部分配置失效、缓存控制等等,后面有需要再优化。
跑个全部项目(34个 entry 的多入口项目),无缓存和带缓存的编译结果:


上面的实现步骤是第一版,后面根据更大视角的思考,采用了另一个 prebundle 的方案,更规范也更通用,不过项目复杂度稍高,更方便后期从 webpack 转 esbuild 方案,也不是一篇文章能说完的,有时间再另开系列讨论。
同时业内也热火朝天的把工具链朝着
rust 方向转,我很看好,等大厂有了阶段性成果,我也很愿意在这个项目中尝试一下。esbuild 之类的工具,预编译之类的思路,这些东西好不好用?我只能说真香,但是你项目的膨胀速度和开发体验出现问题了,这些东西可能并不是核心问题,总有更好更快的开发工具。
规范的开发、专业的协作,合理的项目分隔、恰当的分包和底层库提取,足够的开发宽容度和技术债清理,这些都是开发中需要重视的东西,不能指望着几行代码能解决你项目的无限膨胀。
共勉。
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/prebundle)