202 - 《Umi 最佳实践:拆包策略》
计划近期每周更新 1 篇 Bigfish 2022 版的最佳实践,这是第 4 篇。
现有方案
Umi 3 默认禁用了拆包,把所有文件合并到一起,产物只有一个 umi.js 和一个 umi.css。好处包括,1)部署容易,不需要考虑 publicPath,2)通过 proxy 代理到本地调试时容易,规则简单好写,等等。坏处也很明显,性能差,需要把所有页面的 JS 和 CSS 加载完成后,才开始解析、执行和渲染页面。Umi 3 同时还提供了 dynamicImport 配置,用于开启基于路由的拆包(code splitting)。
那么,拆和不拆,到底哪个是最佳实践?我觉得不拆是提升了 DX(开发体验) 却损害了 UX(用户体验),所以我觉得拆更好一些。所以,Umi 4 反着来,默认做基于路由的拆包,同时通过 FAQ 让开发者可以通过安装和配置额外的 babel 插件手动切换到不拆的模式。
Umi 4 默认支持基于路由的拆包,同时开发者可以通过 import()
手动拆包,前者也是基于 import()
语法。这种拆包的方式在 webpack 里叫 async chunk。此外,webpack 还支持通过 optimization.splitChunks
和 optimization.runtimeChunk
配置公共 chunk 的提取和组织,而这两个配置 Umi 都没有提供默认值,用的是 Webpack 的默认策略。
问题
Umi 3 + Webpack 4 默认的 cacheGroups 配置如下。
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
},
},
这个配置有较为明显的缺陷,即 vendors 里没有配置 reuseExistingChunk:true
。这会导致重复依赖的出现,比如 antd 组件可能同时出现在多个不同路由的 async chunk 产物里,所以 Umi 3 文档里有一篇是关于如何解这个重复问题的。(注:webpack 5 的默认配置里已加 reuseExistingChunk,所以不会再有这个问题。)重复依赖危害较大,包括:1)产物尺寸大幅增加,2)打包慢,3)潜在的加载问题,比如前一篇最佳实践文件中提到的「覆盖样式切换路由后被默认样式再次覆盖」的问题。
那么,是否解了这个问题后,使用 webpack 5 默认的 cacheGroups 配置就是最佳的方式了?我理解并不是,默认的 cacheGroups 还存在几个问题。1)拆包不合理,比如默认 chunk 是 async 而不是 all,没有包含 initial chunk,在 Umi 框架里会导致 umi.js 过大,从而影响初始页面的加载,2)没有合理利用缓存,每次发布构建时都会产出新的 chunk hash,从而导致缓存失效,但有些依赖其实非常稳定,比如 react、lodash 等,他们并不需要每次发布后都更新缓存。
新方案
社区和我们在项目的实践过程中,发现有一些大家在用的拆包策略。
一、大 vendors 策略
{
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
name: 'vendors',
}
}
把所有依赖合到一起,绝对不会有重复。同时缺点是,1)单文件的尺寸过大,2)毫无缓存效率可言。
info - File sizes after gzip:
215.74 kB dist/vendors.js
17.67 kB (+17 B) dist/umi.js
581 B (-573 B) dist/p__foo.async.js
579 B (-574 B) dist/p__index.async.js
282 B dist/p__index.chunk.css
282 B dist/p__foo.chunk.css
二、一个依赖一个包策略
{
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
name(module) {
// 这里是简单示例,实际上还要针对 npm client 产物格式进行处理,比如 pnpm 和 cnpm 的命名方式就不同
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `npm.${packageName.replace('@', '')}`;
},
}
}
和策略 1 类似,不同的是把依赖按 package name + version 进行拆分,算是解了策略 1 的尺寸和缓存效率问题。但同时带来的潜在问题是,可能导致请求较多。我的理解是,对于非大型项目来说其实还好,因为,1)单个页面的请求不会包含非常多的依赖,2)基于 HTTP/2,几十个请求不算问题。但是,对于大型项目或巨型项目来说,需要考虑更合适的方案