202 - 《Umi 最佳实践:拆包策略》

发布于 2022年10月25日

计划近期每周更新 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.splitChunksoptimization.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,几十个请求不算问题。但是,对于大型项目或巨型项目来说,需要考虑更合适的方案

内容预览已结束

此内容需要会员权限。请先登录以查看完整内容。