详解 SplitChunks,详解 Webpack 分包策略

1,051 阅读9分钟

深入理解分包产物之一 Chunk

Chunk 是 Webpack 用于组织、管理、优化最终产物,在构建流程进入生成(Seal)阶段后:

  1. Webpack 根据entry配置创建若干 Chunk 对象
  2. 遍历构建阶段(Make)找到的所有 Module 对象,同一 entry 下的模块分配到该 entry 对应的 Chunk
  3. 遇到异步模块则创建新的 Chunk 对象,并将异步模块放入该 Chunk。
  4. 分配完毕后,根据 SplitChunksPlugin 的算法进一步对这些 Chunk 执行裁剪、拆分、合并、代码调优
  5. 最后,将这些 Chunk 输出成最终的产物(Asset)文件。

Chunk 一方面作为 Module 容器,根据一系列默认 分包策略 决定哪些模块应该合并在一起打包;另一方面根据 splitChunks 设定的 策略 优化分包,决定最终输出多少产物文件。

Chunk 分包结果的好坏直接影响了最终应用性能,Webpack 默认会将以下三种模块做分包处理:

  • Initial Chunk:entry 模块及相应子模块打包成 Initial Chunk;
  • Async Chunk:通过 import('./xx') 等语句导入的异步模块及相应子模块组成的 Async Chunk;
  • Runtime Chunk:运行时代码抽离成 Runtime Chunk,可通过 entry.runtime 配置项实现。

直接使用 Initial Chunk 或者 Async Chunk 会面临以下三个问题

  1. 模块重复打包:假如多个 Chunk 同时依赖同一个 Module,那么这个 Module 会被不受限制地重复打包进这些 Chunk。
  2. 资源冗杂:客户端必须等待整个应用的代码包都加载完毕才能启动运行,但可能用户当下访问的内容只需要使用其中一部分代码。
  3. 缓存失效:将所有资源打成一个包后,所有改动 —— 即使只是修改了一个字符,客户端都需要重新下载整个代码包,缓存命中率极低。

这些问题可以通过SplitChunksPlugin解决,大致策略如下:

  • 将被多个 Chunk 依赖的包分离成独立的 Chunk,防止资源重复
  • node_modules中的资源通常变动较少,可以抽成一个独立的包,业务代码的频繁变动不会导致这部分第三方库资源缓存失效,被无意义地重复加载

SplitChunksPlugin简介

SplitChunksPlugin 能够基于一些更灵活、合理的启发式规则将 Module 编排仅不同的 Chunk,最终构建出性能更佳,缓存更友好的应用产物。

  • SplitChunksPlugin 支持根据 Module 路径、Module 被引用次数、Chunk 大小、Chunk 请求数等决定是否对 Chunk 做进一步拆解,实现:
    • 单独打包某些特定路径的内容,例如node_modules打包为vendors
    • 单独打包使用频率较高的文件
  • SplitChunksPlugin 提供了 cacheGroup 概念,用于对不同特点的资源做分组处理,并为这些分组设置更有针对性的分包规则
  • SplitChunksPlugin 内置了defaultdefaultVendors两个chacheGroup,提供一些开箱即用的分包特性:
    • node_modules资源会命中defaultVendors规则,并被单独打包
    • 只有包体积超过 20kb 的 Chunk 才会被单独打包
    • 加载 Async Chunk 所需请求数不得超过 30 个
    • 加载 Initial Chunk 所需请求数不得超过 30 个

splitChunks主要有两种类型的配置: - minChunks/minSize/maxInitialRequest  等分包条件,满足这些条件的模块都会被执行分包; - cacheGroup :用于为特定资源声明特定分包条件,例如可以为 node_modules 包设定更宽松的分包条件。

基本设置

设置分包范围

首先,SplitChunksPlugin 默认情况下只对 Async Chunk 生效,我们可以通过 splitChunks.chunks 调整作用范围,该配置项支持如下值:

  • 字符串 'all' :对 Initial Chunk 与 Async Chunk 都生效,建议优先使用该值;
  • 字符串 'initial' :只对 Initial Chunk 生效;
  • 字符串 'async' :只对 Async Chunk 生效;
  • 函数 (chunk) => boolean :该函数返回 true 时生效;

例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
}

设置为 all 效果最佳,此时 Initial Chunk、Async Chunk 都会被 SplitChunksPlugin 插件优化。

根据 Module 使用频率分包

SplitChunksPlugin 支持按 Module 被 Chunk 引用的次数决定是否分包,借助这种能力我们可以轻易将那些被频繁使用的模块打包成独立文件,减少代码重复。

用法很简单,只需用 splitChunks.minChunks 配置项设定最小引用次数,例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 设定引用次数超过 2 的模块才进行分包
      minChunks: 2
    },
  },
}

注意,这里“被 Chunk 引用次数”并不直接等价于被 import 的次数,而是取决于上游调用者是否被视作 Initial Chunk 或 Async Chunk 处理。

// common.js
export default "common chunk";

// async-module.js
import common from './common'

// entry-a.js
import common from './common'
import('./async-module')

// entry-b.js
import common from './common'

其中,entry-aentry-b 分别被视作 Initial Chunk 处理;async-module 被 entry-a 以异步方式引入,因此被视作 Async Chunk 处理。那么对于 common 模块来说,分别被三个不同的 Chunk 引入,此时引用次数为 3。

common 模块命中 optimization.splitChunks.minChunks = 2 规则,因此该模块可能会被单独分包。

强调一下,上面说的是“可能”,minChunks 并不是唯一条件,此外还需要满足诸如 minSizechunks 等限制条件才会真正执行分包。

限制分包数量

在 minChunks 基础上,为防止最终产物文件数量过多导致 HTTP 网络请求数剧增,反而降低应用性能,Webpack 还提供了 maxInitialRequest/maxAsyncRequest 配置项,用于限制分包数量:

  • maxInitialRequest:用于设置 Initial Chunk 最大并行请求数;
  • maxAsyncRequests:用于设置 Async Chunk 最大并行请求数。

这里所说的“请求数”,是指加载一个 Chunk 时所需要加载的所有分包数。例如对于一个 Chunk A,如果根据分包规则(如模块引用次数、第三方包)分离出了若干子 Chunk A[¡],那么加载 A 时,浏览器需要同时加载所有的 A[¡],此时并行请求数等于 ¡ 个分包加 A 主包,即 ¡+1。

若 minChunks = 2 ,则 common-1 、common-2 同时命中 minChunks 规则被分别打包,浏览器请求 entry-b 时需要同时请求 common-1 、common-2 两个分包,并行数为 2 + 1 = 3,此时若 maxInitialRequest = 2,则分包数超过阈值,SplitChunksPlugin 会 放弃 common-1common-2 中体积较小的分包maxAsyncRequest 逻辑与此类似,不在赘述。

并行请求数关键逻辑总结如下:

  • Initial Chunk 本身算一个请求;
  • Async Chunk 不算并行请求;
  • 通过 runtimeChunk 拆分出的 runtime 不算并行请求;
  • 如果同时有两个 Chunk 满足拆分规则,但是 maxInitialRequests(或 maxAsyncRequest) 的值只能允许再拆分一个模块,那么体积更大的模块会被优先拆解。

限制分包体积

除上面介绍的 minChunks —— 模块被引用次数,以及 maxXXXRequest —— 包数量,这两个条件外,Webpack 还提供了一系列与 Chunk 大小有关的分包判定规则,借助这些规则我们可以实现当包体过小时直接取消分包 —— 防止产物过"碎";当包体过大时尝试对 Chunk 再做拆解 —— 避免单个 Chunk 过大。

这一规则相关的配置项有:

  • minSize: 超过这个尺寸的 Chunk 才会正式被分包;
  • maxSize: 超过这个尺寸的 Chunk 会尝试进一步拆分出更小的 Chunk;
  • maxAsyncSize: 与 maxSize 功能类似,但只对异步引入的模块生效;
  • maxInitialSize: 与 maxSize 类似,但只对 entry 配置的入口模块生效;
  • enforceSizeThreshold: 超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 Size 限制。

那么,结合前面介绍的两种规则,SplitChunksPlugin 的主体流程如下:

  1. SplitChunksPlugin 尝试将命中 minChunks 规则的 Module 统一抽到一个额外的 Chunk 对象;

  2. 判断该 Chunk 是否满足 maxInitialRequests 阈值,若满足则进行下一步;

  3. 判断该 Chunk 资源的体积是否大于上述配置项minSize声明的下限阈值;

    • 如果体积小于 minSize 则取消这次分包,对应的 Module 依然会被合并入原来的 Chunk
    • 如果 Chunk 体积大于 minSize 则判断是否超过 maxSizemaxAsyncSizemaxInitialSize 声明的上限阈值,如果超过则尝试将该 Chunk 继续分割成更小的部分

提示:虽然 maxSize 等阈值规则会产生更多的包体,但缓存粒度会更小,命中率相对也会更高,配合持久缓存与 HTTP2 的多路复用能力,网络性能反而会有正向收益。

缓存组 cacheGroups 简介

上述 minChunksmaxInitialRequestminSize 都属于分包条件,决定是否对什么情况下对那些 Module 做分包处理。此外, SplitChunksPlugin 还提供了 cacheGroups 配置项用于为不同文件组设置不同的规则,例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendors: {
            test: /[\/]node_modules[\/]/,
            minChunks: 1,
            minSize: 0
        }
      },
    },
  },
};

示例通过 cacheGroups 属性设置 vendors 缓存组,所有命中 vendors.test 规则的模块都会被归类 vendors 分组,优先应用该组下的 minChunksminSize 等分包配置。

cacheGroups 支持上述 minSice/minChunks/maxInitialRequest 等条件配置,此外还支持一些与分组逻辑强相关的属性,包括:

  • test:接受正则表达式、函数及字符串,所有符合 test 判断的 Module 或 Chunk 都会被分到该组;
  • type:接受正则表达式、函数及字符串,与 test 类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如 type = 'json' 会命中所有 JSON 文件;
  • idHint:字符串型,用于设置 Chunk ID,它还会被追加到最终产物文件名中,例如 idHint = 'vendors' 时,输出产物文件名形如 vendors-xxx-xxx.js ;
  • priority:数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到 priority 更大的组。

缓存组的作用在于能为不同类型的资源设置更具适用性的分包规则,一个典型场景是将所有 node_modules 下的模块统一打包到 vendors 产物,从而实现第三方库与业务代码的分离。

Webpack 提供了两个开箱即用的 cacheGroups,分别命名为 default 与 defaultVendors,默认配置:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        default: {
          idHint: "",
          reuseExistingChunk: true,
          minChunks: 2,
          priority: -20
        },
        defaultVendors: {
          idHint: "vendors",
          reuseExistingChunk: true,
          test: /[\/]node_modules[\/]/i,
          priority: -10
        }
      },
    },
  },
};

这两个配置组能帮助我们:

  • 将所有 node_modules 中的资源单独打包到 vendors-xxx-xx.js 命名的产物
  • 对引用次数大于等于 2 的模块 —— 也就是被多个 Chunk 引用的模块,单独打包

开发者也可以将默认分组设置为 false,关闭分组配置,例如:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        default: false
      },
    },
  },
};